diff --git a/changes/1.bugfix.md b/changes/1.bugfix.md new file mode 100644 index 0000000000..51ad387334 --- /dev/null +++ b/changes/1.bugfix.md @@ -0,0 +1 @@ +Zarr v3 node creation now rejects reserved node names before writing metadata. This prevents invalid hierarchies that cannot be discovered or reopened. diff --git a/packages/zarr-indexing/pyproject.toml b/packages/zarr-indexing/pyproject.toml index 50a63832ea..7d6e0ea5f1 100644 --- a/packages/zarr-indexing/pyproject.toml +++ b/packages/zarr-indexing/pyproject.toml @@ -60,7 +60,7 @@ docs = [ # Bump them together with the root. "mkdocs-material==9.7.7", "mkdocs==1.6.1", - "mkdocstrings==1.0.6", + "mkdocstrings==1.0.8", # pymdown-extensions 12.3 changed the Highlight extension API; mkdocstrings 1.0.6 # cannot drive it (Highlight.__init__() missing argument "md"). Match the # version in the repo-root uv.lock until mkdocstrings is bumped everywhere. diff --git a/packages/zarr-indexing/uv.lock b/packages/zarr-indexing/uv.lock index 37a98a5694..7cd08ca1f5 100644 --- a/packages/zarr-indexing/uv.lock +++ b/packages/zarr-indexing/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.12" [[package]] @@ -397,7 +397,7 @@ wheels = [ [[package]] name = "mkdocstrings" -version = "1.0.6" +version = "1.0.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jinja2" }, @@ -407,9 +407,9 @@ dependencies = [ { name = "mkdocs-autorefs" }, { name = "pymdown-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/53/71/f85bdf13355073ae15a7375f09879375a830553552e58c1c4b7e0bbc5c8b/mkdocstrings-1.0.6.tar.gz", hash = "sha256:a0b8c2bdd29a6416c80d717aa369bbf7831946bd9f23c2a66db1b1dbe7693dbd", size = 100649, upload-time = "2026-07-11T19:38:05.732Z" } +sdist = { url = "https://files.pythonhosted.org/packages/27/cc/e3ed8b16851f5d664dffcf8f55fc60d0d3f736a3edca21d1508105d1255a/mkdocstrings-1.0.8.tar.gz", hash = "sha256:b50fc326230906425c82ee9ec59b80e5db00892b2b593d4d0b841a0d4acf537b", size = 107444, upload-time = "2026-10-19T15:43:02.157Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/5b/4c1902e8bdd5c4db63284e9d101dece4038d4025d6d88850ffe0a1578980/mkdocstrings-1.0.6-py3-none-any.whl", hash = "sha256:2703708697487d1b6d6d7b412e176fa436edf120c1bf81dc9e126b12d00893c7", size = 35787, upload-time = "2026-07-11T19:38:04.417Z" }, + { url = "https://files.pythonhosted.org/packages/19/5e/4df7a2290e97c60a3ba410084966e5ab84aa89d15c8c98c195acbaefa8f4/mkdocstrings-1.0.8-py3-none-any.whl", hash = "sha256:4b03ed2b863009f1153f0d04aacb22a85b92afc28a0636d85717c92eecca638c", size = 42209, upload-time = "2026-10-19T15:43:00.65Z" }, ] [[package]] @@ -760,7 +760,7 @@ docs = [ { name = "griffe-inherited-docstrings", specifier = "==1.1.3" }, { name = "mkdocs", specifier = "==1.6.1" }, { name = "mkdocs-material", specifier = "==9.7.7" }, - { name = "mkdocstrings", specifier = "==1.0.6" }, + { name = "mkdocstrings", specifier = "==1.0.8" }, { name = "mkdocstrings-python", specifier = "==2.0.8" }, { name = "pymdown-extensions", specifier = "==11.0.1" }, { name = "ruff", specifier = "==0.16.8" }, diff --git a/packages/zarr-metadata/pyproject.toml b/packages/zarr-metadata/pyproject.toml index 109f5b48df..4c6017c269 100644 --- a/packages/zarr-metadata/pyproject.toml +++ b/packages/zarr-metadata/pyproject.toml @@ -55,7 +55,7 @@ docs = [ # Bump them together with the root. "mkdocs-material==9.7.7", "mkdocs==1.6.1", - "mkdocstrings==1.0.6", + "mkdocstrings==1.0.8", # pymdown-extensions 12.3 changed the Highlight extension API; mkdocstrings 1.0.6 # cannot drive it (Highlight.__init__() missing argument "md"). Match the # version in the repo-root uv.lock until mkdocstrings is bumped everywhere. diff --git a/packages/zarr-metadata/uv.lock b/packages/zarr-metadata/uv.lock index 52967144b5..0349bd0c7c 100644 --- a/packages/zarr-metadata/uv.lock +++ b/packages/zarr-metadata/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.11" [[package]] @@ -571,7 +571,7 @@ wheels = [ [[package]] name = "mkdocstrings" -version = "1.0.6" +version = "1.0.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jinja2" }, @@ -581,9 +581,9 @@ dependencies = [ { name = "mkdocs-autorefs" }, { name = "pymdown-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/53/71/f85bdf13355073ae15a7375f09879375a830553552e58c1c4b7e0bbc5c8b/mkdocstrings-1.0.6.tar.gz", hash = "sha256:a0b8c2bdd29a6416c80d717aa369bbf7831946bd9f23c2a66db1b1dbe7693dbd", size = 100649, upload-time = "2026-07-11T19:38:05.732Z" } +sdist = { url = "https://files.pythonhosted.org/packages/27/cc/e3ed8b16851f5d664dffcf8f55fc60d0d3f736a3edca21d1508105d1255a/mkdocstrings-1.0.8.tar.gz", hash = "sha256:b50fc326230906425c82ee9ec59b80e5db00892b2b593d4d0b841a0d4acf537b", size = 107444, upload-time = "2026-10-19T15:43:02.157Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/5b/4c1902e8bdd5c4db63284e9d101dece4038d4025d6d88850ffe0a1578980/mkdocstrings-1.0.6-py3-none-any.whl", hash = "sha256:2703708697487d1b6d6d7b412e176fa436edf120c1bf81dc9e126b12d00893c7", size = 35787, upload-time = "2026-07-11T19:38:04.417Z" }, + { url = "https://files.pythonhosted.org/packages/19/5e/4df7a2290e97c60a3ba410084966e5ab84aa89d15c8c98c195acbaefa8f4/mkdocstrings-1.0.8-py3-none-any.whl", hash = "sha256:4b03ed2b863009f1153f0d04aacb22a85b92afc28a0636d85717c92eecca638c", size = 42209, upload-time = "2026-10-19T15:43:00.65Z" }, ] [[package]] @@ -1163,7 +1163,7 @@ docs = [ { name = "griffe-inherited-docstrings", specifier = "==1.1.3" }, { name = "mkdocs", specifier = "==1.6.1" }, { name = "mkdocs-material", specifier = "==9.7.7" }, - { name = "mkdocstrings", specifier = "==1.0.6" }, + { name = "mkdocstrings", specifier = "==1.0.8" }, { name = "mkdocstrings-python", specifier = "==2.0.8" }, { name = "pymdown-extensions", specifier = "==11.0.1" }, { name = "ruff", specifier = "==0.16.8" }, diff --git a/pyproject.toml b/pyproject.toml index e6323ce2ba..e8f3e09a99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -139,7 +139,7 @@ docs = [ # Doc building "mkdocs-material[imaging]==9.7.7", "mkdocs==1.6.1", - "mkdocstrings==1.0.6", + "mkdocstrings==1.0.8", "mkdocstrings-python==2.0.9", "mike==2.2.0", "mkdocs-redirects==1.2.3", diff --git a/src/zarr/core/common.py b/src/zarr/core/common.py index 40046124ec..74f862bcc3 100644 --- a/src/zarr/core/common.py +++ b/src/zarr/core/common.py @@ -55,6 +55,20 @@ DimensionNames = DimensionNamesLike # for backwards compatibility +def validate_node_path(path: str, zarr_format: ZarrFormat) -> None: + """Validate node names in a normalized hierarchy path before creating them.""" + if zarr_format != 3 or not path: + return + + for name in path.split("/"): + if all(char == "." for char in name) or name.startswith("__") or name == ZARR_JSON: + raise ValueError( + f"Invalid Zarr format 3 node name {name!r} in path {path!r}. " + "Node names must not consist only of periods, start with '__', " + "or equal 'zarr.json'." + ) + + class NamedConfig[TName: str, TConfig: Mapping[str, object]](TypedDict): """ A typed dictionary representing an object with a name and configuration, where the configuration diff --git a/src/zarr/core/group.py b/src/zarr/core/group.py index 7792fa58de..b16ee41058 100644 --- a/src/zarr/core/group.py +++ b/src/zarr/core/group.py @@ -44,6 +44,7 @@ ShapeLike, ZarrFormat, parse_shapelike, + validate_node_path, ) from zarr.core.config import config from zarr.core.dtype import parse_data_type @@ -3379,6 +3380,9 @@ def _prepare_nodes( """The array or group each of `nodes` describes, at its path in `store`, and the metadata documents of `nodes`, by their keys in the store: every node is built and encoded before anything is stored.""" + for path, metadata in nodes.items(): + validate_node_path(path, metadata.zarr_format) + built = { path: _build_node(store=store, path=path, metadata=meta) for path, meta in nodes.items() } diff --git a/src/zarr/core/metadata/io.py b/src/zarr/core/metadata/io.py index c1cd927b94..462b7551e6 100644 --- a/src/zarr/core/metadata/io.py +++ b/src/zarr/core/metadata/io.py @@ -9,7 +9,7 @@ from zarr.core._json import buffer_to_json_object, json_equal from zarr.core.buffer.core import default_buffer_prototype from zarr.core.buffer.cpu import buffer_prototype as cpu_buffer_prototype -from zarr.core.common import ZARR_JSON, ZARRAY_JSON, ZATTRS_JSON +from zarr.core.common import ZARR_JSON, ZARRAY_JSON, ZATTRS_JSON, validate_node_path from zarr.core.metadata.repair import mark_repaired, repair_array_document from zarr.errors import ArrayNotFoundError, ContainsArrayError from zarr.storage._common import StorePath, ensure_no_existing_node @@ -228,6 +228,7 @@ async def save_new_metadata( ensure_parents : bool, optional Create any missing parent groups, and check no existing parents are arrays. """ + validate_node_path(store_path.path, metadata.zarr_format) to_save = encode_documents(store_path, metadata) if overwrite and store_path.store.supports_deletes: await store_path.delete_dir() diff --git a/tests/test_group.py b/tests/test_group.py index 6976ef536c..d2b2bd8651 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -171,6 +171,47 @@ def test_group_name_properties( assert branch.basename == branch_name.split("/")[-1] +@pytest.mark.parametrize("node_type", ["array", "group"]) +@pytest.mark.parametrize( + "name", + ["__reserved", "parent/__reserved", "zarr.json", "parent/zarr.json", "...", "parent/..."], +) +async def test_create_v3_rejects_invalid_node_names( + memory_store: MemoryStore, node_type: Literal["array", "group"], name: str +) -> None: + root = await AsyncGroup.from_store(memory_store, zarr_format=3) + keys_before = {key async for key in memory_store.list()} + if node_type == "array": + create_node = root.create_array(name, shape=(1,), dtype="uint8") + else: + create_node = root.create_group(name) + + with pytest.raises(ValueError, match="Invalid Zarr format 3 node name"): + await create_node + + assert {key async for key in memory_store.list()} == keys_before + + +@pytest.mark.parametrize("name", ["__reserved", "zarr.json"]) +async def test_create_v2_allows_v3_reserved_node_names( + memory_store: MemoryStore, name: str +) -> None: + root = await AsyncGroup.from_store(memory_store, zarr_format=2) + child = await root.create_group(name) + assert child.path == name + + +async def test_create_hierarchy_rejects_invalid_v3_node_name_without_writes( + memory_store: MemoryStore, +) -> None: + nodes = {"parent/__reserved": GroupMetadata(zarr_format=3)} + + with pytest.raises(ValueError, match="Invalid Zarr format 3 node name"): + await _collect_aiterator(create_hierarchy(store=memory_store, nodes=nodes)) + + assert {key async for key in memory_store.list()} == set() + + @pytest.mark.parametrize("consolidated_metadata", [True, False]) def test_group_members(store: Store, zarr_format: ZarrFormat, consolidated_metadata: bool) -> None: """ diff --git a/uv.lock b/uv.lock index ffeff19fa7..ca997f6608 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.15'", @@ -1649,7 +1649,7 @@ wheels = [ [[package]] name = "mkdocstrings" -version = "1.0.6" +version = "1.0.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jinja2" }, @@ -1659,9 +1659,9 @@ dependencies = [ { name = "mkdocs-autorefs" }, { name = "pymdown-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/53/71/f85bdf13355073ae15a7375f09879375a830553552e58c1c4b7e0bbc5c8b/mkdocstrings-1.0.6.tar.gz", hash = "sha256:a0b8c2bdd29a6416c80d717aa369bbf7831946bd9f23c2a66db1b1dbe7693dbd", size = 100649, upload-time = "2026-07-11T19:38:05.732Z" } +sdist = { url = "https://files.pythonhosted.org/packages/27/cc/e3ed8b16851f5d664dffcf8f55fc60d0d3f736a3edca21d1508105d1255a/mkdocstrings-1.0.8.tar.gz", hash = "sha256:b50fc326230906425c82ee9ec59b80e5db00892b2b593d4d0b841a0d4acf537b", size = 107444, upload-time = "2026-10-19T15:43:02.157Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/5b/4c1902e8bdd5c4db63284e9d101dece4038d4025d6d88850ffe0a1578980/mkdocstrings-1.0.6-py3-none-any.whl", hash = "sha256:2703708697487d1b6d6d7b412e176fa436edf120c1bf81dc9e126b12d00893c7", size = 35787, upload-time = "2026-07-11T19:38:04.417Z" }, + { url = "https://files.pythonhosted.org/packages/19/5e/4df7a2290e97c60a3ba410084966e5ab84aa89d15c8c98c195acbaefa8f4/mkdocstrings-1.0.8-py3-none-any.whl", hash = "sha256:4b03ed2b863009f1153f0d04aacb22a85b92afc28a0636d85717c92eecca638c", size = 42209, upload-time = "2026-10-19T15:43:00.65Z" }, ] [[package]] @@ -3848,7 +3848,7 @@ dev = [ { name = "mkdocs", specifier = "==1.6.1" }, { name = "mkdocs-material", extras = ["imaging"], specifier = "==9.7.7" }, { name = "mkdocs-redirects", specifier = "==1.2.3" }, - { name = "mkdocstrings", specifier = "==1.0.6" }, + { name = "mkdocstrings", specifier = "==1.0.8" }, { name = "mkdocstrings-python", specifier = "==2.0.9" }, { name = "moto", extras = ["s3", "server"], specifier = "==5.2.3" }, { name = "mypy", specifier = "==2.3.1" }, @@ -3879,7 +3879,7 @@ docs = [ { name = "mkdocs", specifier = "==1.6.1" }, { name = "mkdocs-material", extras = ["imaging"], specifier = "==9.7.7" }, { name = "mkdocs-redirects", specifier = "==1.2.3" }, - { name = "mkdocstrings", specifier = "==1.0.6" }, + { name = "mkdocstrings", specifier = "==1.0.8" }, { name = "mkdocstrings-python", specifier = "==2.0.9" }, { name = "numcodecs", extras = ["msgpack"] }, { name = "pytest", specifier = "==9.1.1" },