Skip to content

Commit 2affa68

Browse files
committed
Support with_read_only in LoggingStore and LatencyStore.
Fixes #3699
1 parent 22ea09f commit 2affa68

6 files changed

Lines changed: 123 additions & 6 deletions

File tree

src/zarr/experimental/cache_store.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import logging
55
import time
66
from collections import OrderedDict
7-
from typing import TYPE_CHECKING, Any, Literal
7+
from typing import TYPE_CHECKING, Any, Literal, Self
88

99
from zarr.abc.store import ByteRequest, Store
1010
from zarr.storage._wrapper import WrapperStore
@@ -120,6 +120,11 @@ def __init__(
120120
self._misses = 0
121121
self._evictions = 0
122122

123+
def _with_store(self, store: Store) -> Self:
124+
# Cannot support this operation because it would share a cache, but have a new store
125+
# So cache keys would conflict
126+
raise NotImplementedError("CacheStore does not support this operation.")
127+
123128
def _is_key_fresh(self, key: str) -> bool:
124129
"""Check if a cached key is still fresh based on max_age_seconds.
125130

src/zarr/storage/_logging.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ def _default_handler(self) -> logging.Handler:
7777
)
7878
return handler
7979

80+
def _with_store(self, store: T_Store) -> Self:
81+
return type(self)(store=store, log_level=self.log_level, log_handler=self.log_handler)
82+
8083
@contextmanager
8184
def log(self, hint: Any = "") -> Generator[None, None, None]:
8285
"""Context manager to log method calls

src/zarr/storage/_wrapper.py

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

3-
from typing import TYPE_CHECKING, Generic, TypeVar
3+
from typing import TYPE_CHECKING, Generic, TypeVar, cast
44

55
if TYPE_CHECKING:
66
from collections.abc import AsyncGenerator, AsyncIterator, Iterable
@@ -31,14 +31,23 @@ class WrapperStore(Store, Generic[T_Store]):
3131
def __init__(self, store: T_Store) -> None:
3232
self._store = store
3333

34+
def _with_store(self, store: T_Store) -> Self:
35+
"""
36+
Constructs a new instance of the wrapper store with the same details but a new store.
37+
"""
38+
return type(self)(store=store)
39+
3440
@classmethod
3541
async def open(cls: type[Self], store_cls: type[T_Store], *args: Any, **kwargs: Any) -> Self:
3642
store = store_cls(*args, **kwargs)
3743
await store._open()
3844
return cls(store=store)
3945

46+
def with_read_only(self, read_only: bool = False) -> Self:
47+
return self._with_store(cast(T_Store, self._store.with_read_only(read_only)))
48+
4049
def __enter__(self) -> Self:
41-
return type(self)(self._store.__enter__())
50+
return self._with_store(self._store.__enter__())
4251

4352
def __exit__(
4453
self,

src/zarr/testing/store.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import json
55
import pickle
66
from abc import abstractmethod
7-
from typing import TYPE_CHECKING, Generic, TypeVar
7+
from typing import TYPE_CHECKING, Generic, Self, TypeVar
88

99
from zarr.storage import WrapperStore
1010

@@ -578,10 +578,13 @@ class LatencyStore(WrapperStore[Store]):
578578
get_latency: float
579579
set_latency: float
580580

581-
def __init__(self, cls: Store, *, get_latency: float = 0, set_latency: float = 0) -> None:
581+
def __init__(self, store: Store, *, get_latency: float = 0, set_latency: float = 0) -> None:
582582
self.get_latency = float(get_latency)
583583
self.set_latency = float(set_latency)
584-
self._store = cls
584+
self._store = store
585+
586+
def _with_store(self, store: Store) -> Self:
587+
return type(self)(store, get_latency=self.get_latency, set_latency=self.set_latency)
585588

586589
async def set(self, key: str, value: Buffer) -> None:
587590
"""

tests/test_store/test_latency.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
from __future__ import annotations
2+
3+
import pytest
4+
5+
from zarr.core.buffer import default_buffer_prototype
6+
from zarr.storage import MemoryStore
7+
from zarr.testing.store import LatencyStore
8+
9+
10+
async def test_latency_store_with_read_only_round_trip() -> None:
11+
"""
12+
Ensure that LatencyStore.with_read_only returns another LatencyStore with
13+
the requested read_only state, preserves latency configuration, and does
14+
not change the original wrapper.
15+
"""
16+
base = await MemoryStore.open()
17+
# Start from a read-only underlying store
18+
ro_base = base.with_read_only(read_only=True)
19+
latency_ro = LatencyStore(ro_base, get_latency=0.01, set_latency=0.02)
20+
21+
assert latency_ro.read_only
22+
assert latency_ro.get_latency == pytest.approx(0.01)
23+
assert latency_ro.set_latency == pytest.approx(0.02)
24+
25+
buf = default_buffer_prototype().buffer.from_bytes(b"abcd")
26+
27+
# Cannot write through the read-only wrapper
28+
with pytest.raises(
29+
ValueError, match="store was opened in read-only mode and does not support writing"
30+
):
31+
await latency_ro.set("key", buf)
32+
33+
# Create a writable wrapper from the read-only one
34+
writer = latency_ro.with_read_only(read_only=False)
35+
assert isinstance(writer, LatencyStore)
36+
assert not writer.read_only
37+
# Latency configuration is preserved
38+
assert writer.get_latency == latency_ro.get_latency
39+
assert writer.set_latency == latency_ro.set_latency
40+
41+
# Writes via the writable wrapper succeed
42+
await writer.set("key", buf)
43+
out = await writer.get("key", prototype=default_buffer_prototype())
44+
assert out is not None
45+
assert out.to_bytes() == buf.to_bytes()
46+
47+
# Creating a read-only copy from the writable wrapper works and is enforced
48+
reader = writer.with_read_only(read_only=True)
49+
assert isinstance(reader, LatencyStore)
50+
assert reader.read_only
51+
with pytest.raises(
52+
ValueError, match="store was opened in read-only mode and does not support writing"
53+
):
54+
await reader.set("other", buf)
55+
56+
# The original read-only wrapper remains read-only
57+
assert latency_ro.read_only

tests/test_store/test_logging.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,46 @@ def test_is_open_setter_raises(self, store: LoggingStore[LocalStore]) -> None:
8686
):
8787
store._is_open = True
8888

89+
async def test_with_read_only_round_trip(self, local_store: LocalStore) -> None:
90+
"""
91+
Ensure that LoggingStore.with_read_only returns another LoggingStore with
92+
the requested read_only state, preserves logging configuration, and does
93+
not change the original store.
94+
"""
95+
# Start from a read-only underlying store
96+
ro_store = local_store.with_read_only(read_only=True)
97+
wrapped_ro = LoggingStore(store=ro_store, log_level="INFO")
98+
assert wrapped_ro.read_only
99+
100+
buf = default_buffer_prototype().buffer.from_bytes(b"0123")
101+
102+
# Cannot write through the read-only wrapper
103+
with pytest.raises(
104+
ValueError, match="store was opened in read-only mode and does not support writing"
105+
):
106+
await wrapped_ro.set("foo", buf)
107+
108+
# Create a writable wrapper
109+
writer = wrapped_ro.with_read_only(read_only=False)
110+
assert isinstance(writer, LoggingStore)
111+
assert not writer.read_only
112+
# logging configuration is preserved
113+
assert writer.log_level == wrapped_ro.log_level
114+
assert writer.log_handler == wrapped_ro.log_handler
115+
116+
# Writes via the writable wrapper succeed
117+
await writer.set("foo", buf)
118+
out = await writer.get("foo", prototype=default_buffer_prototype())
119+
assert out is not None
120+
assert out.to_bytes() == buf.to_bytes()
121+
122+
# The original wrapper remains read-only
123+
assert wrapped_ro.read_only
124+
with pytest.raises(
125+
ValueError, match="store was opened in read-only mode and does not support writing"
126+
):
127+
await wrapped_ro.set("bar", buf)
128+
89129

90130
@pytest.mark.parametrize("store", ["local", "memory", "zip"], indirect=["store"])
91131
async def test_logging_store(store: Store, caplog: pytest.LogCaptureFixture) -> None:

0 commit comments

Comments
 (0)