Skip to content

Commit 49564dc

Browse files
committed
fix(metadata): read a legacy zero chunk size on an empty axis in Zarr format 3
The compatibility policy for a stored chunk size of 0 on a zero-length axis covered Zarr format 2 only, so arrays written by zarr-python 3.0 and 3.1 with `chunk_shape: [0]` — or `[false]`, which 3.0 wrote for `chunks=False` — still could not be opened at all. `ArrayV3Metadata` now applies the same policy to a regular chunk grid: a stored chunk size of 0 on a zero-length axis is read as 1 with a `ZarrUserWarning`, and a zero chunk size on a positive-length axis is left for the chunk grid parser to reject. It runs in `__init__` rather than in the grid parser because the policy needs the array shape, which chunk grid metadata does not carry. Both warnings now say how to store a corrected chunk size — open the array writable and call `array.update_attributes({})`, which rewrites the whole document from the parsed metadata — from one shared constant. The Zarr format 2 warning also named only zarr-python 2.x; measured against real installs, every 3.x release before 3.4 wrote a zero chunk size for an empty array too (3.3.0 for `chunks=-1` and `chunks=False`). Tested against stores written by zarr 3.0.10, 3.1.6 and 3.3.0: they open, append without losing data, and re-save to a chunk size that reopens without a warning. Assisted-by: ClaudeCode:claude-opus-5
1 parent 4456d80 commit 49564dc

6 files changed

Lines changed: 162 additions & 9 deletions

File tree

‎changes/4334.bugfix.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
1-
Chunk-grid normalization now shares a helper that selects chunk size 1 for a zero-length axis when inferring a full-span chunk size. Chunk edge lengths remain positive while array extents may be zero. Zarr format 2 metadata with a stored chunk edge of 0 on a zero-length axis is interpreted as chunk size 1 with a `ZarrUserWarning`; a zero chunk edge on a positive-length axis is rejected. This supports legacy metadata such as the zero chunk sizes written by zarr-python 2.18.7 for empty arrays created with `chunks=False`, `chunks=-1`, or `chunks=(0,)`, without claiming that every historical reader behaved identically. Rectilinear chunk grids can be created on a zero-length dimension with a non-empty list of positive chunk sizes; these sizes are retained for subsequent growth. `FixedDimension(size=0, ...)` now raises `ValueError`.
1+
Chunk-grid normalization now shares a helper that selects chunk size 1 for a zero-length axis when inferring a full-span chunk size. Chunk edge lengths remain positive while array extents may be zero. `FixedDimension(size=0, ...)` now raises `ValueError`. Rectilinear chunk grids can be created on a zero-length dimension with a non-empty list of positive chunk sizes; these sizes are retained for subsequent growth.
2+
3+
A stored chunk size of 0 on a zero-length axis is read as 1 with a `ZarrUserWarning`, in both Zarr format 2 (`chunks`) and Zarr format 3 (a regular grid's `chunk_shape`, including the JSON `false` written by zarr-python 3.0 for `chunks=False`); a zero chunk size on a positive-length axis is rejected. zarr-python wrote such metadata for empty arrays until 3.4 — Zarr format 2 through 3.3 and by zarr-python 2.x, Zarr format 3 in 3.0 and 3.1 — and appending to one of these Zarr format 2 arrays previously reported success while writing no chunk, so the appended data read back as the fill value. The warning explains how to store a corrected chunk size: open the array writable and call `array.update_attributes({})`.

‎src/zarr/core/metadata/common.py‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,20 @@
11
from __future__ import annotations
22

3-
from typing import TYPE_CHECKING
3+
from typing import TYPE_CHECKING, Final
44

55
if TYPE_CHECKING:
66
from zarr.core.common import JSON
77

8+
RESAVE_METADATA_HINT: Final = (
9+
"Re-save the array metadata to store the corrected value: open the array "
10+
"writable and call `array.update_attributes({})`."
11+
)
12+
"""How to persist metadata that was read under a compatibility policy.
13+
14+
`update_attributes` rewrites the whole metadata document from the parsed
15+
(corrected) metadata, so an empty update is enough.
16+
"""
17+
818

919
def parse_attributes(data: dict[str, JSON] | None) -> dict[str, JSON]:
1020
if data is None:

‎src/zarr/core/metadata/v2.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
)
4343
from zarr.core.config import config, parse_indexing_order
4444
from zarr.core.json_parse import parse_field
45-
from zarr.core.metadata.common import parse_attributes
45+
from zarr.core.metadata.common import RESAVE_METADATA_HINT, parse_attributes
4646

4747

4848
class ArrayV2MetadataDict(TypedDict):
@@ -90,8 +90,8 @@ def __init__(
9090
shape_parsed = parse_shapelike(shape)
9191
chunks_parsed = parse_shapelike(chunks)
9292
# Same invariant as the Zarr format 3 chunk grid metadata: every chunk edge
93-
# length is at least 1. zarr-python 2.18.7 can write `chunks: [0]` for
94-
# a zero-length axis created with `chunks=False`, `-1` or `(0,)`.
93+
# length is at least 1. zarr-python 2.18.7, and 3.x before 3.4, can write
94+
# `chunks: [0]` for a zero-length axis (e.g. `chunks=False`, `-1` or `(0,)`).
9595
# Normalize that empty axis to chunk size 1 so it can use the positive-size
9696
# grid model. This is a compatibility policy for legacy metadata, not a
9797
# statement about every historical reader. A zero chunk size on a
@@ -106,7 +106,8 @@ def __init__(
106106
)
107107
warnings.warn(
108108
f"Dimension {dim_idx}: chunk edge length 0 on a zero-length axis "
109-
"(as written by zarr-python 2.x) is treated as 1.",
109+
"(as written by zarr-python 2.x, and by 3.x before 3.4) is treated "
110+
f"as 1. {RESAVE_METADATA_HINT}",
110111
ZarrUserWarning,
111112
stacklevel=2,
112113
)

‎src/zarr/core/metadata/v3.py‎

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
from __future__ import annotations
22

33
import json
4+
import warnings
45
from collections.abc import Iterable, Mapping, Sequence
56
from dataclasses import dataclass, field, replace
67
from typing import TYPE_CHECKING, Any, Final, Literal, NotRequired, TypeGuard, cast
78

9+
import numpy as np
810
from typing_extensions import TypedDict
911

1012
from zarr.abc.codec import ArrayArrayCodec, ArrayBytesCodec, BytesBytesCodec, Codec
@@ -35,8 +37,8 @@
3537
from zarr.core.dtype import VariableLengthUTF8, ZDType, get_data_type_from_json
3638
from zarr.core.dtype.common import check_dtype_spec_v3
3739
from zarr.core.json_parse import parse_field
38-
from zarr.core.metadata.common import parse_attributes
39-
from zarr.errors import MetadataValidationError, NodeTypeValidationError
40+
from zarr.core.metadata.common import RESAVE_METADATA_HINT, parse_attributes
41+
from zarr.errors import MetadataValidationError, NodeTypeValidationError, ZarrUserWarning
4042
from zarr.registry import get_codec_class
4143

4244
if TYPE_CHECKING:
@@ -476,6 +478,51 @@ class ArrayMetadataJSON_V3(TypedDict, extra_items=AllowedExtraField): # type: i
476478
}
477479

478480

481+
def _read_legacy_zero_chunk_sizes(
482+
chunk_grid: dict[str, JSON] | ChunkGridMetadata | NamedConfig[str, Any],
483+
shape: tuple[int, ...],
484+
) -> dict[str, JSON] | ChunkGridMetadata | NamedConfig[str, Any]:
485+
"""Read a stored regular chunk size of 0 on a zero-length axis as 1.
486+
487+
zarr-python 3.0 and 3.1 stored `chunk_shape: [0]` for an array created with
488+
a zero-length axis and `chunks=(0,)` or `chunks=False` (3.0 stored `false`
489+
for the latter). Chunk sizes must be at least 1, and a zero-length axis has
490+
no chunks for the size to describe until the array grows, so the size is
491+
read as 1 with a warning. This is the policy `ArrayV2Metadata` applies to
492+
Zarr format 2 metadata. It needs the array shape, which the chunk grid
493+
metadata does not carry, so it runs here rather than in the grid parser.
494+
A zero chunk size on a positive-length axis is left for that parser to
495+
reject.
496+
"""
497+
if not isinstance(chunk_grid, Mapping) or chunk_grid.get("name") != "regular":
498+
return chunk_grid
499+
configuration = chunk_grid.get("configuration")
500+
if not isinstance(configuration, Mapping):
501+
return chunk_grid
502+
chunk_shape = configuration.get("chunk_shape")
503+
if not isinstance(chunk_shape, Sequence) or isinstance(chunk_shape, str):
504+
return chunk_grid
505+
if len(chunk_shape) != len(shape):
506+
return chunk_grid # the dimensionality check reports this
507+
normalized = list(chunk_shape)
508+
changed = False
509+
for dim_idx, (size, extent) in enumerate(zip(chunk_shape, shape, strict=True)):
510+
if isinstance(size, int | np.integer) and size == 0 and extent == 0:
511+
warnings.warn(
512+
f"Dimension {dim_idx}: chunk edge length {size!r} on a zero-length axis "
513+
"(as written by zarr-python 3.0 and 3.1) is treated as 1. "
514+
f"{RESAVE_METADATA_HINT}",
515+
ZarrUserWarning,
516+
stacklevel=2,
517+
)
518+
normalized[dim_idx] = 1
519+
changed = True
520+
if not changed:
521+
return chunk_grid
522+
corrected: dict[str, JSON] = {**configuration, "chunk_shape": normalized}
523+
return {"name": "regular", "configuration": corrected}
524+
525+
479526
@dataclass(frozen=True, kw_only=True)
480527
class ArrayV3Metadata(Metadata):
481528
shape: tuple[int, ...]
@@ -510,7 +557,9 @@ def __init__(
510557
"""
511558

512559
shape_parsed = parse_shapelike(shape)
513-
chunk_grid_parsed = parse_chunk_grid(chunk_grid)
560+
chunk_grid_parsed = parse_chunk_grid(
561+
_read_legacy_zero_chunk_sizes(chunk_grid, shape_parsed)
562+
)
514563
chunk_key_encoding_parsed = parse_chunk_key_encoding(chunk_key_encoding)
515564
dimension_names_parsed = parse_dimension_names(dimension_names)
516565
# Note: relying on a type method is numpy-specific

‎tests/test_chunk_grids.py‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import contextlib
2+
import json
3+
import warnings
4+
from pathlib import Path
25
from typing import Any, Literal, cast
36

47
import numpy as np
@@ -541,6 +544,62 @@ def test_rectilinear_zero_extent_matches_resize() -> None:
541544
assert created.write_chunk_sizes == resized.write_chunk_sizes == ((2, 1),)
542545

543546

547+
def _store_legacy_zero_chunk(path: Any, zarr_format: Literal[2, 3], stored: Any) -> None:
548+
"""Rewrite an array's stored chunk size to *stored*, as older zarr-python did."""
549+
doc_name = ".zarray" if zarr_format == 2 else "zarr.json"
550+
doc_path = path / doc_name
551+
doc = json.loads(doc_path.read_text())
552+
if zarr_format == 2:
553+
doc["chunks"] = [stored]
554+
else:
555+
doc["chunk_grid"]["configuration"]["chunk_shape"] = [stored]
556+
doc_path.write_text(json.dumps(doc))
557+
558+
559+
@pytest.mark.parametrize("zarr_format", [2, 3])
560+
@pytest.mark.parametrize("stored", [0, False], ids=["zero", "false"])
561+
def test_legacy_zero_chunk_on_empty_axis_round_trip(
562+
tmp_path: Path, zarr_format: Literal[2, 3], stored: Any
563+
) -> None:
564+
"""An empty array whose stored chunk size is 0 opens, appends without losing
565+
data, and re-saves as a valid chunk size.
566+
567+
zarr-python wrote such metadata for an empty array until 3.4: Zarr format 2
568+
through 3.3, and Zarr format 3 in 3.0 and 3.1, which also wrote JSON `false`
569+
for `chunks=False`. Appending to one of these arrays used to report success
570+
while writing no chunk, so the data read back as the fill value.
571+
"""
572+
path = tmp_path / "legacy.zarr"
573+
zarr.create_array(store=path, shape=(0,), chunks=(4,), dtype="int64", zarr_format=zarr_format)
574+
_store_legacy_zero_chunk(path, zarr_format, stored)
575+
576+
with pytest.warns(ZarrUserWarning, match="zero-length axis"):
577+
arr = zarr.open_array(store=path, mode="a")
578+
assert arr.chunks == (1,)
579+
580+
arr.append(np.arange(3, dtype="int64"))
581+
np.testing.assert_array_equal(zarr.open_array(store=path)[...], np.arange(3))
582+
583+
# The warning says to do this; it must leave metadata that reopens cleanly.
584+
arr.update_attributes({})
585+
with warnings.catch_warnings():
586+
warnings.simplefilter("error", ZarrUserWarning)
587+
reopened = zarr.open_array(store=path)
588+
assert reopened.chunks == (1,)
589+
590+
591+
@pytest.mark.parametrize("zarr_format", [2, 3])
592+
def test_stored_zero_chunk_on_positive_axis_rejected(
593+
tmp_path: Path, zarr_format: Literal[2, 3]
594+
) -> None:
595+
"""A stored chunk size of 0 is only tolerated on a zero-length axis."""
596+
path = tmp_path / "bad.zarr"
597+
zarr.create_array(store=path, shape=(5,), chunks=(4,), dtype="int64", zarr_format=zarr_format)
598+
_store_legacy_zero_chunk(path, zarr_format, 0)
599+
with pytest.raises(ValueError, match="must be >= 1"):
600+
zarr.open_array(store=path)
601+
602+
544603
def test_normalize_chunks_1d_zero_span_accepts_any_edges() -> None:
545604
"""On a zero-length span the explicit edge list is stored verbatim."""
546605
dim = normalize_chunks_1d([3, 5], span=0)

‎tests/test_metadata/test_v3.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,3 +463,35 @@ def test_group_metadata_to_dict_consolidated(attributes: dict[str, Any] | None)
463463
},
464464
},
465465
}
466+
467+
468+
@pytest.mark.parametrize(
469+
("shape", "chunk_shape", "expected"),
470+
[
471+
((0,), [0], (1,)),
472+
((4, 0), [4, 0], (4, 1)),
473+
((0, 0), [0, 0], (1, 1)),
474+
((0,), [False], (1,)),
475+
],
476+
ids=["1d", "one-empty-axis", "all-empty-axes", "json-false"],
477+
)
478+
def test_zero_chunk_size_on_empty_axis_normalized(
479+
shape: tuple[int, ...], chunk_shape: list[Any], expected: tuple[int, ...]
480+
) -> None:
481+
"""A stored regular chunk size of 0 on a zero-length axis is read as 1, with a warning.
482+
483+
zarr-python 3.0 and 3.1 wrote this for an array created with a zero-length
484+
axis; 3.0 wrote JSON `false` for `chunks=False`. This is the Zarr format 3
485+
counterpart of the policy `ArrayV2Metadata` applies.
486+
"""
487+
from zarr.core.metadata.v3 import RegularChunkGridMetadata
488+
from zarr.errors import ZarrUserWarning
489+
490+
d = minimal_metadata_dict_v3(
491+
shape=shape,
492+
chunk_grid={"name": "regular", "configuration": {"chunk_shape": chunk_shape}},
493+
)
494+
with pytest.warns(ZarrUserWarning, match="zero-length axis"):
495+
meta = ArrayV3Metadata.from_dict(d) # type: ignore[arg-type]
496+
assert isinstance(meta.chunk_grid, RegularChunkGridMetadata)
497+
assert meta.chunk_grid.chunk_shape == expected

0 commit comments

Comments
 (0)