|
1 | 1 | from __future__ import annotations |
2 | 2 |
|
| 3 | +import io |
3 | 4 | import os |
| 5 | +import pickle |
4 | 6 | import shutil |
5 | 7 | import tempfile |
6 | 8 | import zipfile |
@@ -188,6 +190,163 @@ async def test_move(self, tmp_path: Path) -> None: |
188 | 190 | assert np.array_equal(array[...], np.arange(10)) |
189 | 191 |
|
190 | 192 |
|
| 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 | + |
191 | 350 | class ZipStoreLifecycleMachine(RuleBasedStateMachine): |
192 | 351 | """Drive a ZipStore through construct / open / write / close transitions. |
193 | 352 |
|
|
0 commit comments