Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/1.bugfix.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/zarr-indexing/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 5 additions & 5 deletions packages/zarr-indexing/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/zarr-metadata/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 5 additions & 5 deletions packages/zarr-metadata/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 14 additions & 0 deletions src/zarr/core/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/zarr/core/group.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
Expand Down
3 changes: 2 additions & 1 deletion src/zarr/core/metadata/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
41 changes: 41 additions & 0 deletions tests/test_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand Down
12 changes: 6 additions & 6 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading