From 0286bd6aef348531b8fab2b912ea5de4a161db05 Mon Sep 17 00:00:00 2001 From: David Stansby Date: Tue, 29 Jul 2025 20:11:10 +0100 Subject: [PATCH 1/5] Prevent mode='r+' from creating new directories --- src/zarr/storage/_common.py | 2 +- src/zarr/storage/_local.py | 42 +++++++++++++++++++++++++++++++++---- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/zarr/storage/_common.py b/src/zarr/storage/_common.py index 3a63b30e9b..66a53c19df 100644 --- a/src/zarr/storage/_common.py +++ b/src/zarr/storage/_common.py @@ -358,7 +358,7 @@ async def make_store_path( elif isinstance(store_like, Path): # Create a new LocalStore - store = await LocalStore.open(root=store_like, read_only=_read_only) + store = await LocalStore.open(root=store_like, mode=mode) elif isinstance(store_like, str): # Either a FSSpec URI or a local filesystem path diff --git a/src/zarr/storage/_local.py b/src/zarr/storage/_local.py index 43e585415d..32a9d1a24b 100644 --- a/src/zarr/storage/_local.py +++ b/src/zarr/storage/_local.py @@ -5,7 +5,7 @@ import os import shutil from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Self from zarr.abc.store import ( ByteRequest, @@ -16,7 +16,7 @@ ) from zarr.core.buffer import Buffer from zarr.core.buffer.core import default_buffer_prototype -from zarr.core.common import concurrent_map +from zarr.core.common import AccessModeLiteral, concurrent_map if TYPE_CHECKING: from collections.abc import AsyncIterator, Iterable @@ -102,16 +102,50 @@ def __init__(self, root: Path | str, *, read_only: bool = False) -> None: ) self.root = root - def with_read_only(self, read_only: bool = False) -> LocalStore: + def with_read_only(self, read_only: bool = False) -> Self: # docstring inherited return type(self)( root=self.root, read_only=read_only, ) - async def _open(self) -> None: + @classmethod + async def open( + cls, root: Path | str, *, read_only: bool = False, mode: AccessModeLiteral | None = None + ) -> Self: + """ + Create and open the store. + + Parameters + ---------- + root : str or Path + Directory to use as root of store. + read_only : bool + Whether the store is read-only + mode : + Mode in which to create the store. This only affects opening the store, + and the final read-only state of the store is controlled through the + read_only parameter. + + Returns + ------- + Store + The opened store instance. + """ + if mode is not None: + read_only_creation = mode in ["r", "r+"] + else: + read_only_creation = read_only + store = cls(root, read_only=read_only_creation) + await store._open() + return store.with_read_only(read_only) + + async def _open(self, *, mode: AccessModeLiteral | None = None) -> None: if not self.read_only: self.root.mkdir(parents=True, exist_ok=True) + + if not self.root.exists(): + raise FileNotFoundError(f"{self.root} does not exist") return await super()._open() async def clear(self) -> None: From 01cf88490d9a6ccfef06e4077931f7c503a82c9c Mon Sep 17 00:00:00 2001 From: David Stansby Date: Tue, 29 Jul 2025 20:11:27 +0100 Subject: [PATCH 2/5] Add test --- tests/test_api.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_api.py b/tests/test_api.py index 3668ef306a..7ed1236da0 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -333,8 +333,12 @@ def test_open_with_mode_r(tmp_path: Path) -> None: def test_open_with_mode_r_plus(tmp_path: Path) -> None: # 'r+' means read/write (must exist) + new_store_path = tmp_path / "new_store.zarr" + assert not new_store_path.exists(), "Test should operate on non-existent directory" with pytest.raises(FileNotFoundError): - zarr.open(store=tmp_path, mode="r+") + zarr.open(store=new_store_path, mode="r+") + assert not new_store_path.exists(), "mode='r+' should not create directory" + zarr.ones(store=tmp_path, shape=(3, 3)) z2 = zarr.open(store=tmp_path, mode="r+") assert isinstance(z2, Array) From 1c03056a46ede284b5ee9c79501c700b764ca17c Mon Sep 17 00:00:00 2001 From: David Stansby Date: Mon, 4 Aug 2025 11:14:32 +0100 Subject: [PATCH 3/5] Open store for mode = r+ --- src/zarr/storage/_local.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/zarr/storage/_local.py b/src/zarr/storage/_local.py index 32a9d1a24b..1229ec316a 100644 --- a/src/zarr/storage/_local.py +++ b/src/zarr/storage/_local.py @@ -132,13 +132,19 @@ async def open( Store The opened store instance. """ + # If mode = 'r+', want to open in read only mode (fail if exists), + # but return a writeable store if mode is not None: read_only_creation = mode in ["r", "r+"] else: read_only_creation = read_only store = cls(root, read_only=read_only_creation) await store._open() - return store.with_read_only(read_only) + + # Set read_only state + store = store.with_read_only(read_only) + await store._open() + return store async def _open(self, *, mode: AccessModeLiteral | None = None) -> None: if not self.read_only: From 4dd3f7ecdbfd8260a8b0e17a94888e713ee0e508 Mon Sep 17 00:00:00 2001 From: David Stansby Date: Wed, 20 Aug 2025 08:32:20 +0100 Subject: [PATCH 4/5] Add changelog --- changes/3307.bugfix.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/3307.bugfix.rst diff --git a/changes/3307.bugfix.rst b/changes/3307.bugfix.rst new file mode 100644 index 0000000000..069205fcc6 --- /dev/null +++ b/changes/3307.bugfix.rst @@ -0,0 +1 @@ +Opening an array or group with ``mode="r+"`` will no longer create new arrays or groups. From 59aeb23fd7d88f3188c25fed9a4444df34ca1de6 Mon Sep 17 00:00:00 2001 From: David Stansby Date: Sun, 24 Aug 2025 10:27:37 +0100 Subject: [PATCH 5/5] Fix errr raised when opening non-existent path --- tests/test_api.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_api.py b/tests/test_api.py index 7ed1236da0..11ee8c1afc 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -45,7 +45,6 @@ from zarr.errors import ( ArrayNotFoundError, MetadataValidationError, - NodeNotFoundError, ZarrDeprecationWarning, ZarrUserWarning, ) @@ -191,7 +190,7 @@ async def test_open_array(memory_store: MemoryStore, zarr_format: ZarrFormat) -> assert z.read_only # path not found - with pytest.raises(NodeNotFoundError): + with pytest.raises(FileNotFoundError): zarr.api.synchronous.open(store="doesnotexist", mode="r", zarr_format=zarr_format)