Skip to content

Commit 0b053b2

Browse files
authored
fix(dtype): write the generic time unit in brackets in Zarr format 2 metadata (zarr-developers#4581)
* fix(dtype): write the generic time unit in brackets in Zarr format 2 metadata The v2 spec requires the unit of a datetime64 or timedelta64 dtype in brackets, but a generic-unit time dtype serialized as NumPy's unitless typestr (<M8), which the spec forbids. The generic unit is now written explicitly — <M8[generic], or <M8[2generic] with a scale, as scaled generic units already were — which NumPy parses back to the same dtype. The unitless spellings stay readable as the generic unit. Fixes zarr-developers#4580 Assisted-by: ClaudeCode:claude-fable-5 * docs: changelog fragment for #421 Assisted-by: ClaudeCode:claude-fable-5 * refactor(dtype): share the v2 time typestr spelling between the two time types The generic-unit bracketing moves to TimeDTypeBase._v2_name, which both to_json implementations call, instead of being duplicated in each. Assisted-by: ClaudeCode:claude-fable-5 * test(dtype): check v2 JSON alias spellings through the shared wrapper tests The one-off unitless-time test becomes json_alias_v2_params on the shared dtype test base: (alias, canonical) pairs asserting that reading either spelling gives the same dtype and writing gives the canonical one. The time types list the unitless generic spellings and NumPy's microsecond alias; other types can list their aliases the same way. Assisted-by: ClaudeCode:claude-fable-5
1 parent bf0d231 commit 0b053b2

4 files changed

Lines changed: 60 additions & 13 deletions

File tree

‎changes/421.bugfix.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
The generic time unit of a datetime64 or timedelta64 data type is now written in brackets in Zarr format 2 metadata — `<M8[generic]`, or `<M8[2generic]` with a scale — as the v2 spec requires, instead of NumPy's unitless typestr `<M8`. The unitless spellings stay readable as the generic unit.

‎src/zarr/core/dtype/npy/time.py‎

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,16 @@ def __post_init__(self) -> None:
239239
# Store the NumPy spelling; "μs" stays accepted as input and in stored metadata.
240240
object.__setattr__(self, "unit", "us")
241241

242+
def _v2_name(self) -> str:
243+
"""The v2 typestr: NumPy's spelling, except that the generic unit, which
244+
NumPy's `dtype.str` omits, is written in brackets with its scale, because
245+
the v2 spec requires a bracketed unit."""
246+
name: str = self.to_native_dtype().str
247+
if self.unit != "generic":
248+
return name
249+
scale = "" if self.scale_factor == 1 else self.scale_factor
250+
return f"{name}[{scale}generic]"
251+
242252
@classmethod
243253
def from_native_dtype(cls, dtype: TBaseDType) -> Self:
244254
"""
@@ -497,11 +507,7 @@ def to_json(self, zarr_format: ZarrFormat) -> TimeDelta64JSON_V2 | TimeDelta64JS
497507
If the zarr_format is not 2 or 3.
498508
"""
499509
if zarr_format == 2:
500-
name: str = self.to_native_dtype().str
501-
if self.unit == "generic" and self.scale_factor != 1:
502-
# NumPy omits generic scale from dtype.str; preserve it explicitly.
503-
name += f"[{self.scale_factor}generic]"
504-
return {"name": name, "object_codec_id": None}
510+
return {"name": self._v2_name(), "object_codec_id": None}
505511
elif zarr_format == 3:
506512
return {
507513
"name": self._zarr_v3_name,
@@ -785,11 +791,7 @@ def to_json(self, zarr_format: ZarrFormat) -> DateTime64JSON_V2 | DateTime64JSON
785791
If the zarr_format is not 2 or 3.
786792
"""
787793
if zarr_format == 2:
788-
name: str = self.to_native_dtype().str
789-
if self.unit == "generic" and self.scale_factor != 1:
790-
# NumPy omits generic scale from dtype.str; preserve it explicitly.
791-
name += f"[{self.scale_factor}generic]"
792-
return {"name": name, "object_codec_id": None}
794+
return {"name": self._v2_name(), "object_codec_id": None}
793795
elif zarr_format == 3:
794796
return {
795797
"name": self._zarr_v3_name,

‎tests/test_dtype/test_npy/test_time.py‎

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,9 @@ def _native_dtypes(kind: str) -> tuple[np.dtype[np.generic], ...]:
3333

3434

3535
def _v2_names(kind: str) -> tuple[DTypeSpec_V2, ...]:
36-
# NumPy's spelling, except that a generic scale is written explicitly.
36+
# NumPy's spelling, except that the generic unit, which NumPy omits, is
37+
# written explicitly: the v2 spec requires a bracketed unit.
3738
def suffix(unit: str, scale: int) -> str:
38-
if unit == "generic" and scale == 1:
39-
return ""
4039
return f"[{unit}]" if scale == 1 else f"[{scale}{unit}]"
4140

4241
return tuple(
@@ -81,6 +80,23 @@ class TestDateTime64(_TestTimeBase):
8180
np.dtype("timedelta64[ns]"),
8281
)
8382
valid_json_v2 = _v2_names("M8")
83+
# The legacy unitless spelling reads as the generic unit and writes back
84+
# bracketed, as the v2 spec requires (#4580); NumPy's microsecond alias
85+
# reads as NumPy's own spelling.
86+
json_alias_v2_params = (
87+
(
88+
{"name": "<M8", "object_codec_id": None},
89+
{"name": "<M8[generic]", "object_codec_id": None},
90+
),
91+
(
92+
{"name": ">M8", "object_codec_id": None},
93+
{"name": ">M8[generic]", "object_codec_id": None},
94+
),
95+
(
96+
{"name": "<M8[μs]", "object_codec_id": None},
97+
{"name": "<M8[us]", "object_codec_id": None},
98+
),
99+
)
84100
valid_json_v3 = _v3_configurations("numpy.datetime64")
85101
invalid_json_v2 = (
86102
{"name": "datetime64", "object_codec_id": None},
@@ -125,6 +141,21 @@ class TestTimeDelta64(_TestTimeBase):
125141
)
126142

127143
valid_json_v2 = _v2_names("m8")
144+
# See TestDateTime64.json_alias_v2_params.
145+
json_alias_v2_params = (
146+
(
147+
{"name": "<m8", "object_codec_id": None},
148+
{"name": "<m8[generic]", "object_codec_id": None},
149+
),
150+
(
151+
{"name": ">m8", "object_codec_id": None},
152+
{"name": ">m8[generic]", "object_codec_id": None},
153+
),
154+
(
155+
{"name": "<m8[μs]", "object_codec_id": None},
156+
{"name": "<m8[us]", "object_codec_id": None},
157+
),
158+
)
128159
valid_json_v3 = _v3_configurations("numpy.timedelta64")
129160
invalid_json_v2 = (
130161
{"name": "timedelta64", "object_codec_id": None},

‎tests/test_dtype/test_wrapper.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,11 @@ class BaseTestZDType:
9090
# pairs. the first element of the pair is used to create a dtype instance, and the second
9191
# element is the json serialization of the scalar that we want to round-trip.
9292

93+
# for testing v2 JSON spellings that read as a valid dtype but are not the
94+
# spelling zarr writes: (alias json, canonical json) pairs. Reading either
95+
# spelling gives the same dtype, and writing it gives the canonical spelling.
96+
json_alias_v2_params: ClassVar[tuple[tuple[DTypeSpec_V2, DTypeSpec_V2], ...]] = ()
97+
9398
scalar_v2_params: ClassVar[tuple[tuple[ZDType[Any, Any], Any], ...]] = ()
9499
scalar_v3_params: ClassVar[tuple[tuple[Any, Any], ...]] = ()
95100
cast_value_params: ClassVar[tuple[tuple[ZDType[Any, Any], Any, Any], ...]] = ()
@@ -127,6 +132,14 @@ def test_from_json_roundtrip_v3(self, valid_json_v3: DTypeSpec_V3) -> None:
127132
zdtype = self.test_cls.from_json(valid_json_v3, zarr_format=3)
128133
assert zdtype.to_json(zarr_format=3) == valid_json_v3
129134

135+
def test_from_json_alias_v2(
136+
self, json_alias_v2_params: tuple[DTypeSpec_V2, DTypeSpec_V2]
137+
) -> None:
138+
alias, canonical = json_alias_v2_params
139+
zdtype = self.test_cls.from_json(alias, zarr_format=2)
140+
assert zdtype == self.test_cls.from_json(canonical, zarr_format=2)
141+
assert zdtype.to_json(zarr_format=2) == canonical
142+
130143
def test_from_json_invalid_v2(self, invalid_json_v2: DTypeSpec_V2) -> None:
131144
with pytest.raises(DataTypeValidationError):
132145
self.test_cls.from_json(invalid_json_v2, zarr_format=2)

0 commit comments

Comments
 (0)