Skip to content

Commit 0b72757

Browse files
jhammanclaude
andauthored
feat: ZipStore accepts open binary file-like objects (#4187)
Allows constructing a ZipStore from any seekable binary reader, enabling zip archives on remote storage: - io objects (BytesIO, fsspec file objects) are used directly - minimal readers that are not io.IOBase instances and whose read() may return buffer-protocol objects rather than bytes (e.g. obstore.ReadableFile) are adapted via a small io.RawIOBase wrapper when opened for reading clear()/move() raise NotImplementedError for file-object-backed stores. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent eefa424 commit 0b72757

4 files changed

Lines changed: 281 additions & 9 deletions

File tree

changes/4187.feature.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
`ZipStore` now accepts an open binary file-like object in place of a path, enabling
2+
zip archives on remote storage (e.g. a file opened with `fsspec` or an
3+
`obstore.ReadableFile`). Operations that require a filesystem location
4+
(`clear`, `move`) raise `NotImplementedError` for file-object-backed stores.

docs/user-guide/storage.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,19 @@ array = zarr.create_array(store=store, shape=(2,), dtype='float64')
124124
print(array)
125125
```
126126

127+
In place of a path, `ZipStore` also accepts an open binary file object (for
128+
example a file opened with `fsspec`, or an `obstore` reader), enabling zip
129+
archives on remote storage. The file must stay open for as long as the store
130+
is in use:
131+
132+
```python exec="true" session="storage" source="above" result="ansi"
133+
store.close()
134+
f = open('data.zip', mode='rb') # must stay open while the store is used
135+
array = zarr.open_array(store=zarr.storage.ZipStore(f), mode='r')
136+
print(array[:])
137+
f.close()
138+
```
139+
127140
### Remote Store
128141

129142
The [`zarr.storage.FsspecStore`][] stores the contents of a Zarr hierarchy following the same

src/zarr/storage/_zip.py

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

3+
import io
34
import os
45
import shutil
56
import threading
67
import time
78
import zipfile
89
from pathlib import Path
9-
from typing import TYPE_CHECKING, Any, Literal
10+
from typing import IO, TYPE_CHECKING, Any, Literal
1011

1112
from zarr.abc.store import (
1213
ByteRequest,
@@ -23,14 +24,67 @@
2324
ZipStoreAccessModeLiteral = Literal["r", "w", "a"]
2425

2526

27+
class _RawReaderAdapter(io.RawIOBase):
28+
"""
29+
Adapt a minimal seekable reader to the `io` interface `zipfile` needs.
30+
31+
Some file-like objects (e.g. `obstore.ReadableFile`) implement
32+
`read`/`seek`/`tell` but are not `io.IOBase` instances, and their
33+
`read` may return a buffer-protocol object rather than `bytes`.
34+
Wrapping in this adapter plus `io.BufferedReader` yields real `bytes`.
35+
36+
Reads are clamped to the bytes remaining before EOF: some readers
37+
(obstore < 0.6) raise on short reads rather than returning fewer bytes.
38+
The size is cached, which is safe because the adapter is only used for
39+
read-only access.
40+
"""
41+
42+
def __init__(self, fileobj: IO[bytes]) -> None:
43+
self._fileobj = fileobj
44+
self._size: int | None = None
45+
46+
def _get_size(self) -> int:
47+
if self._size is None:
48+
pos = self._fileobj.tell()
49+
self._size = self._fileobj.seek(0, os.SEEK_END)
50+
self._fileobj.seek(pos)
51+
return self._size
52+
53+
def readable(self) -> bool:
54+
return True
55+
56+
def seekable(self) -> bool:
57+
return True
58+
59+
def seek(self, pos: int, whence: int = 0) -> int:
60+
return self._fileobj.seek(pos, whence)
61+
62+
def tell(self) -> int:
63+
return self._fileobj.tell()
64+
65+
def readinto(self, b: Any) -> int:
66+
n_requested = min(len(b), self._get_size() - self._fileobj.tell())
67+
if n_requested <= 0:
68+
return 0
69+
data = self._fileobj.read(n_requested)
70+
n = len(data)
71+
b[:n] = memoryview(data)
72+
return n
73+
74+
2675
class ZipStore(Store):
2776
"""
2877
Store using a ZIP file.
2978
3079
Parameters
3180
----------
32-
path : str
33-
Location of file.
81+
path : str, Path, or IO[bytes]
82+
Location of file, or an open binary file object. A file object must
83+
support `read`, `seek`, and `tell`; objects that are not `io.IOBase`
84+
instances (e.g. an `obstore` reader) are adapted automatically but
85+
can only be used for reading (`mode="r"`). The file object must stay
86+
open for the lifetime of the store, and operations that require a
87+
filesystem location (`clear`, `move`, pickling) are not supported.
3488
mode : str, optional
3589
One of 'r' to read an existing file, 'w' to truncate and write a new
3690
file, 'a' to append to an existing file, or 'x' to exclusively create
@@ -58,16 +112,17 @@ class ZipStore(Store):
58112
supports_deletes: bool = False
59113
supports_listing: bool = True
60114

61-
path: Path
115+
path: Path | None
62116
compression: int
63117
allowZip64: bool
64118

65119
_zf: zipfile.ZipFile
66120
_lock: threading.RLock
121+
_fileobj: IO[bytes] | None
67122

68123
def __init__(
69124
self,
70-
path: Path | str,
125+
path: Path | str | IO[bytes],
71126
*,
72127
mode: ZipStoreAccessModeLiteral = "r",
73128
read_only: bool | None = None,
@@ -81,8 +136,28 @@ def __init__(
81136

82137
if isinstance(path, str):
83138
path = Path(path)
84-
assert isinstance(path, Path)
85-
self.path = path # root?
139+
if isinstance(path, Path):
140+
self.path = path # root?
141+
self._fileobj = None
142+
else:
143+
self.path = None
144+
if not isinstance(path, io.IOBase):
145+
if not all(
146+
callable(getattr(path, attr, None)) for attr in ("read", "seek", "tell")
147+
):
148+
raise TypeError(
149+
f"expected a path or an open binary file object supporting "
150+
f"read/seek/tell, got {type(path).__name__}"
151+
)
152+
if mode != "r":
153+
raise TypeError(
154+
f"a file object that is not an io.IOBase instance can only be "
155+
f"opened for reading (mode='r', got mode={mode!r})"
156+
)
157+
# e.g. an obstore ReadableFile: readable and seekable, but
158+
# not an io object and reads may not return bytes
159+
path = io.BufferedReader(_RawReaderAdapter(path))
160+
self._fileobj = path
86161

87162
self._zmode = mode
88163
self.compression = compression
@@ -95,7 +170,7 @@ def _sync_open(self) -> None:
95170
self._lock = threading.RLock()
96171

97172
self._zf = zipfile.ZipFile(
98-
self.path,
173+
self.path if self.path is not None else self._fileobj, # type: ignore[arg-type]
99174
mode=self._zmode,
100175
compression=self.compression,
101176
allowZip64=self.allowZip64,
@@ -107,6 +182,13 @@ async def _open(self) -> None:
107182
self._sync_open()
108183

109184
def __getstate__(self) -> dict[str, Any]:
185+
if self.path is None:
186+
# A path-backed store pickles its path and reopens the file on
187+
# unpickling; an open file object cannot be serialized that way.
188+
raise TypeError(
189+
"cannot pickle a ZipStore backed by a file-like object; "
190+
"construct the store from a path instead"
191+
)
110192
# We need a copy to not modify the state of the original store
111193
state = self.__dict__.copy()
112194
for attr in ["_zf", "_lock"]:
@@ -130,20 +212,30 @@ async def clear(self) -> None:
130212
# docstring inherited
131213
with self._lock:
132214
self._check_writable()
215+
if self.path is None:
216+
raise NotImplementedError(
217+
"clear() is not supported for a ZipStore backed by a file-like object"
218+
)
133219
self._zf.close()
134220
os.remove(self.path)
135221
self._zf = zipfile.ZipFile(
136222
self.path, mode="w", compression=self.compression, allowZip64=self.allowZip64
137223
)
138224

139225
def __str__(self) -> str:
226+
if self.path is None:
227+
return f"zip://{self._fileobj!r}"
140228
return f"zip://{self.path}"
141229

142230
def __repr__(self) -> str:
143231
return f"ZipStore('{self}')"
144232

145233
def __eq__(self, other: object) -> bool:
146-
return isinstance(other, type(self)) and self.path == other.path
234+
return (
235+
isinstance(other, type(self))
236+
and self.path == other.path
237+
and self._fileobj is other._fileobj
238+
)
147239

148240
def _get(
149241
self,
@@ -297,6 +389,10 @@ async def move(self, path: Path | str) -> None:
297389
"""
298390
Move the store to another path.
299391
"""
392+
if self.path is None:
393+
raise NotImplementedError(
394+
"move() is not supported for a ZipStore backed by a file-like object"
395+
)
300396
if isinstance(path, str):
301397
path = Path(path)
302398
self.close()

tests/test_store/test_zip.py

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

3+
import io
34
import os
5+
import pickle
46
import shutil
57
import tempfile
68
import zipfile
@@ -188,6 +190,163 @@ async def test_move(self, tmp_path: Path) -> None:
188190
assert np.array_equal(array[...], np.arange(10))
189191

190192

193+
class TestZipStoreFileObj:
194+
"""ZipStore backed by an open binary file-like object instead of a path."""
195+
196+
@pytest.fixture
197+
def zip_bytes(self, tmp_path: Path) -> bytes:
198+
path = tmp_path / "data.zip"
199+
store = ZipStore(path, mode="w")
200+
zarr.create_array(store, data=np.arange(10), chunks=(5,))
201+
store.close()
202+
return path.read_bytes()
203+
204+
def test_read_from_fileobj(self, zip_bytes: bytes) -> None:
205+
# an existing archive can be read through any seekable binary reader
206+
store = ZipStore(io.BytesIO(zip_bytes), mode="r")
207+
array = zarr.open_array(store, mode="r")
208+
assert np.array_equal(array[...], np.arange(10))
209+
assert store.path is None
210+
211+
def test_write_to_fileobj(self) -> None:
212+
# a writable file object receives the archive; the bytes it holds
213+
# after close() are a complete, reopenable zip
214+
buffer = io.BytesIO()
215+
store = ZipStore(buffer, mode="w", read_only=False)
216+
zarr.create_array(store, data=np.arange(4))
217+
store.close()
218+
219+
roundtrip = ZipStore(io.BytesIO(buffer.getvalue()), mode="r")
220+
array = zarr.open_array(roundtrip, mode="r")
221+
assert np.array_equal(array[...], np.arange(4))
222+
223+
async def test_clear_unsupported(self, zip_bytes: bytes) -> None:
224+
# clear() requires a filesystem location, so it raises a clear error
225+
# for file-object-backed stores
226+
store = ZipStore(io.BytesIO(zip_bytes), mode="a", read_only=False)
227+
store._sync_open()
228+
with pytest.raises(NotImplementedError, match="clear.*file-like"):
229+
await store.clear()
230+
231+
async def test_move_unsupported(self, zip_bytes: bytes) -> None:
232+
# move() requires a filesystem location, so it raises a clear error
233+
# for file-object-backed stores
234+
store = ZipStore(io.BytesIO(zip_bytes), mode="a", read_only=False)
235+
store._sync_open()
236+
with pytest.raises(NotImplementedError, match="move.*file-like"):
237+
await store.move("elsewhere.zip")
238+
239+
def test_invalid_file_object_rejected(self) -> None:
240+
# objects without read/seek/tell are rejected at construction, not
241+
# deep inside zipfile
242+
with pytest.raises(TypeError, match="read/seek/tell"):
243+
ZipStore(42, mode="r") # type: ignore[arg-type]
244+
245+
@pytest.mark.parametrize("mode", ["w", "a", "x"])
246+
def test_non_iobase_reader_write_modes_rejected(self, zip_bytes: bytes, mode: str) -> None:
247+
# readers that are not io.IOBase instances are adapted for reading
248+
# only; write modes are rejected at construction with a clear error
249+
class MinimalReader:
250+
def __init__(self, data: bytes) -> None:
251+
self._buffer = io.BytesIO(data)
252+
253+
def read(self, size: int, /) -> bytes:
254+
return self._buffer.read(size)
255+
256+
def seek(self, pos: int, whence: int = 0, /) -> int:
257+
return self._buffer.seek(pos, whence)
258+
259+
def tell(self) -> int:
260+
return self._buffer.tell()
261+
262+
with pytest.raises(TypeError, match="opened for reading"):
263+
ZipStore(MinimalReader(zip_bytes), mode=mode, read_only=False) # type: ignore[arg-type]
264+
265+
def test_fsspec_file(self, tmp_path: Path, zip_bytes: bytes) -> None:
266+
# a file opened through fsspec (already an io.IOBase) is used directly;
267+
# fsspec's local filesystem stands in for a remote one
268+
fsspec = pytest.importorskip("fsspec")
269+
270+
path = tmp_path / "fsspec.zip"
271+
path.write_bytes(zip_bytes)
272+
with fsspec.open(f"local://{path}", "rb") as fileobj:
273+
store = ZipStore(fileobj, mode="r")
274+
array = zarr.open_array(store, mode="r")
275+
assert np.array_equal(array[...], np.arange(10))
276+
assert store.path is None
277+
278+
def test_obstore_reader(self, tmp_path: Path, zip_bytes: bytes) -> None:
279+
# obstore's ReadableFile is not an io.IOBase and its read() returns a
280+
# buffer-protocol object; ZipStore adapts it via _RawReaderAdapter
281+
obstore = pytest.importorskip("obstore")
282+
from obstore.store import LocalStore as ObstoreLocalStore
283+
284+
(tmp_path / "obstore.zip").write_bytes(zip_bytes)
285+
reader = obstore.open_reader(ObstoreLocalStore(str(tmp_path)), "obstore.zip")
286+
store = ZipStore(reader, mode="r")
287+
array = zarr.open_array(store, mode="r")
288+
assert np.array_equal(array[...], np.arange(10))
289+
290+
def test_raw_reader_adapter_eof(self) -> None:
291+
from zarr.storage._zip import _RawReaderAdapter
292+
293+
class MinimalReader:
294+
"""Non-io.IOBase reader exposing only read/seek/tell, like obstore."""
295+
296+
def __init__(self, data: bytes) -> None:
297+
self._buffer = io.BytesIO(data)
298+
299+
def read(self, size: int, /) -> bytes:
300+
return self._buffer.read(size)
301+
302+
def seek(self, pos: int, whence: int = 0, /) -> int:
303+
return self._buffer.seek(pos, whence)
304+
305+
def tell(self) -> int:
306+
return self._buffer.tell()
307+
308+
# the adapter must clamp reads to EOF: some readers (obstore < 0.6)
309+
# raise on short reads instead of returning fewer bytes
310+
data = b"0123456789"
311+
adapter = _RawReaderAdapter(MinimalReader(data)) # type: ignore[arg-type]
312+
313+
# A read straddling EOF returns only the remaining bytes.
314+
adapter.seek(len(data) - 3)
315+
buf = bytearray(8)
316+
assert adapter.readinto(buf) == 3
317+
assert bytes(buf[:3]) == data[-3:]
318+
319+
# A read at EOF returns 0.
320+
assert adapter.tell() == len(data)
321+
assert adapter.readinto(bytearray(8)) == 0
322+
323+
def test_pickle_fileobj_raises(self, zip_bytes: bytes) -> None:
324+
# an open file object cannot be reliably serialized, so pickling a
325+
# file-object-backed store raises with a pointer at the alternative
326+
store = ZipStore(io.BytesIO(zip_bytes), mode="r")
327+
with pytest.raises(TypeError, match="cannot pickle a ZipStore backed by a file-like"):
328+
pickle.dumps(store)
329+
330+
def test_pickle_path_backed_roundtrip(self, tmp_path: Path, zip_bytes: bytes) -> None:
331+
# path-backed stores remain picklable: the path is serialized and the
332+
# archive is reopened on unpickling
333+
path = tmp_path / "pickled.zip"
334+
path.write_bytes(zip_bytes)
335+
store = ZipStore(path, mode="r")
336+
unpickled = pickle.loads(pickle.dumps(store))
337+
array = zarr.open_array(unpickled, mode="r")
338+
assert np.array_equal(array[...], np.arange(10))
339+
340+
def test_str_and_eq(self, zip_bytes: bytes) -> None:
341+
# file-object-backed stores stringify with the object repr and
342+
# compare equal only when backed by the very same file object
343+
fileobj = io.BytesIO(zip_bytes)
344+
store = ZipStore(fileobj, mode="r")
345+
assert str(store).startswith("zip://<")
346+
assert store == ZipStore(fileobj, mode="r")
347+
assert store != ZipStore(io.BytesIO(zip_bytes), mode="r")
348+
349+
191350
class ZipStoreLifecycleMachine(RuleBasedStateMachine):
192351
"""Drive a ZipStore through construct / open / write / close transitions.
193352

0 commit comments

Comments
 (0)