diff --git a/python/zarrista/_array.pyi b/python/zarrista/_array.pyi index 66a16d9..8095692 100644 --- a/python/zarrista/_array.pyi +++ b/python/zarrista/_array.pyi @@ -43,7 +43,8 @@ class Array: ) -> Array: """Use the provided metadata to open a new array at `path` in `store`. - This does **not** write the metadata to the store. + This does **not** write the metadata to the store; use + [`Array.store_metadata`][zarrista.Array.store_metadata] for that. """ @property def attrs(self) -> dict[str, JSONValue]: @@ -177,6 +178,15 @@ class Array: responsible for ensuring they match the array's codec pipeline; invalid bytes produce a chunk that cannot be decoded. """ + def store_metadata(self) -> None: + """Write the array's metadata to the store. + + This is the write counterpart to + [`Array.from_metadata`][zarrista.Array.from_metadata], which only constructs an + in-memory array, without writing to the store. + + Any existing metadata at the array's path is overwritten. + """ def compact_chunk( self, chunk_indices: list[int], @@ -263,7 +273,9 @@ class AsyncArray: ) -> AsyncArray: """Use the provided metadata to open a new array at `path` in `store`. - This does **not** write the metadata to the store. + This does **not** write the metadata to the store; use + [`AsyncArray.store_metadata`][zarrista.AsyncArray.store_metadata] for + that. """ @property def attrs(self) -> dict[str, JSONValue]: @@ -400,6 +412,15 @@ class AsyncArray: responsible for ensuring they match the array's codec pipeline; invalid bytes produce a chunk that cannot be decoded. """ + async def store_metadata(self) -> None: + """Write the array's metadata to the store. + + This is the write counterpart to + [`AsyncArray.from_metadata`][zarrista.AsyncArray.from_metadata], which only + constructs an in-memory array, without writing to the store. + + Any existing metadata at the array's path is overwritten. + """ async def compact_chunk( self, chunk_indices: list[int], diff --git a/python/zarrista/_builder.pyi b/python/zarrista/_builder.pyi index 539dd3e..e23f181 100644 --- a/python/zarrista/_builder.pyi +++ b/python/zarrista/_builder.pyi @@ -78,8 +78,27 @@ class ArrayBuilder: ) -> ArrayBuilder: """Return a new builder with the inner (subchunk) shape, enabling sharding.""" def create(self, store: FilesystemStore | MemoryStore, path: str) -> Array: - """Build the array in `store` at `path` and return it.""" + """Build the array in `store` at `path` and return it. + + This **does** write to the store: the array's metadata is stored at + `path`, overwriting any metadata already there. Use + [`ArrayBuilder.create_metadata`][zarrista.ArrayBuilder.create_metadata] + to build metadata without touching a store. + """ async def create_async(self, store: AsyncStore, path: str) -> AsyncArray: - """Build the array in an async `store` at `path` and return it.""" + """Build the array in an async `store` at `path` and return it. + + This **does** write to the store: the array's metadata is stored at + `path`, overwriting any metadata already there. Use + [`ArrayBuilder.create_metadata`][zarrista.ArrayBuilder.create_metadata] + to build metadata without touching a store. + """ def create_metadata(self) -> ArrayMetadataV3: - """Build the array's Zarr v3 metadata without touching a store.""" + """Build the array's Zarr v3 metadata without touching a store. + + Nothing is written. Pass the result to + [`Array.from_metadata`][zarrista.Array.from_metadata] to open an + in-memory array, or use + [`ArrayBuilder.create`][zarrista.ArrayBuilder.create] to build and write + in one step. + """ diff --git a/src/array/async.rs b/src/array/async.rs index 9144cef..c197e18 100644 --- a/src/array/async.rs +++ b/src/array/async.rs @@ -304,4 +304,16 @@ impl PyAsyncArray { Ok(()) }) } + + /// Write the array metadata to the store. + fn store_metadata<'py>(&self, py: Python<'py>) -> PyResult> { + let inner = self.inner.clone(); + future_into_py(py, async move { + inner + .async_store_metadata() + .await + .map_err(ZarristaError::from)?; + Ok(()) + }) + } } diff --git a/src/array/sync.rs b/src/array/sync.rs index 53ac913..5ff1ba6 100644 --- a/src/array/sync.rs +++ b/src/array/sync.rs @@ -214,4 +214,10 @@ impl PyArray { } Ok(()) } + + /// Write the array metadata to the store. + fn store_metadata(&self) -> ZarristaResult<()> { + self.inner.store_metadata()?; + Ok(()) + } } diff --git a/tests/array/test_store_metadata.py b/tests/array/test_store_metadata.py new file mode 100644 index 0000000..e45aa49 --- /dev/null +++ b/tests/array/test_store_metadata.py @@ -0,0 +1,82 @@ +"""`store_metadata()` writes the array's in-memory metadata to the store.""" + +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest +from obstore.store import LocalStore + +from zarrista import Array, AsyncArray +from zarrista.exceptions import ZarristaError +from zarrista.store import FilesystemStore, MemoryStore + +if TYPE_CHECKING: + from zarr_metadata import ArrayMetadataV3 + + +def _metadata(data_type: str = "int16") -> "ArrayMetadataV3": + return { + "zarr_format": 3, + "attributes": {}, + "chunk_grid": {"name": "regular", "configuration": {"chunk_shape": (2, 2)}}, + "data_type": data_type, + "chunk_key_encoding": {"name": "default", "configuration": {"separator": "/"}}, + "fill_value": 0, + "node_type": "array", + "shape": (4, 4), + "codecs": ({"name": "bytes"},), + } + + +def test_from_metadata_does_not_write() -> None: + store = MemoryStore() + Array.from_metadata(_metadata(), store) + + # Nothing was written, so there is no array to open. + with pytest.raises(ZarristaError): + Array.open(store) + + +def test_store_metadata_makes_array_openable() -> None: + store = MemoryStore() + array = Array.from_metadata(_metadata(), store) + array.store_metadata() + + reopened = Array.open(store) + assert reopened.shape == [4, 4] + assert reopened.dtype.name == "int16" + + +def test_store_metadata_overwrites(tmp_path: Path) -> None: + store = FilesystemStore(tmp_path) + Array.from_metadata(_metadata("int16"), store).store_metadata() + Array.from_metadata(_metadata("float64"), store).store_metadata() + + assert Array.open(FilesystemStore(tmp_path)).dtype.name == "float64" + + +def test_read_only_store_metadata_raises() -> None: + store = MemoryStore() + read_only = Array.from_metadata(_metadata(), store).read_only() + with pytest.raises(ZarristaError, match="read only"): + read_only.store_metadata() + + +# --- async -------------------------------------------------------------------- + + +async def test_async_store_metadata_makes_array_openable(tmp_path: Path) -> None: + store = LocalStore(str(tmp_path)) + array = AsyncArray.from_metadata(_metadata(), store) + await array.store_metadata() + + reopened = await AsyncArray.open_async(LocalStore(str(tmp_path))) + assert reopened.shape == [4, 4] + assert reopened.dtype.name == "int16" + + +async def test_async_read_only_store_metadata_raises(tmp_path: Path) -> None: + store = LocalStore(str(tmp_path)) + read_only = AsyncArray.from_metadata(_metadata(), store).read_only() + with pytest.raises(ZarristaError, match="read only"): + await read_only.store_metadata() diff --git a/tests/test_builder.py b/tests/test_builder.py index 0238535..8d9fe1f 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -3,6 +3,7 @@ import pytest from zarrista import ( + Array, ArrayBuilder, ChunkGrid, ChunkKeyEncoding, @@ -65,6 +66,23 @@ def test_create_returns_configured_array(): assert array.dimension_names == ["y", "x"] +def test_create_writes_metadata(): + """`create` writes metadata, so the array can be reopened from the store.""" + store = MemoryStore() + _builder().create(store, "/a") + + assert Array.open(store, "/a").shape == [8, 8] + + +def test_create_overwrites_existing_metadata(): + """`create` overwrites metadata already present at `path`.""" + store = MemoryStore() + _builder().create(store, "/a") + _builder().shape([16, 16]).create(store, "/a") + + assert Array.open(store, "/a").shape == [16, 16] + + def test_dimension_names_can_be_cleared(): meta = ( _builder().dimension_names(["y", "x"]).dimension_names(None).create_metadata()