Skip to content

Commit ef15e20

Browse files
authored
[v3] Feature: Store open mode (#1911)
* wip * feature(store): set open mode on store initialization
1 parent fc7fa4f commit ef15e20

File tree

11 files changed

+116
-33
lines changed

11 files changed

+116
-33
lines changed

src/zarr/abc/store.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,31 @@
33
from typing import Protocol, runtime_checkable
44

55
from zarr.buffer import Buffer
6-
from zarr.common import BytesLike
6+
from zarr.common import BytesLike, OpenMode
77

88

99
class Store(ABC):
10+
_mode: OpenMode
11+
12+
def __init__(self, mode: OpenMode = "r"):
13+
if mode not in ("r", "r+", "w", "w-", "a"):
14+
raise ValueError("mode must be one of 'r', 'r+', 'w', 'w-', 'a'")
15+
self._mode = mode
16+
17+
@property
18+
def mode(self) -> OpenMode:
19+
"""Access mode of the store."""
20+
return self._mode
21+
22+
@property
23+
def writeable(self) -> bool:
24+
"""Is the store writeable?"""
25+
return self.mode in ("a", "w", "w-")
26+
27+
def _check_writable(self) -> None:
28+
if not self.writeable:
29+
raise ValueError("store mode does not support writing")
30+
1031
@abstractmethod
1132
async def get(
1233
self, key: str, byte_range: tuple[int | None, int | None] | None = None
@@ -147,6 +168,10 @@ def list_dir(self, prefix: str) -> AsyncGenerator[str, None]:
147168
"""
148169
...
149170

171+
def close(self) -> None: # noqa: B027
172+
"""Close the store."""
173+
pass
174+
150175

151176
@runtime_checkable
152177
class ByteGetter(Protocol):

src/zarr/common.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
Selection = slice | SliceSelection
2828
ZarrFormat = Literal[2, 3]
2929
JSON = None | str | int | float | Enum | dict[str, "JSON"] | list["JSON"] | tuple["JSON", ...]
30+
OpenMode = Literal["r", "r+", "a", "w", "w-"]
3031

3132

3233
def product(tup: ChunkCoords) -> int:

src/zarr/store/core.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from zarr.abc.store import Store
77
from zarr.buffer import Buffer
8+
from zarr.common import OpenMode
89
from zarr.store.local import LocalStore
910

1011

@@ -60,13 +61,18 @@ def __eq__(self, other: Any) -> bool:
6061
StoreLike = Store | StorePath | Path | str
6162

6263

63-
def make_store_path(store_like: StoreLike) -> StorePath:
64+
def make_store_path(store_like: StoreLike, *, mode: OpenMode | None = None) -> StorePath:
6465
if isinstance(store_like, StorePath):
66+
if mode is not None:
67+
assert mode == store_like.store.mode
6568
return store_like
6669
elif isinstance(store_like, Store):
70+
if mode is not None:
71+
assert mode == store_like.mode
6772
return StorePath(store_like)
6873
elif isinstance(store_like, str):
69-
return StorePath(LocalStore(Path(store_like)))
74+
assert mode is not None
75+
return StorePath(LocalStore(Path(store_like), mode=mode))
7076
raise TypeError
7177

7278

src/zarr/store/local.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
from zarr.abc.store import Store
99
from zarr.buffer import Buffer
10-
from zarr.common import concurrent_map, to_thread
10+
from zarr.common import OpenMode, concurrent_map, to_thread
1111

1212

1313
def _get(path: Path, byte_range: tuple[int | None, int | None] | None) -> Buffer:
@@ -69,7 +69,8 @@ class LocalStore(Store):
6969

7070
root: Path
7171

72-
def __init__(self, root: Path | str):
72+
def __init__(self, root: Path | str, *, mode: OpenMode = "r"):
73+
super().__init__(mode=mode)
7374
if isinstance(root, str):
7475
root = Path(root)
7576
assert isinstance(root, Path)
@@ -117,6 +118,7 @@ async def get_partial_values(
117118
return await concurrent_map(args, to_thread, limit=None) # TODO: fix limit
118119

119120
async def set(self, key: str, value: Buffer) -> None:
121+
self._check_writable()
120122
assert isinstance(key, str)
121123
if isinstance(value, bytes | bytearray):
122124
# TODO: to support the v2 tests, we convert bytes to Buffer here
@@ -127,6 +129,7 @@ async def set(self, key: str, value: Buffer) -> None:
127129
await to_thread(_put, path, value)
128130

129131
async def set_partial_values(self, key_start_values: list[tuple[str, int, bytes]]) -> None:
132+
self._check_writable()
130133
args = []
131134
for key, start, value in key_start_values:
132135
assert isinstance(key, str)
@@ -138,6 +141,7 @@ async def set_partial_values(self, key_start_values: list[tuple[str, int, bytes]
138141
await concurrent_map(args, to_thread, limit=None) # TODO: fix limit
139142

140143
async def delete(self, key: str) -> None:
144+
self._check_writable()
141145
path = self.root / key
142146
if path.is_dir(): # TODO: support deleting directories? shutil.rmtree?
143147
shutil.rmtree(path)

src/zarr/store/memory.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from zarr.abc.store import Store
66
from zarr.buffer import Buffer
7-
from zarr.common import concurrent_map
7+
from zarr.common import OpenMode, concurrent_map
88
from zarr.store.core import _normalize_interval_index
99

1010

@@ -17,7 +17,10 @@ class MemoryStore(Store):
1717

1818
_store_dict: MutableMapping[str, Buffer]
1919

20-
def __init__(self, store_dict: MutableMapping[str, Buffer] | None = None):
20+
def __init__(
21+
self, store_dict: MutableMapping[str, Buffer] | None = None, *, mode: OpenMode = "r"
22+
):
23+
super().__init__(mode=mode)
2124
self._store_dict = store_dict or {}
2225

2326
def __str__(self) -> str:
@@ -47,6 +50,7 @@ async def exists(self, key: str) -> bool:
4750
return key in self._store_dict
4851

4952
async def set(self, key: str, value: Buffer, byte_range: tuple[int, int] | None = None) -> None:
53+
self._check_writable()
5054
assert isinstance(key, str)
5155
if isinstance(value, bytes | bytearray):
5256
# TODO: to support the v2 tests, we convert bytes to Buffer here
@@ -62,6 +66,7 @@ async def set(self, key: str, value: Buffer, byte_range: tuple[int, int] | None
6266
self._store_dict[key] = value
6367

6468
async def delete(self, key: str) -> None:
69+
self._check_writable()
6570
try:
6671
del self._store_dict[key]
6772
except KeyError:

src/zarr/store/remote.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from zarr.abc.store import Store
66
from zarr.buffer import Buffer
7+
from zarr.common import OpenMode
78
from zarr.store.core import _dereference_path
89

910
if TYPE_CHECKING:
@@ -18,17 +19,22 @@ class RemoteStore(Store):
1819

1920
root: UPath
2021

21-
def __init__(self, url: UPath | str, **storage_options: dict[str, Any]):
22+
def __init__(
23+
self, url: UPath | str, *, mode: OpenMode = "r", **storage_options: dict[str, Any]
24+
):
2225
import fsspec
2326
from upath import UPath
2427

28+
super().__init__(mode=mode)
29+
2530
if isinstance(url, str):
2631
self.root = UPath(url, **storage_options)
2732
else:
2833
assert (
2934
len(storage_options) == 0
3035
), "If constructed with a UPath object, no additional storage_options are allowed."
3136
self.root = url.rstrip("/")
37+
3238
# test instantiate file system
3339
fs, _ = fsspec.core.url_to_fs(str(self.root), asynchronous=True, **self.root._kwargs)
3440
assert fs.__class__.async_impl, "FileSystem needs to support async operations."
@@ -67,6 +73,7 @@ async def get(
6773
return value
6874

6975
async def set(self, key: str, value: Buffer, byte_range: tuple[int, int] | None = None) -> None:
76+
self._check_writable()
7077
assert isinstance(key, str)
7178
fs, root = self._make_fs()
7279
path = _dereference_path(root, key)
@@ -80,6 +87,7 @@ async def set(self, key: str, value: Buffer, byte_range: tuple[int, int] | None
8087
await fs._pipe_file(path, value)
8188

8289
async def delete(self, key: str) -> None:
90+
self._check_writable()
8391
fs, root = self._make_fs()
8492
path = _dereference_path(root, key)
8593
if await fs._exists(path):

src/zarr/testing/store.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Generic, TypeVar
1+
from typing import Any, Generic, TypeVar
22

33
import pytest
44

@@ -31,13 +31,43 @@ def get(self, store: S, key: str) -> Buffer:
3131
raise NotImplementedError
3232

3333
@pytest.fixture(scope="function")
34-
def store(self) -> Store:
35-
return self.store_cls()
34+
def store_kwargs(self) -> dict[str, Any]:
35+
return {"mode": "w"}
36+
37+
@pytest.fixture(scope="function")
38+
def store(self, store_kwargs: dict[str, Any]) -> Store:
39+
return self.store_cls(**store_kwargs)
3640

3741
def test_store_type(self, store: S) -> None:
3842
assert isinstance(store, Store)
3943
assert isinstance(store, self.store_cls)
4044

45+
def test_store_mode(self, store: S, store_kwargs: dict[str, Any]) -> None:
46+
assert store.mode == "w", store.mode
47+
assert store.writeable
48+
49+
with pytest.raises(AttributeError):
50+
store.mode = "w" # type: ignore
51+
52+
# read-only
53+
kwargs = {**store_kwargs, "mode": "r"}
54+
read_store = self.store_cls(**kwargs)
55+
assert read_store.mode == "r", read_store.mode
56+
assert not read_store.writeable
57+
58+
async def test_not_writable_store_raises(self, store_kwargs: dict[str, Any]) -> None:
59+
kwargs = {**store_kwargs, "mode": "r"}
60+
store = self.store_cls(**kwargs)
61+
assert not store.writeable
62+
63+
# set
64+
with pytest.raises(ValueError):
65+
await store.set("foo", Buffer.from_bytes(b"bar"))
66+
67+
# delete
68+
with pytest.raises(ValueError):
69+
await store.delete("foo")
70+
4171
def test_store_repr(self, store: S) -> None:
4272
raise NotImplementedError
4373

@@ -72,6 +102,7 @@ async def test_set(self, store: S, key: str, data: bytes) -> None:
72102
"""
73103
Ensure that data can be written to the store using the store.set method.
74104
"""
105+
assert store.writeable
75106
data_buf = Buffer.from_bytes(data)
76107
await store.set(key, data_buf)
77108
observed = self.get(store, key)

tests/v3/conftest.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,11 @@ def parse_store(
2222
store: Literal["local", "memory", "remote"], path: str
2323
) -> LocalStore | MemoryStore | RemoteStore:
2424
if store == "local":
25-
return LocalStore(path)
25+
return LocalStore(path, mode="w")
2626
if store == "memory":
27-
return MemoryStore()
27+
return MemoryStore(mode="w")
2828
if store == "remote":
29-
return RemoteStore()
29+
return RemoteStore(mode="w")
3030
raise AssertionError
3131

3232

@@ -38,24 +38,24 @@ def path_type(request):
3838
# todo: harmonize this with local_store fixture
3939
@pytest.fixture
4040
def store_path(tmpdir):
41-
store = LocalStore(str(tmpdir))
41+
store = LocalStore(str(tmpdir), mode="w")
4242
p = StorePath(store)
4343
return p
4444

4545

4646
@pytest.fixture(scope="function")
4747
def local_store(tmpdir):
48-
return LocalStore(str(tmpdir))
48+
return LocalStore(str(tmpdir), mode="w")
4949

5050

5151
@pytest.fixture(scope="function")
5252
def remote_store():
53-
return RemoteStore()
53+
return RemoteStore(mode="w")
5454

5555

5656
@pytest.fixture(scope="function")
5757
def memory_store():
58-
return MemoryStore()
58+
return MemoryStore(mode="w")
5959

6060

6161
@pytest.fixture(scope="function")

tests/v3/test_codecs.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ async def set(self, value: np.ndarray):
5050

5151
@pytest.fixture
5252
def store() -> Iterator[Store]:
53-
yield StorePath(MemoryStore())
53+
yield StorePath(MemoryStore(mode="w"))
5454

5555

5656
@pytest.fixture

tests/v3/test_store.py

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22

3-
from collections.abc import MutableMapping
3+
from typing import Any
44

55
import pytest
66

@@ -10,7 +10,6 @@
1010
from zarr.testing.store import StoreTests
1111

1212

13-
@pytest.mark.parametrize("store_dict", (None, {}))
1413
class TestMemoryStore(StoreTests[MemoryStore]):
1514
store_cls = MemoryStore
1615

@@ -20,21 +19,25 @@ def set(self, store: MemoryStore, key: str, value: Buffer) -> None:
2019
def get(self, store: MemoryStore, key: str) -> Buffer:
2120
return store._store_dict[key]
2221

22+
@pytest.fixture(scope="function", params=[None, {}])
23+
def store_kwargs(self, request) -> dict[str, Any]:
24+
return {"store_dict": request.param, "mode": "w"}
25+
2326
@pytest.fixture(scope="function")
24-
def store(self, store_dict: MutableMapping[str, Buffer] | None):
25-
return MemoryStore(store_dict=store_dict)
27+
def store(self, store_kwargs: dict[str, Any]) -> MemoryStore:
28+
return self.store_cls(**store_kwargs)
2629

2730
def test_store_repr(self, store: MemoryStore) -> None:
2831
assert str(store) == f"memory://{id(store._store_dict)}"
2932

3033
def test_store_supports_writes(self, store: MemoryStore) -> None:
31-
assert True
34+
assert store.supports_writes
3235

3336
def test_store_supports_listing(self, store: MemoryStore) -> None:
34-
assert True
37+
assert store.supports_listing
3538

3639
def test_store_supports_partial_writes(self, store: MemoryStore) -> None:
37-
assert True
40+
assert store.supports_partial_writes
3841

3942
def test_list_prefix(self, store: MemoryStore) -> None:
4043
assert True
@@ -52,21 +55,21 @@ def set(self, store: LocalStore, key: str, value: Buffer) -> None:
5255
parent.mkdir(parents=True)
5356
(store.root / key).write_bytes(value.to_bytes())
5457

55-
@pytest.fixture(scope="function")
56-
def store(self, tmpdir) -> LocalStore:
57-
return self.store_cls(str(tmpdir))
58+
@pytest.fixture
59+
def store_kwargs(self, tmpdir) -> dict[str, str]:
60+
return {"root": str(tmpdir), "mode": "w"}
5861

5962
def test_store_repr(self, store: LocalStore) -> None:
6063
assert str(store) == f"file://{store.root!s}"
6164

6265
def test_store_supports_writes(self, store: LocalStore) -> None:
63-
assert True
66+
assert store.supports_writes
6467

6568
def test_store_supports_partial_writes(self, store: LocalStore) -> None:
66-
assert True
69+
assert store.supports_partial_writes
6770

6871
def test_store_supports_listing(self, store: LocalStore) -> None:
69-
assert True
72+
assert store.supports_listing
7073

7174
def test_list_prefix(self, store: LocalStore) -> None:
7275
assert True

0 commit comments

Comments
 (0)