diff --git a/changes/419.feature.md b/changes/419.feature.md new file mode 100644 index 0000000000..9dd9f28247 --- /dev/null +++ b/changes/419.feature.md @@ -0,0 +1,3 @@ +Added `zarr.ArrayIO` and `zarr.AsyncArrayIO`, an explicit-IO layer over Zarr arrays. Constructing one performs no IO against storage: storing metadata (`store_metadata`), storing or reading chunks (`setitem` / `getitem`), and their erasing inverses (`erase_metadata`, `erase_chunk`, `erase_chunks`) are explicit method calls that run in whatever order the caller chooses, so chunks can be written before metadata. `open` auto-detects the Zarr format by default, as `zarrs` does, and `verify` reads the stored metadata and raises, naming each differing field, when it does not match the metadata the instance is bound to. +`Array` and `AsyncArray` are built on this layer: every `AsyncArray` wraps an `AsyncArrayIO` and delegates its metadata and selection IO to it, the wrapped layer is reachable as `Array.io` / `AsyncArray.io`, and `to_array()` returns an array over the same binding. +The binding holds the metadata document as a `zarr-metadata` model — `zarr-python` now depends on `zarr-metadata` — reachable as `ArrayIO.document`: document operations (`store_metadata`, `erase_metadata`, `erase_chunk`, `erase_chunks`, `verify`) never resolve codec or data type names, so an array whose codecs this environment lacks can still be opened, verified and erased, and names resolve into classes only when chunks are read or written. diff --git a/docs/api/zarr/array.md b/docs/api/zarr/array.md index ff61cb1fe2..dc51e1913e 100644 --- a/docs/api/zarr/array.md +++ b/docs/api/zarr/array.md @@ -1,2 +1,4 @@ ::: zarr.Array ::: zarr.AsyncArray +::: zarr.ArrayIO +::: zarr.AsyncArrayIO diff --git a/pyproject.toml b/pyproject.toml index 0df173f805..067d2e7297 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,7 @@ dependencies = [ 'typing_extensions>=4.14', 'donfig>=0.8', 'msgspec>=0.19', + 'zarr-metadata>=0.6.0', ] dynamic = [ diff --git a/src/zarr/__init__.py b/src/zarr/__init__.py index f305245f4e..c30b0cb4bf 100644 --- a/src/zarr/__init__.py +++ b/src/zarr/__init__.py @@ -34,7 +34,7 @@ zeros, zeros_like, ) -from zarr.core.array import Array, AsyncArray +from zarr.core.array import Array, ArrayIO, AsyncArray, AsyncArrayIO from zarr.core.config import config from zarr.core.group import AsyncGroup, Group @@ -147,7 +147,9 @@ def set_format(log_format: str) -> None: __all__ = [ "Array", + "ArrayIO", "AsyncArray", + "AsyncArrayIO", "AsyncGroup", "Group", "__version__", diff --git a/src/zarr/core/array.py b/src/zarr/core/array.py index f650388f80..33b7a7daef 100644 --- a/src/zarr/core/array.py +++ b/src/zarr/core/array.py @@ -2,11 +2,12 @@ import contextlib import copy +import json import math import warnings from asyncio import gather from collections.abc import Iterable, Mapping, Sequence -from dataclasses import dataclass, field, replace +from dataclasses import dataclass, replace from itertools import starmap from logging import getLogger from typing import ( @@ -21,6 +22,7 @@ import numpy as np from typing_extensions import Sentinel, deprecated +from zarr_metadata import ZarrV2ArrayMetadata, ZarrV3ArrayMetadata import zarr from zarr.abc.codec import ArrayArrayCodec, ArrayBytesCodec, BytesBytesCodec, Codec @@ -121,6 +123,7 @@ ) from zarr.core.metadata.io import ( ARRAY_DOCUMENTS, + _write_metadata, encode_documents, parse_stored_array, read_documents, @@ -138,7 +141,9 @@ from zarr.core.metadata.v3 import ( ChunkGridMetadata, RectilinearChunkGridMetadata, + RectilinearChunkGridMetadataJSON, RegularChunkGridMetadata, + RegularChunkGridMetadataJSON, create_chunk_grid_metadata, parse_node_type_array, ) @@ -178,6 +183,9 @@ # Array and AsyncArray are defined in the base `zarr` namespace __all__ = [ "DEFAULT_FILL_VALUE", + "ArrayIO", + "AsyncArrayIO", + "build_array_metadata", "create_codec_pipeline", "parse_array_metadata", ] @@ -392,11 +400,7 @@ class AsyncArray[T_ArrayMetadata: (ArrayV2Metadata, ArrayV3Metadata)]: The runtime configuration of the array. """ - metadata: T_ArrayMetadata - store_path: StorePath - codec_pipeline: CodecPipeline = field(init=False) - _chunk_grid: ChunkGrid = field(init=False) - config: ArrayConfig + _io: AsyncArrayIO @overload def __init__( @@ -420,18 +424,46 @@ def __init__( store_path: StorePath, config: ArrayConfigLike | None = None, ) -> None: - metadata_parsed = parse_array_metadata(metadata, str(store_path)) - config_parsed = parse_array_config(config) + object.__setattr__(self, "_io", AsyncArrayIO(metadata, store_path, config)) + # An AsyncArray is the resolving layer: build its codec pipeline now, so + # pipeline-construction warnings fire at creation, as they always have. + _ = self._io._resolved_codec_pipeline - object.__setattr__(self, "metadata", metadata_parsed) - object.__setattr__(self, "store_path", store_path) - object.__setattr__(self, "config", config_parsed) - object.__setattr__(self, "_chunk_grid", ChunkGrid.from_metadata(metadata_parsed)) - object.__setattr__( - self, - "codec_pipeline", - create_codec_pipeline(metadata=metadata_parsed, store=store_path.store), - ) + @classmethod + def _from_io(cls, io: AsyncArrayIO) -> AnyAsyncArray: + """An array over an existing binding: the array and `io` share one state.""" + self = object.__new__(cls) + object.__setattr__(self, "_io", io) + return self + + @property + def io(self) -> AsyncArrayIO: + """The explicit-IO layer this array delegates to; both share one state.""" + return self._io + + @property + def metadata(self) -> T_ArrayMetadata: + """The in-memory metadata of this array.""" + return cast("T_ArrayMetadata", self._io.metadata) + + @property + def store_path(self) -> StorePath: + """The location in a store this array is bound to.""" + return self._io.store_path + + @property + def config(self) -> ArrayConfig: + """The runtime configuration of this array.""" + return self._io.config + + @property + def codec_pipeline(self) -> CodecPipeline: + """The codec pipeline used for encoding and decoding chunks.""" + return self._io._resolved_codec_pipeline + + @property + def _chunk_grid(self) -> ChunkGrid: + return self._io._chunk_grid @classmethod async def _create( @@ -1486,17 +1518,7 @@ async def _get_selection( out: NDBuffer | None = None, fields: Fields | None = None, ) -> NDArrayLikeOrScalar: - return await _get_selection( - self.store_path, - self.metadata, - self.codec_pipeline, - self.config, - self._chunk_grid, - indexer, - prototype=prototype, - out=out, - fields=fields, - ) + return await self._io._get_selection(indexer, prototype=prototype, out=out, fields=fields) async def getitem( self, @@ -1540,15 +1562,7 @@ async def getitem( np.int32(0) """ - return await _getitem( - self.store_path, - self.metadata, - self.codec_pipeline, - self.config, - self._chunk_grid, - selection, - prototype=prototype, - ) + return await self._io.getitem(selection, prototype=prototype) async def get_orthogonal_selection( self, @@ -1623,57 +1637,20 @@ async def get_coordinate_selection( out_array = cast("NDArrayLikeOrScalar", np.array(out_array).reshape(indexer.sel_shape)) return out_array - async def _save_metadata(self, metadata: ArrayMetadata, ensure_parents: bool = False) -> None: - """Store `metadata` as this array's own documents, then clear the - `_stored_document` mark (see `_stored_document_replaced`). `_resize` stores the - documents it encoded before deleting chunks directly, then clears the mark the - same way.""" - await save_metadata(self.store_path, metadata, ensure_parents=ensure_parents) - self._stored_document_replaced() + async def _save_metadata(self, ensure_parents: bool = False) -> None: + """Store this array's metadata documents, delegating to the wrapped + `AsyncArrayIO`, which clears the `_stored_document` mark (see + `_stored_document_replaced`). `_resize` stores the documents it encoded before + deleting chunks directly, then clears the mark the same way.""" + await self._io.store_metadata(ensure_parents=ensure_parents) def _stored_document_replaced(self) -> None: - """Record that the store no longer holds a document of this array that needs an - repair: it holds the repair, a valid document, or none. The metadata this handle - holds, which a consolidated group handle may share, then stops standing for the - document it was read from (see `mark_repaired`), so no later write through either - handle stores that document again.""" - object.__setattr__(self.metadata, "_stored_document", None) + """See the module-level `_stored_document_replaced`.""" + _stored_document_replaced(self.metadata) async def _store_repaired_document(self) -> None: - """Store the repair of this array's current stored document, if it needs one, - before chunks are written under this handle's metadata. - - Only for metadata read from a document whose repair moves chunks (see - `zarr.core.metadata.repair`). The document is read again, because the store may - hold a newer one than this handle's metadata. If that one lays out chunks - differently (another writer stored a different chunk size since), this handle - would write chunks no reader finds, so it raises and stores nothing. If it needs - no repair (the array was re-saved since, possibly by another implementation), it - is left as written; if there is none, there is nothing to repair. - - Storing the same repair twice is harmless, so handles that write chunks - concurrently need no coordination. The read and the store are not one atomic - step, though: a metadata write by another handle between them (a resize, an - attribute update) is replaced by the repair of the document read before it, as - with any two metadata writes that race. - """ - if self.metadata._stored_document is None: - return - zarr_format = self.metadata.zarr_format - documents = await read_documents(self.store_path, ARRAY_DOCUMENTS[zarr_format]) - try: - current = parse_stored_array(documents, zarr_format, str(self.store_path)) - except ArrayNotFoundError: - pass - else: - if _chunk_layout(current) != _chunk_layout(self.metadata): - raise ValueError( - f"Array {str(self.store_path)!r}: the metadata stored has changed since " - "this array was opened; reopen the array to write to it. Nothing was stored." - ) - if current._stored_document is not None: - await upsert_metadata(self.store_path, current, documents) - self._stored_document_replaced() + """See the module-level `_store_repaired_document`.""" + await _store_repaired_document(self.store_path, self.metadata) async def _set_selection( self, @@ -1683,21 +1660,7 @@ async def _set_selection( prototype: BufferPrototype, fields: Fields | None = None, ) -> None: - if product(indexer.shape) > 0: - # Chunks are about to be stored under the repaired metadata, so store it - # first: every reader of the store then agrees with them. - await self._store_repaired_document() - return await _set_selection( - self.store_path, - self.metadata, - self.codec_pipeline, - self.config, - self._chunk_grid, - indexer, - value, - prototype=prototype, - fields=fields, - ) + return await self._io._set_selection(indexer, value, prototype=prototype, fields=fields) async def setitem( self, @@ -1738,10 +1701,7 @@ async def setitem( - This method is asynchronous and should be awaited. - Supports basic indexing, where the selection is contiguous and does not involve advanced indexing. """ - if prototype is None: - prototype = default_buffer_prototype() - indexer = BasicIndexer(selection, shape=self.metadata.shape, chunk_grid=self._chunk_grid) - return await self._set_selection(indexer, value, prototype=prototype) + return await self._io.setitem(selection, value, prototype=prototype) @property def oindex(self) -> AsyncOIndex[T_ArrayMetadata]: @@ -1951,6 +1911,11 @@ def async_array(self) -> AsyncArray[T_ArrayMetadata]: """ return self._async_array + @property + def io(self) -> ArrayIO: + """The explicit synchronous IO layer over this array's binding.""" + return ArrayIO._wrap(self._async_array._io) + @property def config(self) -> ArrayConfig: """ @@ -4607,6 +4572,49 @@ async def init_array( AsyncArray The AsyncArray. """ + meta = build_array_metadata( + shape=shape, + dtype=dtype, + chunks=chunks, + shards=shards, + filters=filters, + compressors=compressors, + serializer=serializer, + fill_value=fill_value, + order=order, + zarr_format=zarr_format, + attributes=attributes, + chunk_key_encoding=chunk_key_encoding, + dimension_names=dimension_names, + ) + arr = AsyncArray(metadata=meta, store_path=store_path, config=config) + await save_new_metadata(store_path, meta, overwrite=overwrite, ensure_parents=True) + return arr + + +def build_array_metadata( + *, + shape: ShapeLike, + dtype: ZDTypeLike, + chunks: ChunksLike | Literal["auto"] = "auto", + shards: ShardsLike | None = None, + filters: FiltersLike = "auto", + compressors: CompressorsLike = "auto", + serializer: SerializerLike = "auto", + fill_value: Any | None = DEFAULT_FILL_VALUE, + order: MemoryOrder | None = None, + zarr_format: ZarrFormat | None = 3, + attributes: dict[str, JSON] | None = None, + chunk_key_encoding: ChunkKeyEncodingLike | None = None, + dimension_names: DimensionNamesLike = None, +) -> ArrayV2Metadata | ArrayV3Metadata: + """Build an array metadata document from creation-style keyword arguments. + + This is the pure half of `init_array`: it normalizes the same keyword + arguments into an in-memory metadata object without touching any store. + The parameters have the same meaning as in + [`zarr.api.asynchronous.create_array`][]. + """ if zarr_format is None: zarr_format = _default_zarr_format() @@ -4733,9 +4741,7 @@ async def init_array( attributes=attributes, ) - arr = AsyncArray(metadata=meta, store_path=store_path, config=config) - await save_new_metadata(store_path, meta, overwrite=overwrite, ensure_parents=True) - return arr + return meta async def create_array( @@ -4960,6 +4966,52 @@ def _sharding_codec(metadata: ArrayMetadata) -> ShardingCodec | None: return None +def _stored_document_replaced(metadata: ArrayMetadata) -> None: + """Record that the store no longer holds a document of this array that needs a + repair: it holds the repair, a valid document, or none. The metadata, which a + consolidated group handle may share with an array handle, then stops standing for + the document it was read from (see `mark_repaired`), so no later write through any + handle stores that document again.""" + object.__setattr__(metadata, "_stored_document", None) + + +async def _store_repaired_document(store_path: StorePath, metadata: ArrayMetadata) -> None: + """Store the repair of the array's current stored document, if it needs one, + before chunks are written under `metadata`. + + Only for metadata read from a document whose repair moves chunks (see + `zarr.core.metadata.repair`). The document is read again, because the store may + hold a newer one than `metadata`. If that one lays out chunks differently + (another writer stored a different chunk size since), the caller would write + chunks no reader finds, so it raises and stores nothing. If it needs no repair + (the array was re-saved since, possibly by another implementation), it is left as + written; if there is none, there is nothing to repair. + + Storing the same repair twice is harmless, so handles that write chunks + concurrently need no coordination. The read and the store are not one atomic + step, though: a metadata write by another handle between them (a resize, an + attribute update) is replaced by the repair of the document read before it, as + with any two metadata writes that race. + """ + if metadata._stored_document is None: + return + zarr_format = metadata.zarr_format + documents = await read_documents(store_path, ARRAY_DOCUMENTS[zarr_format]) + try: + current = parse_stored_array(documents, zarr_format, str(store_path)) + except ArrayNotFoundError: + pass + else: + if _chunk_layout(current) != _chunk_layout(metadata): + raise ValueError( + f"Array {str(store_path)!r}: the metadata stored has changed since " + "this array was opened; reopen the array to write to it. Nothing was stored." + ) + if current._stored_document is not None: + await upsert_metadata(store_path, current, documents) + _stored_document_replaced(metadata) + + def _stored_rectilinear_grid_or_none( metadata: ArrayMetadata, ) -> RectilinearChunkGridMetadata | None: @@ -6013,9 +6065,8 @@ async def _delete_chunk(coords: tuple[int, ...]) -> None: await store_documents(array.store_path, documents) array._stored_document_replaced() - # Update metadata and chunk_grid (in place) - object.__setattr__(array, "metadata", new_metadata) - object.__setattr__(array, "_chunk_grid", new_chunk_grid) + # Update the wrapped binding in place; the codec pipeline is kept, as before. + array._io._rebind_resolved(new_metadata, new_chunk_grid) async def _append( @@ -6101,9 +6152,11 @@ async def _update_attributes( The array with the updated attributes. """ array.metadata.attributes.update(new_attributes) + # The document the binding derives must re-derive from the changed metadata. + array._io._rebind_resolved(array.metadata, array._chunk_grid) # Write new metadata - await array._save_metadata(array.metadata) + await array._save_metadata() return array @@ -6130,3 +6183,683 @@ async def _info_complete( await _nshards_initialized(array), await array.store_path.store.getsize_prefix(array.store_path.path), ) + + +async def _delete_key(store_path: StorePath, key: str) -> None: + await (store_path / key).delete() + + +type ArrayDocumentModel = ZarrV2ArrayMetadata | ZarrV3ArrayMetadata +"""A Zarr array metadata document, held as a `zarr_metadata` model: the JSON as +written, validated, with extension names read but never resolved into classes.""" + + +def _document_json(document: ArrayDocumentModel) -> dict[str, Any]: + """The document as plain, mutable JSON. The model returns read-only forms — + arrays as tuples — while zarr's parsers take the stored forms, so the + document goes through JSON text.""" + return cast("dict[str, Any]", json.loads(json.dumps(document.to_json()))) + + +def _array_document_model(data: dict[str, JSON]) -> ArrayDocumentModel: + """The model of an array metadata document in the form `get_array_metadata` + returns: the Zarr format 2 form carries its attributes merged in. + + The read is strict: a document with problems — including one zarr can only + read as repaired — raises `zarr_metadata.MetadataValidationError` naming + each problem. A document whose extension names are unknown is not a + problem: the model reports them as unclaimed fields, and resolution + fails only when the data path is built. + """ + if data.get("zarr_format") == 2: + return ZarrV2ArrayMetadata(data) + return ZarrV3ArrayMetadata(data) + + +class AsyncArrayIO: + """Explicit asynchronous IO for one Zarr array. + + An `AsyncArrayIO` binds an array metadata document to a location in a + store. The document is held as a `zarr_metadata` model — the JSON as + written, with extension names read but not resolved into codec or data + type classes — so every document operation (`store_metadata`, + `erase_metadata`, `erase_chunk`, `erase_chunks`, `verify`) works without + resolving anything: an array whose codecs this environment lacks can + still be opened, verified and erased. Reading and writing chunks + (`getitem` / `setitem`) resolves the document into classes on first use; + `metadata` is that resolved form, derived once from `document` — or, when + the instance was built from an already-resolved metadata object (as every + `AsyncArray` is), held directly, with `document` the derived one. + + Constructing one — directly, or with `build` — performs no IO: nothing is + read from or written to the store until an IO method is called. + `store_metadata` stores the metadata document, and `setitem` / `getitem` + store and read chunks, in whatever order the caller chooses. Every write + has an erasing inverse: `store_metadata` ↔ `erase_metadata`, and chunk + writes ↔ `erase_chunk` / `erase_chunks`. `AsyncArray` is an array-like + layer over this one: it wraps an `AsyncArrayIO` and delegates its metadata + and selection IO to it. + + `store_metadata` is the publication point: until it is called, no Zarr + reader resolves the array, so a failed chunks-first run leaves no readable + array. It does leave chunk keys in the store, which occupy space and would + be misread by a different array stored later at the same location; + `erase_chunks` removes them. + + Warning: the bound document is assumed, never verified against the store. + Reads and partial writes decode stored chunks under it, so binding a + document that disagrees with how existing chunks at this location were + encoded misreads or corrupts data — the hazard TensorStore documents for + `assume_metadata`. One exception to "no implicit metadata IO": an array + opened from a stored document that needed a repair stores that repair + before its first chunk write. Call `store_metadata` first to make that + write explicit. + """ + + _store_path: StorePath + _config: ArrayConfig + _source: ArrayDocumentModel | ArrayMetadata + _document_cache: ArrayDocumentModel | None + _resolved_cache: ArrayMetadata | None + _chunk_grid_cache: ChunkGrid | None + _chunk_key_encoding_cache: ChunkKeyEncoding | None + _codec_pipeline_cache: CodecPipeline | None + + def __init__( + self, + document: ArrayDocumentModel | ArrayMetadata | ArrayMetadataDict, + store_path: StorePath, + config: ArrayConfigLike | None = None, + ) -> None: + self._store_path = store_path + self._config = parse_array_config(config) + self._document_cache = None + self._resolved_cache = None + self._chunk_grid_cache = None + self._chunk_key_encoding_cache = None + self._codec_pipeline_cache = None + if isinstance(document, ZarrV2ArrayMetadata | ZarrV3ArrayMetadata): + self._source = document + else: + # A resolved metadata object passes through; a raw document dict is + # read eagerly, as repaired when it needs that, warning as `AsyncArray` + # always has. + self._source = parse_array_metadata(document, str(store_path)) + + @classmethod + async def build( + cls, + store: StoreLike, + *, + name: str | None = None, + shape: ShapeLike, + dtype: ZDTypeLike, + chunks: ChunksLike | Literal["auto"] = "auto", + shards: ShardsLike | None = None, + filters: FiltersLike = "auto", + compressors: CompressorsLike = "auto", + serializer: SerializerLike = "auto", + fill_value: Any | None = DEFAULT_FILL_VALUE, + order: MemoryOrder | None = None, + zarr_format: ZarrFormat | None = 3, + attributes: dict[str, JSON] | None = None, + chunk_key_encoding: ChunkKeyEncodingLike | None = None, + dimension_names: DimensionNamesLike = None, + config: ArrayConfigLike | None = None, + ) -> AsyncArrayIO: + """Declare an array bound to a location in a store, without storing anything. + + The keyword arguments have the same meaning as in + [`zarr.api.asynchronous.create_array`][]. The returned instance holds + the normalized metadata in memory; `store_metadata` stores it. + """ + metadata = build_array_metadata( + shape=shape, + dtype=dtype, + chunks=chunks, + shards=shards, + filters=filters, + compressors=compressors, + serializer=serializer, + fill_value=fill_value, + order=order, + zarr_format=zarr_format, + attributes=attributes, + chunk_key_encoding=chunk_key_encoding, + dimension_names=dimension_names, + ) + store_path = await make_store_path(store, path=name) + return cls(metadata, store_path, config) + + @classmethod + async def open( + cls, + store: StoreLike, + *, + name: str | None = None, + zarr_format: ZarrFormat | None = None, + config: ArrayConfigLike | None = None, + ) -> AsyncArrayIO: + """Read the metadata document of an existing array and bind to it. + + This is the one classmethod constructor that performs IO: the array's + stored metadata document is read from the store. `zarr_format=None` + (the default) reads Zarr format 3 and falls back to format 2, as + `open` does in `zarrs`; pass 2 or 3 to require one format. The read is + strict and does not resolve extension names: an array with unknown + codecs opens for document work, while a stored document zarr can only + read as repaired raises `zarr_metadata.MetadataValidationError` — open + such an array with [`zarr.api.asynchronous.open_array`][], which reads + it as repaired. + """ + store_path = await make_store_path(store, path=name) + data = await get_array_metadata(store_path, zarr_format=zarr_format) + return cls(_array_document_model(data), store_path, config) + + @property + def document(self) -> ArrayDocumentModel: + """The metadata document this instance is bound to, as a `zarr_metadata` model.""" + source = self._source + if isinstance(source, ZarrV2ArrayMetadata | ZarrV3ArrayMetadata): + return source + if self._document_cache is None: + buffers = source.to_buffer_dict(default_buffer_prototype()) + key_value = {key: buffer.to_bytes() for key, buffer in buffers.items()} + if isinstance(source, ArrayV2Metadata): + self._document_cache = ZarrV2ArrayMetadata.from_key_value(key_value) + else: + self._document_cache = ZarrV3ArrayMetadata.from_key_value(key_value) + return self._document_cache + + @property + def metadata(self) -> ArrayMetadata: + """The resolved metadata: the document with its codec and data type names + resolved into classes, derived from `document` on first use.""" + source = self._source + if isinstance(source, ArrayV2Metadata | ArrayV3Metadata): + return source + if self._resolved_cache is None: + data = _document_json(source) + if isinstance(source, ZarrV2ArrayMetadata): + data["attributes"] = dict(source.attributes) + self._resolved_cache = parse_array_metadata(data, str(self._store_path)) + return self._resolved_cache + + @property + def store_path(self) -> StorePath: + """The location in a store this instance is bound to.""" + return self._store_path + + @property + def config(self) -> ArrayConfig: + """The runtime configuration of this instance.""" + return self._config + + @property + def _zarr_format(self) -> ZarrFormat: + if isinstance(self._source, ZarrV2ArrayMetadata | ArrayV2Metadata): + return 2 + return 3 + + @property + def _resolved_if_any(self) -> ArrayMetadata | None: + source = self._source + if isinstance(source, ArrayV2Metadata | ArrayV3Metadata): + return source + return self._resolved_cache + + def _rebind_resolved( + self, resolved: ArrayMetadata, chunk_grid: ChunkGrid | None = None + ) -> None: + """Make `resolved` this binding's state; the document re-derives from it. + The codec pipeline is kept: rebinding is how `resize` swaps the shape, + which leaves the chunk encoding as it was.""" + self._source = resolved + self._document_cache = None + self._resolved_cache = None + self._chunk_grid_cache = chunk_grid + self._chunk_key_encoding_cache = None + + @property + def _chunk_grid(self) -> ChunkGrid: + """The stored chunk grid: from the resolved metadata when the instance + holds one, and otherwise from the document alone, so the storage-level + operations of a document-bound instance never resolve codecs.""" + if self._chunk_grid_cache is None: + resolved = self._resolved_if_any + if resolved is not None: + self._chunk_grid_cache = ChunkGrid.from_metadata(resolved) + return self._chunk_grid_cache + document = self.document + doc = _document_json(document) + if isinstance(document, ZarrV2ArrayMetadata): + self._chunk_grid_cache = ChunkGrid.from_sizes(document.shape, tuple(doc["chunks"])) + else: + grid = doc["chunk_grid"] + # Through the same grid-metadata objects `ChunkGrid.from_metadata` + # dispatches on, so both derivations lay out chunks identically. + # A grid needs its configuration to lay out keys, so the v3.1 + # bare-name shorthand cannot name a computable grid. + name = grid if isinstance(grid, str) else grid["name"] + sizes: Sequence[int | Sequence[int]] + if isinstance(grid, dict) and name == "regular": + sizes = tuple( + RegularChunkGridMetadata.from_dict( + cast("RegularChunkGridMetadataJSON", grid) + ).chunk_shape + ) + elif isinstance(grid, dict) and name == "rectilinear": + sizes = RectilinearChunkGridMetadata.from_dict( + cast("RectilinearChunkGridMetadataJSON", grid) + ).chunk_shapes + else: + raise ValueError( + f"Array {str(self._store_path)!r}: unknown chunk grid " + f"{name!r}; its stored chunk keys cannot be computed." + ) + self._chunk_grid_cache = ChunkGrid.from_sizes(document.shape, sizes) + return self._chunk_grid_cache + + def _encode_chunk_key(self, chunk_coords: tuple[int, ...]) -> str: + """The store key of a stored chunk: from the resolved metadata when the + instance holds one, and otherwise from the document alone.""" + resolved = self._resolved_if_any + if resolved is not None: + return resolved.encode_chunk_key(chunk_coords) + return self._chunk_key_encoding.encode_chunk_key(chunk_coords) + + @property + def _chunk_key_encoding(self) -> ChunkKeyEncoding: + """The chunk key encoding, derived from the document alone.""" + if self._chunk_key_encoding_cache is None: + document = self.document + doc = _document_json(document) + if isinstance(document, ZarrV2ArrayMetadata): + self._chunk_key_encoding_cache = V2ChunkKeyEncoding( + separator=doc.get("dimension_separator", ".") + ) + else: + encoding = doc["chunk_key_encoding"] + if isinstance(encoding, str): # the v3.1 bare-name shorthand + encoding = {"name": encoding} + self._chunk_key_encoding_cache = parse_chunk_key_encoding(encoding) + return self._chunk_key_encoding_cache + + @property + def _shard_grid_shape(self) -> tuple[int, ...]: + # The stored chunk grid is the shard grid when sharding is used, the + # chunk grid otherwise, so its keys are the stored chunk keys. + return self._chunk_grid.grid_shape + + @property + def _resolved_codec_pipeline(self) -> CodecPipeline: + if self._codec_pipeline_cache is None: + self._codec_pipeline_cache = create_codec_pipeline( + metadata=self.metadata, store=self._store_path.store + ) + return self._codec_pipeline_cache + + def to_array(self) -> AnyAsyncArray: + """Return an `AsyncArray` over this binding; both share one state.""" + return AsyncArray._from_io(self) + + async def store_metadata(self, *, ensure_parents: bool = False) -> None: + """Store this array's metadata document(s). + + The documents are written unconditionally, replacing the metadata of + any existing node at this location, as `store_metadata` does in + `zarrs`. Guarded creation, which raises when a node already exists, + is [`zarr.api.asynchronous.create_array`][]'s job. Chunks of any + previous array at this location are kept and would be read under the + new metadata; call `erase_chunks` first when repurposing a location. + With `ensure_parents`, missing parent groups are stored first. + """ + source = self._source + if isinstance(source, ArrayV2Metadata | ArrayV3Metadata): + await save_metadata(self._store_path, source, ensure_parents=ensure_parents) + _stored_document_replaced(source) + else: + prototype = default_buffer_prototype() + to_save = { + str(key): prototype.buffer.from_bytes(value) + for key, value in source.to_key_value().items() + } + await _write_metadata( + self._store_path, to_save, self._zarr_format, ensure_parents=ensure_parents + ) + + async def verify(self) -> None: + """Check that the stored metadata declares the array this instance is bound to. + + Reads the metadata document stored at this location — favoring Zarr + format 3 when documents of both formats exist — and raises + `ValueError` naming each differing field when it does not equal the + bound document. Document equality is semantic: a fill value is + compared in its canonical spelling. Raises + [`zarr.errors.ArrayNotFoundError`][] when nothing is stored. This is + the explicit counterpart of the verification TensorStore performs + against spec constraints on open: a writer that declared its own + metadata with `build` can assert, before writing chunks, that it + agrees with what a coordinator stored. + """ + bound = self.document + stored = _array_document_model(await get_array_metadata(self._store_path, zarr_format=None)) + if stored == bound: + return + if type(stored) is not type(bound): + stored_format = 2 if isinstance(stored, ZarrV2ArrayMetadata) else 3 + raise ValueError( + f"Array {str(self.store_path)!r}: the stored array is Zarr format " + f"{stored_format}; the bound document is Zarr format {self._zarr_format}." + ) + stored_json = dict(stored.to_json()) + bound_json = dict(bound.to_json()) + if isinstance(stored, ZarrV2ArrayMetadata) and isinstance(bound, ZarrV2ArrayMetadata): + stored_json["attributes"] = dict(stored.attributes) + bound_json["attributes"] = dict(bound.attributes) + differing = sorted( + key + for key in stored_json.keys() | bound_json.keys() + if stored_json.get(key) != bound_json.get(key) + ) + raise ValueError( + f"Array {str(self.store_path)!r}: the stored metadata does not match the " + f"bound metadata; differing fields: {', '.join(differing)}." + ) + + async def erase_metadata(self) -> None: + """Erase this array's metadata document(s), the inverse of `store_metadata`. + + Succeeds if the documents do not exist, as `erase_metadata` does in + `zarrs`. Stored chunks are kept; `erase_chunks` removes those. + """ + for document in ARRAY_DOCUMENTS[self._zarr_format]: + await (self._store_path / document).delete() + resolved = self._resolved_if_any + if resolved is not None: + _stored_document_replaced(resolved) + + async def erase_chunk(self, chunk_coords: tuple[int, ...]) -> None: + """Erase one stored chunk by its chunk grid coordinates. + + When sharding is used, the stored chunk is the shard. Succeeds if the + chunk does not exist, as `erase_chunk` does in `zarrs`. + """ + grid_shape = self._shard_grid_shape + if len(chunk_coords) != len(grid_shape) or any( + coord < 0 or coord >= size for coord, size in zip(chunk_coords, grid_shape, strict=True) + ): + raise IndexError( + f"Chunk coordinates {chunk_coords} are outside the chunk grid " + f"of shape {grid_shape}." + ) + await _delete_key(self._store_path, self._encode_chunk_key(chunk_coords)) + + async def erase_chunks(self) -> None: + """Erase every stored chunk of this array, the inverse of its chunk writes. + + Chunks that do not exist are skipped; the metadata documents are kept. + """ + grid_shape = self._shard_grid_shape + n_chunks = product(grid_shape) + if n_chunks == 0: + return + limit = zarr_config.get("async.concurrency") or 1000 + await concurrent_foreach( + ( + (self._store_path, self._encode_chunk_key(coords)) + for coords in _iter_grid(grid_shape) + ), + _delete_key, + min(limit, n_chunks), + ) + + async def _get_selection( + self, + indexer: Indexer, + *, + prototype: BufferPrototype, + out: NDBuffer | None = None, + fields: Fields | None = None, + ) -> NDArrayLikeOrScalar: + return await _get_selection( + self._store_path, + self.metadata, + self._resolved_codec_pipeline, + self._config, + self._chunk_grid, + indexer, + prototype=prototype, + out=out, + fields=fields, + ) + + async def _set_selection( + self, + indexer: Indexer, + value: npt.ArrayLike, + *, + prototype: BufferPrototype, + fields: Fields | None = None, + ) -> None: + metadata = self.metadata + if product(indexer.shape) > 0: + # Chunks are about to be stored under the repaired metadata, so store it + # first: every reader of the store then agrees with them. + await _store_repaired_document(self._store_path, metadata) + return await _set_selection( + self._store_path, + metadata, + self._resolved_codec_pipeline, + self._config, + self._chunk_grid, + indexer, + value, + prototype=prototype, + fields=fields, + ) + + async def getitem( + self, + selection: BasicSelection, + *, + prototype: BufferPrototype | None = None, + ) -> NDArrayLikeOrScalar: + """Read the selected region, reading absent chunks as the fill value.""" + if prototype is None: + prototype = default_buffer_prototype() + indexer = BasicIndexer(selection, shape=self.metadata.shape, chunk_grid=self._chunk_grid) + return await self._get_selection(indexer, prototype=prototype) + + async def setitem( + self, + selection: BasicSelection, + value: npt.ArrayLike, + prototype: BufferPrototype | None = None, + ) -> None: + """Encode `value` and store the chunks covering `selection`.""" + if prototype is None: + prototype = default_buffer_prototype() + indexer = BasicIndexer(selection, shape=self.metadata.shape, chunk_grid=self._chunk_grid) + return await self._set_selection(indexer, value, prototype=prototype) + + def __repr__(self) -> str: + return f"" + + def __eq__(self, other: object) -> bool: + # Semantic equality over the binding: the document (canonically, via the + # model's own equality), the location and the runtime configuration. + if not isinstance(other, AsyncArrayIO): + return NotImplemented + if self._store_path != other._store_path or self._config != other._config: + return False + resolved, other_resolved = self._resolved_if_any, other._resolved_if_any + if resolved is not None and other_resolved is not None: + return resolved == other_resolved + return self.document == other.document + + def __hash__(self) -> int: + # A subset of the equality fields, available without deriving either + # form: equal bindings hash equally. + return hash((type(self), self._source.shape, self._store_path.path)) + + +class ArrayIO: + """Explicit synchronous IO for one Zarr array. See `AsyncArrayIO`.""" + + _async_io: AsyncArrayIO + + def __init__( + self, + document: ArrayDocumentModel | ArrayMetadata | ArrayMetadataDict, + store_path: StorePath, + config: ArrayConfigLike | None = None, + ) -> None: + self._async_io = AsyncArrayIO(document, store_path, config) + + @classmethod + def _wrap(cls, async_io: AsyncArrayIO) -> ArrayIO: + io = object.__new__(cls) + io._async_io = async_io + return io + + @classmethod + def build( + cls, + store: StoreLike, + *, + name: str | None = None, + shape: ShapeLike, + dtype: ZDTypeLike, + chunks: ChunksLike | Literal["auto"] = "auto", + shards: ShardsLike | None = None, + filters: FiltersLike = "auto", + compressors: CompressorsLike = "auto", + serializer: SerializerLike = "auto", + fill_value: Any | None = DEFAULT_FILL_VALUE, + order: MemoryOrder | None = None, + zarr_format: ZarrFormat | None = 3, + attributes: dict[str, JSON] | None = None, + chunk_key_encoding: ChunkKeyEncodingLike | None = None, + dimension_names: DimensionNamesLike = None, + config: ArrayConfigLike | None = None, + ) -> ArrayIO: + """Declare an array bound to a location in a store, without storing anything. + + See `AsyncArrayIO.build`. + """ + return cls._wrap( + sync( + AsyncArrayIO.build( + store, + name=name, + shape=shape, + dtype=dtype, + chunks=chunks, + shards=shards, + filters=filters, + compressors=compressors, + serializer=serializer, + fill_value=fill_value, + order=order, + zarr_format=zarr_format, + attributes=attributes, + chunk_key_encoding=chunk_key_encoding, + dimension_names=dimension_names, + config=config, + ) + ) + ) + + @classmethod + def open( + cls, + store: StoreLike, + *, + name: str | None = None, + zarr_format: ZarrFormat | None = None, + config: ArrayConfigLike | None = None, + ) -> ArrayIO: + """Read the metadata document of an existing array and bind to it. + + See `AsyncArrayIO.open`. + """ + return cls._wrap( + sync(AsyncArrayIO.open(store, name=name, zarr_format=zarr_format, config=config)) + ) + + @property + def document(self) -> ArrayDocumentModel: + """The metadata document this instance is bound to, as a `zarr_metadata` model.""" + return self._async_io.document + + @property + def metadata(self) -> ArrayMetadata: + """The resolved metadata. See `AsyncArrayIO.metadata`.""" + return self._async_io.metadata + + @property + def store_path(self) -> StorePath: + """The location in a store this instance is bound to.""" + return self._async_io.store_path + + @property + def config(self) -> ArrayConfig: + """The runtime configuration of this instance.""" + return self._async_io.config + + def to_array(self) -> AnyArray: + """Return an `Array` over this binding; both share one state.""" + return Array(self._async_io.to_array()) + + def store_metadata(self, *, ensure_parents: bool = False) -> None: + """Store this array's metadata document(s). See `AsyncArrayIO.store_metadata`.""" + sync(self._async_io.store_metadata(ensure_parents=ensure_parents)) + + def verify(self) -> None: + """Check that the stored metadata declares the array this instance is bound to. + + See `AsyncArrayIO.verify`. + """ + sync(self._async_io.verify()) + + def erase_metadata(self) -> None: + """Erase this array's metadata document(s). See `AsyncArrayIO.erase_metadata`.""" + sync(self._async_io.erase_metadata()) + + def erase_chunk(self, chunk_coords: tuple[int, ...]) -> None: + """Erase one stored chunk by its chunk grid coordinates. See `AsyncArrayIO.erase_chunk`.""" + sync(self._async_io.erase_chunk(chunk_coords)) + + def erase_chunks(self) -> None: + """Erase every stored chunk of this array. See `AsyncArrayIO.erase_chunks`.""" + sync(self._async_io.erase_chunks()) + + def getitem( + self, + selection: BasicSelection, + *, + prototype: BufferPrototype | None = None, + ) -> NDArrayLikeOrScalar: + """Read the selected region, reading absent chunks as the fill value.""" + return sync(self._async_io.getitem(selection, prototype=prototype)) + + def setitem( + self, + selection: BasicSelection, + value: npt.ArrayLike, + prototype: BufferPrototype | None = None, + ) -> None: + """Encode `value` and store the chunks covering `selection`.""" + sync(self._async_io.setitem(selection, value, prototype=prototype)) + + def __repr__(self) -> str: + return f"" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, ArrayIO): + return NotImplemented + return self._async_io == other._async_io + + def __hash__(self) -> int: + return hash((type(self), self._async_io)) diff --git a/tests/test_array_io.py b/tests/test_array_io.py new file mode 100644 index 0000000000..68f60c2e5f --- /dev/null +++ b/tests/test_array_io.py @@ -0,0 +1,204 @@ +"""Tests for the explicit-IO array layer, `zarr.ArrayIO` and `zarr.AsyncArrayIO`.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np +import pytest +from zarr_metadata import ZarrV3ArrayMetadata + +from zarr import ArrayIO, AsyncArrayIO +from zarr.api.asynchronous import open_array +from zarr.core.buffer import default_buffer_prototype +from zarr.errors import ArrayNotFoundError, UnknownCodecError +from zarr.storage import MemoryStore + +if TYPE_CHECKING: + from zarr.core.common import ZarrFormat + +METADATA_DOCUMENTS = ("zarr.json", ".zarray", ".zattrs", ".zgroup") + + +async def _store_keys(store: MemoryStore) -> set[str]: + return {key async for key in store.list()} + + +async def _store_foreign_codec_array(store: MemoryStore) -> None: + """Store a valid v3 array document whose codec this environment lacks.""" + document = ( + b'{"zarr_format": 3, "node_type": "array", "shape": [4], "data_type": "uint8",' + b' "fill_value": 0, "chunk_grid": {"name": "regular", "configuration": {"chunk_shape": [2]}},' + b' "chunk_key_encoding": {"name": "default"},' + b' "codecs": [{"name": "zarr-python-does-not-know-this-codec"}]}' + ) + prototype = default_buffer_prototype() + await store.set("foo/zarr.json", prototype.buffer.from_bytes(document)) + await store.set("foo/c/0", prototype.buffer.from_bytes(b"xx")) + await store.set("foo/c/1", prototype.buffer.from_bytes(b"yy")) + + +@pytest.mark.parametrize("zarr_format", [2, 3]) +async def test_chunks_before_metadata(zarr_format: ZarrFormat) -> None: + """Declaring an array stores nothing; chunks, then metadata, can be stored + in that order, and the result is an ordinary readable array.""" + store = MemoryStore() + data = np.arange(16, dtype="uint16").reshape(4, 4) + aio = await AsyncArrayIO.build( + store, + name="foo", + shape=data.shape, + dtype=data.dtype, + chunks=(2, 2), + zarr_format=zarr_format, + attributes={"answer": 42}, + ) + assert await _store_keys(store) == set() + + await aio.setitem(Ellipsis, data) + keys = await _store_keys(store) + assert len(keys) == 4 + assert not any(key.split("/")[-1] in METADATA_DOCUMENTS for key in keys) + np.testing.assert_array_equal(await aio.getitem(Ellipsis), data) + + await aio.store_metadata() + # the default zarr_format=None auto-detects the format, like zarrs + opened = await AsyncArrayIO.open(store, name="foo") + # the binding is a document; the stored and declared documents agree + assert opened.document == aio.document + assert opened.document.shape == data.shape + assert opened.metadata.zarr_format == zarr_format + assert opened.metadata.attributes == {"answer": 42} + np.testing.assert_array_equal(await opened.getitem(Ellipsis), data) + + arr = await open_array(store=store, path="foo", mode="r", zarr_format=zarr_format) + np.testing.assert_array_equal(await arr.getitem(Ellipsis), data) + # every AsyncArray is built on an AsyncArrayIO, reachable as .io + assert arr.io.metadata is arr.metadata + np.testing.assert_array_equal(await arr.io.getitem(Ellipsis), data) + + # a writer that declared the same array independently can verify its assumption + worker = await AsyncArrayIO.build( + store, + name="foo", + shape=data.shape, + dtype=data.dtype, + chunks=(2, 2), + zarr_format=zarr_format, + attributes={"answer": 42}, + ) + await worker.verify() + + # every write has an erasing inverse: one chunk, all chunks, then metadata + await opened.erase_chunk((0, 0)) + partially_erased = np.asarray(await opened.getitem(Ellipsis)) + np.testing.assert_array_equal(partially_erased[:2, :2], np.zeros((2, 2), dtype="uint16")) + np.testing.assert_array_equal(partially_erased[2:, :], data[2:, :]) + + await opened.erase_chunks() + await opened.erase_chunks() # erasing absent chunks succeeds + np.testing.assert_array_equal(await opened.getitem(Ellipsis), np.zeros_like(data)) + keys = await _store_keys(store) + assert all(key.split("/")[-1] in METADATA_DOCUMENTS for key in keys) + + await opened.erase_metadata() + await opened.erase_metadata() # erasing absent metadata succeeds + assert await _store_keys(store) == set() + with pytest.raises(ArrayNotFoundError): + await AsyncArrayIO.open(store, name="foo") + + +def test_chunks_before_metadata_sync() -> None: + """The synchronous `ArrayIO` supports the same chunks-first flow.""" + store = MemoryStore() + data = np.arange(16, dtype="uint16").reshape(4, 4) + aio = ArrayIO.build(store, name="foo", shape=data.shape, dtype=data.dtype, chunks=(2, 2)) + aio.setitem(Ellipsis, data) + aio.store_metadata() + opened = ArrayIO.open(store, name="foo") + np.testing.assert_array_equal(opened.getitem(Ellipsis), data) + np.testing.assert_array_equal(opened.to_array()[:], data) + # every Array is built on the IO layer, reachable as .io + np.testing.assert_array_equal(opened.to_array().io.getitem(Ellipsis), data) + opened.erase_chunk((0, 0)) + opened.erase_chunks() + opened.erase_metadata() + with pytest.raises(ArrayNotFoundError): + ArrayIO.open(store, name="foo") + + +def test_build_v2_shards_raises() -> None: + with pytest.raises(ValueError, match="shard_shape"): + ArrayIO.build( + MemoryStore(), + name="foo", + shape=(4,), + dtype="uint8", + chunks=(2,), + shards=(4,), + zarr_format=2, + ) + + +async def test_open_missing_raises() -> None: + with pytest.raises(ArrayNotFoundError): + await AsyncArrayIO.open(MemoryStore(), name="foo") + + +async def test_document_work_without_codec_resolution() -> None: + """An array whose codec this environment lacks still opens for document + work — reading the document, verifying it, erasing chunks and metadata — + and refuses only when the data path must be built.""" + store = MemoryStore() + await _store_foreign_codec_array(store) + + aio = await AsyncArrayIO.open(store, name="foo") + document = aio.document + assert isinstance(document, ZarrV3ArrayMetadata) + assert document.shape == (4,) + assert document.must_understand_fields == {} + await aio.verify() + + with pytest.raises(UnknownCodecError, match="zarr-python-does-not-know-this-codec"): + await aio.getitem(Ellipsis) + + await aio.erase_chunk((0,)) + await aio.erase_chunks() + await aio.store_metadata() + await aio.erase_metadata() + assert await _store_keys(store) == set() + + +async def test_verify_mismatch_raises() -> None: + store = MemoryStore() + aio = await AsyncArrayIO.build(store, shape=(4, 4), dtype="uint8", chunks=(2, 2)) + await aio.store_metadata() + other = await AsyncArrayIO.build( + store, shape=(4, 4), dtype="uint8", chunks=(4, 4), attributes={"a": 1} + ) + with pytest.raises(ValueError, match="differing fields: attributes, chunk_grid"): + await other.verify() + + +async def test_verify_format_mismatch_raises() -> None: + store = MemoryStore() + await ( + await AsyncArrayIO.build(store, shape=(4,), dtype="uint8", zarr_format=2) + ).store_metadata() + bound_v3 = await AsyncArrayIO.build(store, shape=(4,), dtype="uint8", zarr_format=3) + with pytest.raises(ValueError, match="stored array is Zarr format 2"): + await bound_v3.verify() + + +async def test_verify_missing_raises() -> None: + aio = await AsyncArrayIO.build(MemoryStore(), shape=(4,), dtype="uint8") + with pytest.raises(ArrayNotFoundError): + await aio.verify() + + +async def test_erase_chunk_out_of_grid_raises() -> None: + aio = await AsyncArrayIO.build(MemoryStore(), shape=(4, 4), dtype="uint8", chunks=(2, 2)) + with pytest.raises(IndexError, match="outside the chunk grid"): + await aio.erase_chunk((2, 0)) + with pytest.raises(IndexError, match="outside the chunk grid"): + await aio.erase_chunk((0,)) diff --git a/uv.lock b/uv.lock index 3b0dbe3049..23012125e9 100644 --- a/uv.lock +++ b/uv.lock @@ -3705,6 +3705,7 @@ dependencies = [ { name = "numpy" }, { name = "packaging" }, { name = "typing-extensions" }, + { name = "zarr-metadata" }, ] [package.optional-dependencies] @@ -3832,6 +3833,7 @@ requires-dist = [ { name = "typer", marker = "extra == 'cli'" }, { name = "typing-extensions", specifier = ">=4.14" }, { name = "universal-pathlib", marker = "extra == 'optional'" }, + { name = "zarr-metadata", specifier = ">=0.6.0" }, ] provides-extras = ["cast-value-rs", "cli", "gpu", "optional", "remote"] @@ -3924,3 +3926,16 @@ test = [ { name = "tomlkit", specifier = "==0.15.1" }, { name = "uv", specifier = "==0.13.4" }, ] + +[[package]] +name = "zarr-metadata" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a3/4526430215c09ed45d47886a7c8b439218622a4f05e6ce1198147961ca64/zarr_metadata-0.6.0.tar.gz", hash = "sha256:4462dc91cdb8c19c370bcd548096125591359579586f42de789dc495a33119d0", size = 519797, upload-time = "2026-10-26T13:55:27.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/b2/c70c528d438bbf656873e8da51949c50efff866b85c6ee3335ba81bf1142/zarr_metadata-0.6.0-py3-none-any.whl", hash = "sha256:ab1f9f33d18094ffac020c2c975aa32f8737f36a0dfa8543b6c6d717a3469d1e", size = 274628, upload-time = "2026-10-26T13:55:25.798Z" }, +]