Skip to content

Commit f400438

Browse files
authored
Merge pull request #78 from dClimate/ci/update-zarr-3.0.9
Ci/update zarr 3.0.9
2 parents a276069 + 8086d50 commit f400438

File tree

9 files changed

+186
-19
lines changed

9 files changed

+186
-19
lines changed

py_hamt/encryption_hamt_store.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,19 @@ def __init__(
110110
self.header = header
111111
self.metadata_read_cache: dict[str, bytes] = {}
112112

113+
def with_read_only(self, read_only: bool = False) -> "SimpleEncryptedZarrHAMTStore":
114+
if read_only == self.read_only:
115+
return self
116+
117+
clone = type(self).__new__(type(self))
118+
clone.hamt = self.hamt
119+
clone.encryption_key = self.encryption_key
120+
clone.header = self.header
121+
clone.metadata_read_cache = self.metadata_read_cache
122+
clone._forced_read_only = read_only # safe; attribute is declared
123+
zarr.abc.store.Store.__init__(clone, read_only=read_only)
124+
return clone
125+
113126
def _encrypt(self, val: bytes) -> bytes:
114127
"""Encrypts data using ChaCha20-Poly1305."""
115128
nonce = get_random_bytes(24) # XChaCha20 uses a 24-byte nonce

py_hamt/store_httpx.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -232,9 +232,21 @@ def __init__(
232232
# helper: get or create the client bound to the current running loop #
233233
# --------------------------------------------------------------------- #
234234
def _loop_client(self) -> httpx.AsyncClient:
235-
"""Get or create a client for the current event loop."""
235+
"""Get or create a client for the current event loop.
236+
237+
If the instance was previously closed but owns its clients, a fresh
238+
client mapping is lazily created on demand. Users that supplied their
239+
own ``httpx.AsyncClient`` still receive an error when the instance has
240+
been closed, as we cannot safely recreate their client.
241+
"""
236242
if self._closed:
237-
raise RuntimeError("KuboCAS is closed; create a new instance")
243+
if not self._owns_client:
244+
raise RuntimeError("KuboCAS is closed; create a new instance")
245+
# We previously closed all internally-owned clients. Reset the
246+
# state so that new clients can be created lazily.
247+
self._closed = False
248+
self._client_per_loop = {}
249+
238250
loop: asyncio.AbstractEventLoop = asyncio.get_running_loop()
239251
try:
240252
return self._client_per_loop[loop]
@@ -245,7 +257,7 @@ def _loop_client(self) -> httpx.AsyncClient:
245257
headers=self._default_headers,
246258
auth=self._default_auth,
247259
limits=httpx.Limits(max_connections=64, max_keepalive_connections=32),
248-
# Uncomment when they finally support Robost HTTP/2 GOAWAY responses
260+
# Uncomment when they finally support Robust HTTP/2 GOAWAY responses
249261
# http2=True,
250262
)
251263
self._client_per_loop[loop] = client

py_hamt/zarr_hamt_store.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ class ZarrHAMTStore(zarr.abc.store.Store):
5151
```
5252
"""
5353

54+
_forced_read_only: bool | None = None # sentinel for wrapper clones
55+
5456
def __init__(self, hamt: HAMT, read_only: bool = False) -> None:
5557
"""
5658
### `hamt` and `read_only`
@@ -79,10 +81,36 @@ def __init__(self, hamt: HAMT, read_only: bool = False) -> None:
7981
"""@private"""
8082

8183
@property
82-
def read_only(self) -> bool:
83-
"""@private"""
84+
def read_only(self) -> bool: # type: ignore[override]
85+
if self._forced_read_only is not None: # instance attr overrides
86+
return self._forced_read_only
8487
return self.hamt.read_only
8588

89+
def with_read_only(self, read_only: bool = False) -> "ZarrHAMTStore":
90+
"""
91+
Return this store (if the flag already matches) or a *shallow*
92+
clone that presents the requested read‑only status.
93+
94+
The clone **shares** the same :class:`~py_hamt.hamt.HAMT`
95+
instance; no flushing, network traffic or async work is done.
96+
"""
97+
# Fast path
98+
if read_only == self.read_only:
99+
return self # Same mode, return same instance
100+
101+
# Create new instance with different read_only flag
102+
# Creates a *bare* instance without running its __init__
103+
clone = type(self).__new__(type(self))
104+
105+
# Copy attributes that matter
106+
clone.hamt = self.hamt # Share the HAMT
107+
clone._forced_read_only = read_only
108+
clone.metadata_read_cache = self.metadata_read_cache.copy()
109+
110+
# Re‑initialise the zarr base class so that Zarr sees the flag
111+
zarr.abc.store.Store.__init__(clone, read_only=read_only)
112+
return clone
113+
86114
def __eq__(self, other: object) -> bool:
87115
"""@private"""
88116
if not isinstance(other, ZarrHAMTStore):
@@ -145,6 +173,9 @@ def supports_partial_writes(self) -> bool:
145173

146174
async def set(self, key: str, value: zarr.core.buffer.Buffer) -> None:
147175
"""@private"""
176+
if self.read_only:
177+
raise Exception("Cannot write to a read-only store.")
178+
148179
if key in self.metadata_read_cache:
149180
self.metadata_read_cache[key] = value.to_bytes()
150181
await self.hamt.set(key, value.to_bytes())
@@ -167,6 +198,8 @@ def supports_deletes(self) -> bool:
167198

168199
async def delete(self, key: str) -> None:
169200
"""@private"""
201+
if self.read_only:
202+
raise Exception("Cannot write to a read-only store.")
170203
try:
171204
await self.hamt.delete(key)
172205
# In practice these lines never seem to be needed, creating and appending data are the only operations most zarrs actually undergo

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ dependencies = [
99
"dag-cbor>=0.3.3",
1010
"msgspec>=0.18.6",
1111
"multiformats[full]>=0.3.1.post4",
12-
"zarr>=3.0.8",
12+
"zarr==3.0.9",
1313
"pycryptodome>=3.21.0",
1414
]
1515

tests/test_kubocas_session.py

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import unittest
44
from threading import Event, Thread
55

6+
import httpx
67
import pytest
78

89
from py_hamt import KuboCAS
@@ -189,18 +190,34 @@ def fake_run(coro):
189190

190191

191192
@pytest.mark.asyncio
192-
async def test_loop_client_raises_after_close():
193-
"""
194-
Verify that calling _loop_client() on a closed KuboCAS instance
195-
raises a RuntimeError.
196-
"""
197-
# Arrange: Create a KuboCAS instance
193+
async def test_loop_client_reopens_after_close():
194+
"""Calling _loop_client() after aclose() recreates a fresh client."""
198195
cas = KuboCAS()
199196

200-
# Act: Close the instance. This should set the internal _closed flag.
197+
first = cas._loop_client()
201198
await cas.aclose()
202199

203-
# Assert: Check that calling _loop_client again raises the expected error.
200+
# Should no longer raise; instead a new client is created.
201+
reopened = cas._loop_client()
202+
assert isinstance(reopened, httpx.AsyncClient)
203+
assert reopened is not first
204+
assert cas._closed is False
205+
206+
await cas.aclose()
207+
208+
209+
@pytest.mark.asyncio
210+
async def test_loop_client_rejects_reuse_of_external_client(global_client_session):
211+
"""Calling _loop_client() after aclose() raises when client is user-supplied."""
212+
cas = KuboCAS(
213+
client=global_client_session,
214+
rpc_base_url="http://127.0.0.1:5001",
215+
gateway_base_url="http://127.0.0.1:8080",
216+
)
217+
assert cas._loop_client() is global_client_session
218+
219+
await cas.aclose()
220+
cas._closed = True # simulate closed instance with external client
204221
with pytest.raises(RuntimeError, match="KuboCAS is closed; create a new instance"):
205222
cas._loop_client()
206223

tests/test_read_only_guards.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# tests/test_read_only_guards.py
2+
import numpy as np
3+
import pytest
4+
from Crypto.Random import get_random_bytes
5+
6+
from py_hamt import HAMT, InMemoryCAS, SimpleEncryptedZarrHAMTStore, ZarrHAMTStore
7+
8+
9+
# ---------- helpers ----------------------------------------------------
10+
async def _rw_plain():
11+
cas = InMemoryCAS()
12+
hamt = await HAMT.build(cas=cas, values_are_bytes=True)
13+
return ZarrHAMTStore(hamt, read_only=False)
14+
15+
16+
async def _rw_enc():
17+
cas = InMemoryCAS()
18+
hamt = await HAMT.build(cas=cas, values_are_bytes=True)
19+
key, hdr = get_random_bytes(32), b"hdr"
20+
return SimpleEncryptedZarrHAMTStore(hamt, False, key, hdr)
21+
22+
23+
# ---------- plain store ------------------------------------------------
24+
@pytest.mark.asyncio
25+
async def test_plain_read_only_guards():
26+
rw = await _rw_plain()
27+
ro = rw.with_read_only(True)
28+
29+
assert ro.read_only is True
30+
with pytest.raises(Exception):
31+
await ro.set("k", np.array([1], dtype="u1"))
32+
with pytest.raises(Exception):
33+
await ro.delete("k")
34+
35+
36+
@pytest.mark.asyncio
37+
async def test_plain_with_same_flag_returns_self():
38+
rw = await _rw_plain()
39+
assert rw.with_read_only(False) is rw # early‑return path
40+
41+
42+
@pytest.mark.asyncio
43+
async def test_roundtrip_plain_store():
44+
rw = await _rw_plain() # writable store
45+
ro = rw.with_read_only(True) # clone → RO
46+
assert ro.read_only is True
47+
assert ro.hamt is rw.hamt
48+
49+
# idempotent: RO→RO returns same object
50+
assert ro.with_read_only(True) is ro
51+
52+
# back to RW (new wrapper)
53+
rw2 = ro.with_read_only(False)
54+
assert rw2.read_only is False and rw2 is not ro
55+
assert rw2.hamt is rw.hamt
56+
57+
# guard: cannot write through RO wrapper
58+
with pytest.raises(Exception):
59+
await ro.set("k", np.array([0], dtype="u1"))
60+
61+
62+
# ---------- encrypted store -------------------------------------------
63+
@pytest.mark.asyncio
64+
async def test_encrypted_read_only_guards_and_self():
65+
rw = await _rw_enc()
66+
assert rw.with_read_only(False) is rw # same‑flag path
67+
ro = rw.with_read_only(True)
68+
with pytest.raises(Exception):
69+
await ro.set("k", np.array([2], dtype="u1"))
70+
with pytest.raises(Exception):
71+
await ro.delete("k")

tests/test_zarr_ipfs.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,3 +189,24 @@ async def test_list_dir_dedup():
189189
await hamt.set("foo/bar/1", b"")
190190
results = [n async for n in zhs.list_dir("foo/")]
191191
assert results == ["bar"] # no duplicates
192+
193+
194+
@pytest.mark.asyncio
195+
async def test_open_rw_store_triggers_helper():
196+
"""
197+
A write‑enabled ZarrHAMTStore must open cleanly in read‑mode.
198+
Behaviour before 3.2.0: ValueError → helper missing.
199+
Behaviour after 3.2.0: success → helper present.
200+
"""
201+
# --- 1. create a small dataset and a RW HAMT backed by in‑memory CAS
202+
cas = InMemoryCAS()
203+
hamt = await HAMT.build(cas=cas, values_are_bytes=True) # read/write
204+
store_rw = ZarrHAMTStore(hamt, read_only=False)
205+
206+
ds = xr.Dataset({"x": ("t", np.arange(3))})
207+
ds.to_zarr(store=store_rw, mode="w", zarr_format=3)
208+
209+
# --- 2. try to re‑open **the same write‑enabled store** in *read* mode
210+
# – this calls Store.with_read_only(True) internally
211+
reopened = xr.open_zarr(store=store_rw) # <-- MUST NOT raise
212+
assert reopened.x.shape == (3,) # sanity check

tests/testing_utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ def create_ipfs():
169169
if client is None:
170170
pytest.skip("Neither IPFS daemon nor Docker available – skipping IPFS tests")
171171

172-
image = "ipfs/kubo:v0.35.0"
172+
image = "ipfs/kubo:v0.36.0"
173173
rpc_p = _free_port()
174174
gw_p = _free_port()
175175

uv.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)