Skip to content
Draft
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/4340.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Structured (``struct``) data types no longer silently corrupt data on the way in or out. Nested structured dtypes written with Zarr format 2 could not be read back, because the inner data type name check rejected the nested list-of-fields form that ``Struct.to_json`` itself produces. Structured dtypes that NumPy allows but this implementation does not preserve used to be accepted and rebuilt as something else: aligned or explicitly offset layouts came back packed with a different itemsize, a titled field came back as two fields, and a subarray field came back as raw bytes. ``Struct.from_native_dtype`` now raises a ``ValueError`` naming the offending field for all three. The ``zarr.testing.strategies`` module gains ``zdtypes`` and ``structured_dtypes`` strategies, and the property tests now check that every registered data type round-trips through JSON and NumPy, and that structured dtype resolution either raises or returns the input dtype exactly.
1 change: 1 addition & 0 deletions changes/4342.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`DateTime64` and `TimeDelta64` normalize `μs` to `us` and preserve generic-unit scale factors when converting native NumPy dtypes and serializing V2/V3 metadata. V2 writes an explicit suffix such as `[2generic]` to avoid the scale loss in NumPy's `dtype.str` and `dtype.name`. Generic temporal arrays also preserve dtype parameters during CPU buffer allocation and correctly convert byte order; generic datetime fill values can be read back, including in structured fields.
1 change: 1 addition & 0 deletions packages/zarr-metadata/changes/4342.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added `numpy_datetime64_configuration`, `numpy_timedelta64_configuration`, and `numpy_time_unit` leaf validators. They validate the documented unit vocabulary and integer scale range `[1, 2**31 - 1]`, including scaled generic units, and normalize `μs` to `us`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""
Vocabulary and validation shared by the `numpy.datetime64` and `numpy.timedelta64` data types.

This module is private (underscore-prefixed); the public names are re-exported by
`zarr_metadata.v3.data_type.numpy_datetime64` and
`zarr_metadata.v3.data_type.numpy_timedelta64`.
"""

from collections.abc import Mapping
from typing import Final, Literal, cast

NumpyTimeUnit = Literal[
"Y", "M", "W", "D", "h", "m", "s", "ms", "us", "μs", "ns", "ps", "fs", "as", "generic"
]
"""Time unit codes used by numpy.datetime64 and numpy.timedelta64."""

NUMPY_TIME_UNIT: Final = (
"Y",
"M",
"W",
"D",
"h",
"m",
"s",
"ms",
"us",
"μs",
"ns",
"ps",
"fs",
"as",
"generic",
)
"""Runtime tuple of the permitted `numpy.timedelta64`/`numpy.datetime64` unit strings."""

MAX_NUMPY_TIME_SCALE_FACTOR: Final = 2**31 - 1
"""The largest `scale_factor` NumPy accepts for a datetime64 or timedelta64 dtype."""

_CONFIGURATION_KEYS: Final = frozenset({"unit", "scale_factor"})


def numpy_time_unit(value: str) -> NumpyTimeUnit:
"""Validate `value` as a NumPy time unit and return its canonical spelling.

The spec lists `"us"` and `"μs"` as equivalent spellings of the microsecond
unit; NumPy itself only ever reports `"us"`, so `"μs"` is returned as `"us"`.

Raises ValueError if `value` is not one of `NUMPY_TIME_UNIT`.
"""
if value not in NUMPY_TIME_UNIT:
raise ValueError(f"Expected one of {NUMPY_TIME_UNIT}, got {value!r}")
if value == "μs":
return "us"
return cast("NumpyTimeUnit", value)


def numpy_time_configuration(value: Mapping[str, object]) -> tuple[NumpyTimeUnit, int]:
"""Validate a `numpy.datetime64` / `numpy.timedelta64` configuration object.

Returns the `(unit, scale_factor)` pair with the unit in its canonical spelling
(see `numpy_time_unit`).

Raises TypeError if `unit` is not a string or `scale_factor` is not an
integer. Raises ValueError if the object has keys other than exactly `unit`
and `scale_factor`, if `unit` is not a `NumpyTimeUnit`, if `scale_factor` is
outside `[1, MAX_NUMPY_TIME_SCALE_FACTOR]`.
"""
keys = frozenset(value)
if keys != _CONFIGURATION_KEYS:
raise ValueError(
f"Expected exactly the keys {sorted(_CONFIGURATION_KEYS)}, got {sorted(keys)}"
)
raw_unit = value["unit"]
if not isinstance(raw_unit, str):
raise TypeError(f"Expected 'unit' to be a string, got {raw_unit!r}")
unit = numpy_time_unit(raw_unit)
scale_factor = value["scale_factor"]
if isinstance(scale_factor, bool) or not isinstance(scale_factor, int):
raise TypeError(f"Expected 'scale_factor' to be an integer, got {scale_factor!r}")
if not 1 <= scale_factor <= MAX_NUMPY_TIME_SCALE_FACTOR:
raise ValueError(
f"Expected 'scale_factor' in [1, {MAX_NUMPY_TIME_SCALE_FACTOR}], got {scale_factor}"
)
return unit, scale_factor


__all__ = [
"MAX_NUMPY_TIME_SCALE_FACTOR",
"NUMPY_TIME_UNIT",
"NumpyTimeUnit",
"numpy_time_configuration",
"numpy_time_unit",
]
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,23 @@
See https://github.com/zarr-developers/zarr-extensions/tree/main/data-types/numpy.datetime64
"""

from collections.abc import Mapping
from typing import Final, Literal

from typing_extensions import ReadOnly, TypedDict

from zarr_metadata.v3.data_type._numpy_time import (
NumpyTimeUnit,
numpy_time_configuration,
numpy_time_unit,
)

NUMPY_DATETIME64_DATA_TYPE_NAME: Final = "numpy.datetime64"
"""The `name` field value of the `numpy.datetime64` data type."""

NumpyDatetime64DataTypeName = Literal["numpy.datetime64"]
"""Literal type of the `name` field of the `numpy.datetime64` data type."""

NumpyTimeUnit = Literal[
"Y", "M", "W", "D", "h", "m", "s", "ms", "us", "μs", "ns", "ps", "fs", "as", "generic"
]
"""Time unit codes used by numpy.datetime64."""


class NumpyDatetime64Configuration(TypedDict):
"""
Expand All @@ -36,6 +38,21 @@ class NumpyDatetime64Configuration(TypedDict):
scale_factor: ReadOnly[int]


def numpy_datetime64_configuration(value: Mapping[str, object]) -> NumpyDatetime64Configuration:
"""Validate `value` as a `numpy.datetime64` configuration and normalize it.

The returned configuration spells the microsecond unit `"us"` even when the
input used the equivalent `"μs"`.

Raises TypeError if `unit` is not a string or `scale_factor` is not an
integer. Raises ValueError if `value` does not have exactly the keys `unit`
and `scale_factor`, if `unit` is not a `NumpyTimeUnit`, if `scale_factor` is
outside `[1, 2**31 - 1]`.
"""
unit, scale_factor = numpy_time_configuration(value)
return {"unit": unit, "scale_factor": scale_factor}


class NumpyDatetime64(TypedDict):
"""`numpy.datetime64` data type metadata."""

Expand All @@ -57,4 +74,6 @@ class NumpyDatetime64(TypedDict):
"NumpyDatetime64DataTypeName",
"NumpyDatetime64FillValue",
"NumpyTimeUnit",
"numpy_datetime64_configuration",
"numpy_time_unit",
]
Original file line number Diff line number Diff line change
Expand Up @@ -4,40 +4,24 @@
See https://github.com/zarr-developers/zarr-extensions/tree/main/data-types/numpy.timedelta64
"""

from collections.abc import Mapping
from typing import Final, Literal

from typing_extensions import ReadOnly, TypedDict

from zarr_metadata.v3.data_type._numpy_time import (
NUMPY_TIME_UNIT,
NumpyTimeUnit,
numpy_time_configuration,
numpy_time_unit,
)

NUMPY_TIMEDELTA64_DATA_TYPE_NAME: Final = "numpy.timedelta64"
"""The `name` field value of the `numpy.timedelta64` data type."""

NumpyTimedelta64DataTypeName = Literal["numpy.timedelta64"]
"""Literal type of the `name` field of the `numpy.timedelta64` data type."""

NumpyTimeUnit = Literal[
"Y", "M", "W", "D", "h", "m", "s", "ms", "us", "μs", "ns", "ps", "fs", "as", "generic"
]
"""Time unit codes used by numpy.timedelta64."""

NUMPY_TIME_UNIT: Final = (
"Y",
"M",
"W",
"D",
"h",
"m",
"s",
"ms",
"us",
"μs",
"ns",
"ps",
"fs",
"as",
"generic",
)
"""Runtime tuple of the permitted `numpy.timedelta64`/`numpy.datetime64` unit strings."""


class NumpyTimedelta64Configuration(TypedDict):
"""
Expand All @@ -55,6 +39,23 @@ class NumpyTimedelta64Configuration(TypedDict):
scale_factor: ReadOnly[int]


def numpy_timedelta64_configuration(
value: Mapping[str, object],
) -> NumpyTimedelta64Configuration:
"""Validate `value` as a `numpy.timedelta64` configuration and normalize it.

The returned configuration spells the microsecond unit `"us"` even when the
input used the equivalent `"μs"`.

Raises TypeError if `unit` is not a string or `scale_factor` is not an
integer. Raises ValueError if `value` does not have exactly the keys `unit`
and `scale_factor`, if `unit` is not a `NumpyTimeUnit`, if `scale_factor` is
outside `[1, 2**31 - 1]`.
"""
unit, scale_factor = numpy_time_configuration(value)
return {"unit": unit, "scale_factor": scale_factor}


class NumpyTimedelta64(TypedDict):
"""`numpy.timedelta64` data type metadata."""

Expand All @@ -77,4 +78,6 @@ class NumpyTimedelta64(TypedDict):
"NumpyTimedelta64Configuration",
"NumpyTimedelta64DataTypeName",
"NumpyTimedelta64FillValue",
"numpy_time_unit",
"numpy_timedelta64_configuration",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Cover the `numpy_datetime64_configuration` validator.

The pydantic-driven fixture tests only check the structural shape of a
configuration; the constraints that tie `unit` and `scale_factor` together
live in the validator function and are covered directly here.
"""

from __future__ import annotations

import pytest

from zarr_metadata.v3.data_type.numpy_datetime64 import numpy_datetime64_configuration

# (input, expected normalized output)
VALID = [
({"unit": "ns", "scale_factor": 1}, {"unit": "ns", "scale_factor": 1}),
({"unit": "s", "scale_factor": 10}, {"unit": "s", "scale_factor": 10}),
({"unit": "generic", "scale_factor": 1}, {"unit": "generic", "scale_factor": 1}),
({"unit": "generic", "scale_factor": 2}, {"unit": "generic", "scale_factor": 2}),
(
{"unit": "generic", "scale_factor": 2**31 - 1},
{"unit": "generic", "scale_factor": 2**31 - 1},
),
({"unit": "us", "scale_factor": 2}, {"unit": "us", "scale_factor": 2}),
({"unit": "μs", "scale_factor": 2}, {"unit": "us", "scale_factor": 2}),
({"unit": "Y", "scale_factor": 2**31 - 1}, {"unit": "Y", "scale_factor": 2**31 - 1}),
]


@pytest.mark.parametrize(("value", "expected"), VALID, ids=lambda x: str(x))
def test_valid(value: dict[str, object], expected: dict[str, object]) -> None:
assert numpy_datetime64_configuration(value) == expected


@pytest.mark.parametrize(
"value",
[{}, {"unit": "s"}, {"scale_factor": 1}, {"unit": "s", "scale_factor": 1, "extra": 0}],
ids=lambda x: str(x),
)
def test_wrong_keys(value: dict[str, object]) -> None:
with pytest.raises(ValueError, match="Expected exactly the keys"):
numpy_datetime64_configuration(value)


@pytest.mark.parametrize("unit", [1, None], ids=str)
def test_unit_not_a_string(unit: object) -> None:
with pytest.raises(TypeError, match="Expected 'unit' to be a string"):
numpy_datetime64_configuration({"unit": unit, "scale_factor": 1})


@pytest.mark.parametrize("unit", ["", "invalid", "US", "seconds"], ids=str)
def test_unknown_unit(unit: str) -> None:
with pytest.raises(ValueError, match="Expected one of"):
numpy_datetime64_configuration({"unit": unit, "scale_factor": 1})


@pytest.mark.parametrize("scale_factor", [1.0, "1", True, None], ids=str)
def test_scale_factor_not_an_integer(scale_factor: object) -> None:
with pytest.raises(TypeError, match="Expected 'scale_factor' to be an integer"):
numpy_datetime64_configuration({"unit": "s", "scale_factor": scale_factor})


@pytest.mark.parametrize("scale_factor", [-1, 0, 2**31], ids=str)
def test_scale_factor_out_of_range(scale_factor: int) -> None:
with pytest.raises(ValueError, match=r"Expected 'scale_factor' in \[1, 2147483647\]"):
numpy_datetime64_configuration({"unit": "s", "scale_factor": scale_factor})
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Cover the `numpy_timedelta64_configuration` validator.

The pydantic-driven fixture tests only check the structural shape of a
configuration; the constraints that tie `unit` and `scale_factor` together
live in the validator function and are covered directly here.
"""

from __future__ import annotations

import pytest

from zarr_metadata.v3.data_type.numpy_timedelta64 import numpy_timedelta64_configuration

# (input, expected normalized output)
VALID = [
({"unit": "ns", "scale_factor": 1}, {"unit": "ns", "scale_factor": 1}),
({"unit": "s", "scale_factor": 10}, {"unit": "s", "scale_factor": 10}),
({"unit": "generic", "scale_factor": 1}, {"unit": "generic", "scale_factor": 1}),
({"unit": "generic", "scale_factor": 2}, {"unit": "generic", "scale_factor": 2}),
(
{"unit": "generic", "scale_factor": 2**31 - 1},
{"unit": "generic", "scale_factor": 2**31 - 1},
),
({"unit": "us", "scale_factor": 2}, {"unit": "us", "scale_factor": 2}),
({"unit": "μs", "scale_factor": 2}, {"unit": "us", "scale_factor": 2}),
({"unit": "Y", "scale_factor": 2**31 - 1}, {"unit": "Y", "scale_factor": 2**31 - 1}),
]


@pytest.mark.parametrize(("value", "expected"), VALID, ids=lambda x: str(x))
def test_valid(value: dict[str, object], expected: dict[str, object]) -> None:
assert numpy_timedelta64_configuration(value) == expected


@pytest.mark.parametrize(
"value",
[{}, {"unit": "s"}, {"scale_factor": 1}, {"unit": "s", "scale_factor": 1, "extra": 0}],
ids=lambda x: str(x),
)
def test_wrong_keys(value: dict[str, object]) -> None:
with pytest.raises(ValueError, match="Expected exactly the keys"):
numpy_timedelta64_configuration(value)


@pytest.mark.parametrize("unit", [1, None], ids=str)
def test_unit_not_a_string(unit: object) -> None:
with pytest.raises(TypeError, match="Expected 'unit' to be a string"):
numpy_timedelta64_configuration({"unit": unit, "scale_factor": 1})


@pytest.mark.parametrize("unit", ["", "invalid", "US", "seconds"], ids=str)
def test_unknown_unit(unit: str) -> None:
with pytest.raises(ValueError, match="Expected one of"):
numpy_timedelta64_configuration({"unit": unit, "scale_factor": 1})


@pytest.mark.parametrize("scale_factor", [1.0, "1", True, None], ids=str)
def test_scale_factor_not_an_integer(scale_factor: object) -> None:
with pytest.raises(TypeError, match="Expected 'scale_factor' to be an integer"):
numpy_timedelta64_configuration({"unit": "s", "scale_factor": scale_factor})


@pytest.mark.parametrize("scale_factor", [-1, 0, 2**31], ids=str)
def test_scale_factor_out_of_range(scale_factor: int) -> None:
with pytest.raises(ValueError, match=r"Expected 'scale_factor' in \[1, 2147483647\]"):
numpy_timedelta64_configuration({"unit": "s", "scale_factor": scale_factor})
Loading
Loading