From 264c8bdd46a5caa8c7a515446f509998e9dc8f16 Mon Sep 17 00:00:00 2001 From: Valentin Boussot Date: Tue, 25 Aug 2026 12:30:02 +0200 Subject: [PATCH 1/7] feat(py,ts): add the OME-Zarr 0.9.dev1 version and its metadata model 0.9.dev1 is 0.6 plus RFC-3, which lifts the limits on the number, names, types and order of axes. It is opt-in: the default target is unchanged and a caller reaches it by passing version="0.9.dev1" explicitly. The model delegates dataset transform parsing and NgffImage construction to the v0.6 reader, and normalizes a 0.5-shaped entry (flat axes, no coordinateSystems) to a single intrinsic coordinate system, so either shape is readable. --- py/ngff_zarr/__init__.py | 9 +- py/ngff_zarr/_supported_versions.py | 20 ++ py/ngff_zarr/v09/__init__.py | 2 + py/ngff_zarr/v09/zarr_metadata.py | 388 ++++++++++++++++++++++++++++ py/test/test_v09_metadata.py | 254 ++++++++++++++++++ ts/src/types/supported_versions.ts | 19 ++ 6 files changed, 691 insertions(+), 1 deletion(-) create mode 100644 py/ngff_zarr/v09/__init__.py create mode 100644 py/ngff_zarr/v09/zarr_metadata.py create mode 100644 py/test/test_v09_metadata.py diff --git a/py/ngff_zarr/__init__.py b/py/ngff_zarr/__init__.py index e70adbe4..f04f15ec 100644 --- a/py/ngff_zarr/__init__.py +++ b/py/ngff_zarr/__init__.py @@ -4,7 +4,11 @@ # SPDX-License-Identifier: MIT from .__about__ import __version__ -from ._supported_versions import SUPPORTED_VERSIONS, V06_ONDISK_VERSION +from ._supported_versions import ( + SUPPORTED_VERSIONS, + V06_ONDISK_VERSION, + NgffVersion, +) from .cli_input_to_ngff_image import cli_input_to_ngff_image from .codecs import codec_from_name, get_available_codecs from .compute_omero import ( @@ -82,6 +86,7 @@ from .v04.zarr_metadata import ( AxesType, Axis, + AxisUnit, Dataset, Identity, Metadata, @@ -111,6 +116,7 @@ "__version__", "SUPPORTED_VERSIONS", "V06_ONDISK_VERSION", + "NgffVersion", "config", # OMERO computation "compute_omero_from_ngff_image", @@ -156,6 +162,7 @@ "Metadata", "MethodMetadata", "AxesType", + "AxisUnit", "SpatialDims", "SupportedDims", "SpaceUnits", diff --git a/py/ngff_zarr/_supported_versions.py b/py/ngff_zarr/_supported_versions.py index 3912ea8a..513a6794 100644 --- a/py/ngff_zarr/_supported_versions.py +++ b/py/ngff_zarr/_supported_versions.py @@ -16,6 +16,10 @@ class NgffVersion(StrEnum): #: while 0.6 was a draft carry the ``dev4`` tag on disk. V06dev4 = "0.6.dev4" V06rc0 = "0.6rc0" + # OME-Zarr 0.9 in development: 0.6 plus RFC-3 (any axis count, names, + # types and ordering). LATEST stays 0.6rc0, so 0.9.dev1 is opt-in. + V09dev1 = "0.9.dev1" + # An alias of V06rc0 (same value): it must stay last. LATEST = "0.6rc0" @@ -29,6 +33,7 @@ class NgffVersion(StrEnum): NgffVersion.V06, NgffVersion.V06dev4, NgffVersion.V06rc0, + NgffVersion.V09dev1, ) #: The ``ome.version`` string written to disk for the API version ``"0.6"``. @@ -44,3 +49,18 @@ class NgffVersion(StrEnum): #: rest, and ``upgrade_ome_zarr`` rewrites the tag. Any other tag is checked #: as given, so a tag from a later spec release is not passed off as this one. V06_SUPERSEDED_TAGS = frozenset({NgffVersion.V06dev4.value}) + + +def is_v06_version(version: object | None) -> bool: + """Whether ``version`` identifies OME-Zarr v0.6, dev releases included. + + Mirrors the TypeScript port's ``isV06Version`` so a store written by + either implementation is recognized the same way. Anything that is not a + string -- ``None`` included -- is not v0.6. + + Tested with ``isinstance`` rather than ``str()``: a :class:`NgffVersion` + member is a ``str`` subclass under both the stdlib ``StrEnum`` (3.11+) and + the backport above, but only the former renders as its value under + ``str()``; the backport renders as ``"NgffVersion.V06dev4"``. + """ + return isinstance(version, str) and version.startswith("0.6") diff --git a/py/ngff_zarr/v09/__init__.py b/py/ngff_zarr/v09/__init__.py new file mode 100644 index 00000000..9bd9d864 --- /dev/null +++ b/py/ngff_zarr/v09/__init__.py @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC +# SPDX-License-Identifier: MIT diff --git a/py/ngff_zarr/v09/zarr_metadata.py b/py/ngff_zarr/v09/zarr_metadata.py new file mode 100644 index 00000000..ec69d4c7 --- /dev/null +++ b/py/ngff_zarr/v09/zarr_metadata.py @@ -0,0 +1,388 @@ +# SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC +# SPDX-License-Identifier: MIT +"""OME-Zarr ``0.9.dev1`` metadata model (RFC-3). + +``0.9.dev1`` is OME-Zarr 0.6 plus RFC-3: an image may declare any number of +axes, with any names, any type strings, and in any order. The RFC-5 +coordinate-system and transformation model of 0.6 is unchanged. + +The dataclasses here carry no version condition. The axis restrictions live in +:mod:`ngff_zarr.structural_validation` and are applied against the *target* +version by the writer. + +This module defines :class:`Axis`, :class:`CoordinateSystem` and +:class:`Metadata`; every other symbol is re-exported from +:mod:`ngff_zarr.v06.zarr_metadata`. +""" + +import functools +import logging +from dataclasses import dataclass, field, fields +from typing import TYPE_CHECKING, Union + +from .._store_types import StoreLike +from .._supported_versions import NgffVersion +from ..rfc4 import AnatomicalOrientation + +# RFC-3 touches the axis model only; these symbols are unchanged from v0.6. +# They must be the same classes, not copies: ``v06.Metadata._to_v05`` +# dispatches on ``isinstance(t, Scale)`` and ``isinstance(t, +# TransformSequence)``, so copies would fall through those branches and emit +# default scale/translation transforms instead. +from ..v04.zarr_metadata import AxisUnit, SupportedDims +from ..v06.zarr_metadata import ( # noqa: F401 + Affine, + BaseTransform, + Coordinates, + CoordinateSystemIdentifier, + Dataset, + Displacements, + Identity, + MethodMetadata, + Omero, + Rotation, + Scale, + Transform, + TransformSequence, + Translation, +) +from ..v06.zarr_metadata import AxesType as AxesTypeV06 + +if TYPE_CHECKING: + from ..ngff_image import NgffImage + from ..v04.zarr_metadata import Metadata as Metadata_v04 + from ..v05.zarr_metadata import Metadata as Metadata_v05 + from ..v06.zarr_metadata import Metadata as Metadata_v06 + +logger = logging.getLogger(__name__) + +#: RFC-3 axis name. Any non-empty string is legal; the union keeps the +#: v0.4 ``SupportedDims`` vocabulary so editors still complete the conventional +#: names. Mirrors the TypeScript port's ``AxisName``. +AxisName = Union[SupportedDims, str] + +#: RFC-3 axis type. Any string is legal alongside the spec-defined vocabulary, +#: which the union keeps for the same reason. ``None`` is permitted: ``type`` +#: is optional in every published axes schema (``required: ["name"]``). +AxesType = Union[AxesTypeV06, str, None] + + +@dataclass +class Axis: + """An RFC-3 axis. + + Same fields as :class:`ngff_zarr.v06.zarr_metadata.Axis`, with ``name``, + ``type`` and ``unit`` widened: each keeps the spec-defined vocabulary in a + union with the free-form string RFC-3 allows. + + ``orientation`` (RFC-4) and ``discrete`` (RFC-5) are carried so a 0.6 + document round-trips without loss. + """ + + name: AxisName + type: AxesType = None + unit: AxisUnit | None = None + orientation: AnatomicalOrientation | None = None + discrete: bool | None = None + + +@dataclass +class CoordinateSystem: + """A named set of RFC-3 axes.""" + + name: str + axes: list[Axis] + + +@functools.lru_cache(maxsize=1) +def _get_axis_fields() -> set[str]: + """Valid field names of :class:`Axis`, cached.""" + return {f.name for f in fields(Axis)} + + +def _filter_axis_dict(axis_dict: dict) -> dict: + """Filter an axis dict down to the recognized :class:`Axis` fields. + + Unknown keys are logged and dropped. The 0.6 axes schema permits keys this + dataclass does not declare, such as ``longName``. + + Raises + ------ + ValueError + If the required ``name`` key is absent. + """ + if "name" not in axis_dict: + raise ValueError( + f"Axis dictionary is missing required field 'name': {axis_dict}" + ) + + axis_fields = _get_axis_fields() + unknown_fields = set(axis_dict.keys()) - axis_fields + if unknown_fields: + logger.warning( + f"Ignoring unknown fields {unknown_fields} in axis " + f"'{axis_dict['name']}'. These fields are not modelled by the " + f"ngff-zarr OME-Zarr 0.9.dev1 axis dataclass." + ) + filtered = {k: v for k, v in axis_dict.items() if k in axis_fields} + # The v0.6 reader builds axes with a bare ``Axis(**axis)`` and its ``type`` + # has no default, so an omitted ``type`` must be made explicit here. + filtered.setdefault("type", None) + return filtered + + +def _axis_from(axis: object) -> Axis: + """Re-instantiate any version's axis object as a v0.9.dev1 :class:`Axis`. + + ``discrete`` is read with :func:`getattr`: ``v06.Metadata._from_v05`` + assigns the v0.5 axis list straight into a ``CoordinateSystem``, so a v0.6 + ``Metadata`` reached from a 0.4/0.5 store holds v0.4 ``Axis`` instances, + which have no ``discrete`` field. + """ + return Axis( + name=axis.name, + type=axis.type, + unit=axis.unit, + orientation=axis.orientation, + discrete=getattr(axis, "discrete", None), + ) + + +@dataclass +class Metadata: + """OME-Zarr ``0.9.dev1`` multiscales metadata. + + Same fields as :class:`ngff_zarr.v06.zarr_metadata.Metadata`. It carries + ``coordinateSystems``, not the flat ``axes`` list of v0.4/v0.5, so the + v0.6 <-> 0.9.dev1 hop is a lossless structural copy; routing through v0.5 + would drop every rotation, affine, coordinates and displacements + transform. + + There is no ``version`` field, as in v0.5 and v0.6. From v0.5 on the spec + version lives in the group-level ``ome`` namespace, written by + ``to_ngff_zarr._write_root_ome_attrs`` from the writer's ``version`` + argument. A field here would be emitted by ``asdict`` inside the + ``multiscales[]`` entry, which the ``ome-namespace`` rule rejects. + """ + + coordinateSystems: list[CoordinateSystem] + datasets: list[Dataset] + coordinateTransformations: list[Transform] | None = None + omero: Omero | None = None + name: str = "image" + type: str | None = None + metadata: MethodMetadata | None = None + #: Unrecognized keys captured on read (see + #: :attr:`ngff_zarr.v04.zarr_metadata.Metadata.extra`). Carried across + #: version conversion; a read-side validation aid, never serialized. + extra: dict = field(default_factory=dict) + + def __post_init__(self): + """On-the-fly validation not well covered by the JSON schemas.""" + _ = self.intrinsic_coordinate_system + + @property + def intrinsic_coordinate_system(self) -> CoordinateSystem: + output_cs = [ds.coordinateTransformations[0].output for ds in self.datasets] + + if output_cs[0] is None: + raise ValueError( + "No output coordinate system found in dataset coordinate transformations. " + ) + + if not all(output == output_cs[0] for output in output_cs): + raise ValueError( + "Multiple different outputs coordinate systems found in" + f" coordinate transformations for multiscales: {output_cs}. " + "This is out of spec for this ome-zarr 0.9.dev1." + ) + + for cs in self.coordinateSystems: + if cs.name == output_cs[0].name: + return cs + raise ValueError( + f"Dataset coordinate transformations reference coordinate system" + f" {output_cs[0].name!r}, which is not declared in" + f" coordinateSystems: {[cs.name for cs in self.coordinateSystems]}." + ) + + @property + def axes(self) -> list[Axis]: + """The intrinsic coordinate system's axes. + + A property, not a field, so ``dataclasses.asdict`` and + ``dataclasses.fields`` ignore it and the serialized entry is unchanged. + The axis rules in :mod:`ngff_zarr.structural_validation` read + ``metadata.axes``. + """ + return self.intrinsic_coordinate_system.axes + + @property + def dimension_names(self) -> tuple: + return tuple(ax.name for ax in self.intrinsic_coordinate_system.axes) + + def to_version( + self, version: Union[str, NgffVersion] + ) -> Union["Metadata", "Metadata_v04", "Metadata_v05", "Metadata_v06"]: + if isinstance(version, str): + version = NgffVersion(version) + + if version == NgffVersion.V09dev1: + return self + if version in (NgffVersion.V06, NgffVersion.V06dev4): + return self._to_v06() + if version == NgffVersion.V05: + return self._to_v06()._to_v05() + if version == NgffVersion.V04: + return self._to_v06()._to_v05()._to_v04() + raise ValueError(f"Unsupported version conversion: 0.9.dev1 -> {version}") + + @classmethod + def from_version( + cls, + metadata: Union["Metadata", "Metadata_v04", "Metadata_v05", "Metadata_v06"], + ) -> "Metadata": + from ..v04.zarr_metadata import Metadata as Metadata_v04 + from ..v05.zarr_metadata import Metadata as Metadata_v05 + from ..v06.zarr_metadata import Metadata as Metadata_v06 + + if isinstance(metadata, cls): + return metadata + if isinstance(metadata, Metadata_v06): + return cls._from_v06(metadata) + if isinstance(metadata, (Metadata_v04, Metadata_v05)): + return cls._from_v06(Metadata_v06.from_version(metadata)) + raise ValueError( + f"Unsupported metadata type ({type(metadata)}) for conversion to 0.9.dev1" + ) + + def _to_v06(self) -> "Metadata_v06": + """Structurally map to v0.6, re-instantiating every axis. + + This does not enforce the v0.6 axis restrictions; the writer applies + them against the target version. This converter is also used on the + way to v0.5 and v0.4. + """ + from ..v06.zarr_metadata import Axis as Axis_v06 + from ..v06.zarr_metadata import CoordinateSystem as CoordinateSystem_v06 + from ..v06.zarr_metadata import Metadata as Metadata_v06 + + coordinate_systems = [ + CoordinateSystem_v06( + name=cs.name, + axes=[ + Axis_v06( + name=ax.name, + type=ax.type, + unit=ax.unit, + orientation=ax.orientation, + discrete=ax.discrete, + ) + for ax in cs.axes + ], + ) + for cs in self.coordinateSystems + ] + + return Metadata_v06( + coordinateSystems=coordinate_systems, + datasets=list(self.datasets), + coordinateTransformations=self.coordinateTransformations, + omero=self.omero, + name=self.name, + type=self.type, + metadata=self.metadata, + extra=dict(self.extra), + ) + + @classmethod + def _from_v06(cls, metadata_v06: "Metadata_v06") -> "Metadata": + """Structurally map from v0.6, re-instantiating every axis. + + ``v06.Metadata._from_v05`` assigns the v0.5 axis list straight into + ``CoordinateSystem.axes``, so a v0.6 ``Metadata`` reached from a + 0.4/0.5 store holds v0.4 ``Axis`` objects, in the same list object. + Aliasing them would share mutable state with the source, drop fields + under ``dataclasses.asdict`` (which walks the runtime class), and break + equality, which dataclasses compare class-exact. + """ + coordinate_systems = [ + CoordinateSystem(name=cs.name, axes=[_axis_from(ax) for ax in cs.axes]) + for cs in metadata_v06.coordinateSystems + ] + + return cls( + coordinateSystems=coordinate_systems, + datasets=list(metadata_v06.datasets), + coordinateTransformations=metadata_v06.coordinateTransformations, + omero=metadata_v06.omero, + name=metadata_v06.name, + type=metadata_v06.type, + metadata=metadata_v06.metadata, + extra=dict(metadata_v06.extra), + ) + + @classmethod + def _from_zarr_attrs( + cls, + root_attrs: dict, + store: StoreLike, + validate: bool = False, + subpath: str | None = None, + ) -> tuple["Metadata", list["NgffImage"]]: + """Read a ``0.9.dev1`` store. + + Dataset transform parsing and ``NgffImage`` construction are delegated + to the v0.6 reader. Handled here first: + + 1. ``validate=True`` is refused: no ``0.9.dev1`` JSON Schema is + published, so no ``spec/0.9.dev1/schemas`` tree is bundled. + 2. A 0.5-shaped entry (flat ``axes``, no ``coordinateSystems``) is + normalized to a single ``intrinsic`` coordinate system, so either + shape is readable. + 3. Unknown axis keys are stripped; the v0.6 reader builds axes with a + bare ``Axis(**axis)``. + """ + import copy + + from ..v06.zarr_metadata import Metadata as Metadata_v06 + + if validate: + raise NotImplementedError( + "Schema validation is unavailable for OME-Zarr 0.9.dev1: OME has " + "published no JSON Schema for it, so ngff-zarr ships no " + "spec/0.9.dev1/schemas tree. Read with validate=False and use " + "ngff_zarr.validate_structural() for the structural rules." + ) + + if "ome" not in root_attrs or "multiscales" not in root_attrs.get("ome", {}): + raise ValueError( + "Invalid OME-Zarr 0.9.dev1 metadata: missing 'ome' or 'multiscales' field." + ) + + # Attributes are plain JSON, so a deep copy is cheap and keeps the + # caller's dict untouched while the entry is normalized in place. + patched = dict(root_attrs) + patched["ome"] = copy.deepcopy(root_attrs["ome"]) + entry = patched["ome"]["multiscales"][0] + + if "coordinateSystems" not in entry and "axes" in entry: + # A 0.5-shaped entry: flat axes, and dataset transforms that carry + # no coordinate-system identifiers. The v0.6 reader dereferences + # those identifiers, so read this shape with the v0.5 reader and + # convert up. + from ..v05.zarr_metadata import Metadata as Metadata_v05 + + patched["ome"]["version"] = "0.5" + metadata_v05, images = Metadata_v05._from_zarr_attrs( + patched, store, validate=False, subpath=subpath + ) + return cls.from_version(metadata_v05), images + + for cs in entry.get("coordinateSystems", []): + cs["axes"] = [_filter_axis_dict(axis) for axis in cs.get("axes", [])] + + metadata_v06, images = Metadata_v06._from_zarr_attrs( + patched, store, validate=False, subpath=subpath + ) + return cls._from_v06(metadata_v06), images diff --git a/py/test/test_v09_metadata.py b/py/test/test_v09_metadata.py new file mode 100644 index 00000000..6dad7198 --- /dev/null +++ b/py/test/test_v09_metadata.py @@ -0,0 +1,254 @@ +# SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC +# SPDX-License-Identifier: MIT +"""OME-Zarr 0.9.dev1 metadata model: reading, conversion and schema reporting. + +Covers the store shapes a 0.9.dev1 reader has to accept (RFC-5 coordinate +systems, a 0.5-style flat axis list, and axes that declare no ``type``), the +conversions to and from every other version, and the axis view the write gate +builds. +""" + +import numpy as np +import pytest +import zarr +from ngff_zarr import from_ome_zarr +from ngff_zarr._supported_versions import NgffVersion +from ngff_zarr.v04.zarr_metadata import Metadata as Metadata_v04 +from ngff_zarr.v05.zarr_metadata import Metadata as Metadata_v05 +from ngff_zarr.v06.zarr_metadata import Metadata as Metadata_v06 +from ngff_zarr.v09.zarr_metadata import ( + Axis, + CoordinateSystem, + CoordinateSystemIdentifier, + Dataset, + Metadata, + Scale, + TransformSequence, + Translation, +) +from packaging import version + +zarr_v3 = pytest.mark.skipif( + version.parse(zarr.__version__) < version.parse("3.0.0b1"), + reason="OME-Zarr 0.9.dev1 is a Zarr v3 hierarchy; zarr version < 3.0.0b1", +) + + +def _write_v09(root, axes, shape, *, flat=False): + """Write a single-level 0.9.dev1 store, in either entry shape.""" + group = zarr.open_group(zarr.storage.LocalStore(str(root)), mode="w", zarr_format=3) + array = group.create_array("0", shape=shape, dtype="uint8", chunks=shape) + array[...] = np.arange(int(np.prod(shape)), dtype="uint8").reshape(shape) + + scale = {"type": "scale", "scale": [1.0] * len(axes)} + if flat: + # 0.5-style: a flat axis list, and dataset transforms that carry no + # coordinate-system identifiers. + entry = { + "axes": axes, + "datasets": [{"path": "0", "coordinateTransformations": [scale]}], + "name": "image", + } + else: + entry = { + "coordinateSystems": [{"name": "intrinsic", "axes": axes}], + "datasets": [ + { + "path": "0", + "coordinateTransformations": [ + { + "type": "sequence", + "transformations": [scale], + "output": {"name": "intrinsic"}, + } + ], + } + ], + "name": "image", + } + group.attrs["ome"] = {"version": "0.9.dev1", "multiscales": [entry]} + return str(root) + + +def _axis(name, axis_type="space"): + return Axis(name=name, type=axis_type) + + +def _metadata(coordinate_systems): + output = CoordinateSystemIdentifier(name=coordinate_systems[0].name) + ndim = len(coordinate_systems[0].axes) + dataset = Dataset( + path="0", + coordinateTransformations=[ + TransformSequence( + transformations=[ + Scale(scale=[1.0] * ndim), + Translation(translation=[0.0] * ndim), + ], + output=output, + ) + ], + ) + return Metadata(coordinateSystems=coordinate_systems, datasets=[dataset]) + + +@zarr_v3 +def test_read_coordinate_system_shape(tmp_path): + """The RFC-5 entry shape reads back at 0.9.dev1 with its axes intact.""" + axes = [{"name": n, "type": "space"} for n in "abcdef"] + root = _write_v09(tmp_path / "cs.ome.zarr", axes, (2,) * 6) + + multiscales = from_ome_zarr(root, validate=False) + + assert type(multiscales.metadata).__module__.endswith("v09.zarr_metadata") + assert multiscales.metadata.dimension_names == tuple("abcdef") + + +@zarr_v3 +def test_read_flat_axes_shape(tmp_path): + """A 0.5-style flat axis list is accepted, not only the RFC-5 shape. + + The v0.6 reader dereferences a transform's output coordinate system, which + this shape does not declare. + """ + axes = [{"name": n, "type": "space"} for n in "zyx"] + root = _write_v09(tmp_path / "flat.ome.zarr", axes, (2, 3, 4), flat=True) + + multiscales = from_ome_zarr(root, validate=False) + + assert type(multiscales.metadata).__module__.endswith("v09.zarr_metadata") + assert multiscales.metadata.dimension_names == ("z", "y", "x") + + +@zarr_v3 +@pytest.mark.parametrize("flat", [False, True], ids=["coordinate-systems", "flat-axes"]) +def test_read_axis_without_type(tmp_path, flat): + """An axis declaring only ``name`` reads back with ``type`` ``None``.""" + axes = [{"name": "a"}, {"name": "b"}, {"name": "c"}] + root = _write_v09(tmp_path / f"untyped-{flat}.ome.zarr", axes, (2, 3, 4), flat=flat) + + multiscales = from_ome_zarr(root, validate=False) + + assert multiscales.metadata.dimension_names == ("a", "b", "c") + assert [ax.type for ax in multiscales.metadata.axes] == [None, None, None] + + +@zarr_v3 +def test_read_keeps_unknown_axis_keys_out(tmp_path): + """A schema-legal key the dataclass does not model is dropped, not fatal.""" + axes = [{"name": n, "type": "space", "longName": f"{n} axis"} for n in "zyx"] + root = _write_v09(tmp_path / "longname.ome.zarr", axes, (2, 3, 4)) + + multiscales = from_ome_zarr(root, validate=False) + + assert multiscales.metadata.dimension_names == ("z", "y", "x") + + +@pytest.mark.parametrize("version", ["0.4", "0.5", "0.6", "0.9.dev1"]) +def test_to_version_round_trip(version): + """0.9.dev1 converts to every version and back without losing the axes.""" + metadata = _metadata([CoordinateSystem("intrinsic", [_axis(n) for n in "zyx"])]) + + converted = metadata.to_version(version) + expected = { + "0.4": Metadata_v04, + "0.5": Metadata_v05, + "0.6": Metadata_v06, + "0.9.dev1": Metadata, + }[version] + assert isinstance(converted, expected) + + back = Metadata.from_version(converted) + assert isinstance(back, Metadata) + assert back.dimension_names == ("z", "y", "x") + + +def test_from_version_reinstantiates_axes(): + """Converting up yields v0.9.dev1 axes, not the source version's objects.""" + metadata = _metadata([CoordinateSystem("intrinsic", [_axis(n) for n in "zyx"])]) + downgraded = metadata.to_version("0.4") + + upgraded = Metadata.from_version(downgraded) + + assert all(isinstance(ax, Axis) for ax in upgraded.axes) + # The source keeps its own axis objects. + assert upgraded.axes is not downgraded.axes + + +def test_axis_views_cover_every_coordinate_system(): + """The write gate inspects every coordinate system, not just the intrinsic one. + + ``Metadata`` exposes an ``axes`` property returning only the intrinsic + system's axes, so a view built from that attribute would miss the others. + """ + from ngff_zarr.to_ngff_zarr import _axis_views + + metadata = _metadata( + [ + CoordinateSystem("intrinsic", [_axis(n) for n in "zyx"]), + CoordinateSystem("other", [_axis(n) for n in "abcdef"]), + ] + ) + + views = _axis_views(metadata) + + assert len(views) == 2 + assert [len(view.axes) for _, view in views] == [3, 6] + + +def test_axes_property_is_not_a_field(): + """``axes`` must not be serialized into the multiscales entry.""" + from dataclasses import asdict + + metadata = _metadata([CoordinateSystem("intrinsic", [_axis(n) for n in "zyx"])]) + + assert "axes" not in asdict(metadata) + assert metadata.axes == metadata.coordinateSystems[0].axes + + +def test_no_bundled_schema_is_reported_explicitly(): + """``load_schema`` names the missing 0.9.dev1 schema instead of failing on I/O.""" + from ngff_zarr.validate import load_schema + + with pytest.raises(ValueError, match="0.9.dev1"): + load_schema(version="0.9.dev1") + + +def test_version_is_supported(): + from inspect import signature + + from ngff_zarr import to_ome_zarr + from ngff_zarr._supported_versions import SUPPORTED_VERSIONS + + assert NgffVersion("0.9.dev1") is NgffVersion.V09dev1 + assert NgffVersion.V09dev1 in SUPPORTED_VERSIONS + # 0.9.dev1 is opt-in: it must not become the default target. Checked on the + # writer's own default, which is what decides how a store is written. + # Pinning ``NgffVersion.LATEST`` to a particular release would fail here on + # every spec bump for a reason unrelated to 0.9.dev1, and that alias is + # read by no code in either port. + assert signature(to_ome_zarr).parameters["version"].default != "0.9.dev1" + assert NgffVersion.LATEST is not NgffVersion.V09dev1 + + +@pytest.mark.parametrize( + "version, accepted", + [("0.4", False), ("0.5", False), ("0.6", False), ("0.9.dev1", True)], +) +def test_validate_structural_is_version_aware(version, accepted): + """The axis rules are inert only at 0.9.dev1. + + The conformance driver depends on this: validating an RFC-3 dataset at a + pre-0.9.dev1 version would report every case as a failure. + """ + from ngff_zarr.structural_validation import ValidationError, validate_structural + + metadata = _metadata( + [CoordinateSystem("intrinsic", [_axis(n) for n in "abcdef"])] + ).to_version("0.4") + + if accepted: + validate_structural(metadata, version=version) + else: + with pytest.raises(ValidationError): + validate_structural(metadata, version=version) diff --git a/ts/src/types/supported_versions.ts b/ts/src/types/supported_versions.ts index 7e9f70d9..56d6d067 100644 --- a/ts/src/types/supported_versions.ts +++ b/ts/src/types/supported_versions.ts @@ -18,6 +18,12 @@ export enum NgffVersion { */ V06dev4 = "0.6.dev4", V06rc0 = "0.6rc0", + /** + * OME-Zarr 0.9 in development: v0.6 plus RFC-3 (any axis count, names, + * types and ordering). Unlike {@link V06dev4} this is the on-disk string. + * {@link LATEST} stays `0.6rc0`, so 0.9.dev1 is opt-in. + */ + V09dev1 = "0.9.dev1", LATEST = "0.6rc0", } @@ -42,6 +48,7 @@ export const SUPPORTED_VERSIONS: readonly NgffVersion[] = [ NgffVersion.V06, NgffVersion.V06dev4, NgffVersion.V06rc0, + NgffVersion.V09dev1, ] as const; /** @@ -60,3 +67,15 @@ export function isSupportedVersion(version: string): version is NgffVersion { export function isV06Version(version: string): boolean { return version.startsWith("0.6"); } + +/** + * Whether a version adopts the RFC-3 free-form axis model. + * + * Only `0.9.dev1` does: the bundled 0.4, 0.5 and 0.6 axes schemas all cap the + * axis count at 5, and require 2-3 `space` axes (0.6 excepted for an `array` + * coordinate system, see `takesArraySchemaBranch`). `undefined` applies the + * restrictions. + */ +export function isRfc3AxisModelAllowed(version?: string): boolean { + return version !== undefined && version === NgffVersion.V09dev1; +} From e418a39dbb0f278a8eaaadab1d7d71e845515fa6 Mon Sep 17 00:00:00 2001 From: Valentin Boussot Date: Tue, 25 Aug 2026 12:30:22 +0200 Subject: [PATCH 2/7] fix(py,ts): correct the axis model and gate the RFC-3 rules by version The v0.4 axis model was stricter than the spec on one count and looser on another: `type` was required where the schema makes it optional, and nothing refused two axes sharing a name, which the spec forbids by handling the axes as a set. `axis-names-unique` closes the second and both rule manifests carry it. The rules that RFC-3 lifts are now gated on the version rather than applied everywhere: the axis count, the canonical time-channel-space class order and the 2-or-3 spatial axis requirement hold below 0.9.dev1 and stand down at it. A v0.6 array coordinate system satisfies the spatial-axis rule. The axis unit types as the vocabulary or any string, which is what the schema declares. --- docs/validation/api.md | 20 +- docs/validation/parity.md | 79 +++-- docs/validation/rule-reference.md | 64 ++-- py/ngff_zarr/multiscales.py | 3 +- py/ngff_zarr/ngff_image.py | 4 +- py/ngff_zarr/structural_validation.py | 165 ++++++++-- py/ngff_zarr/to_ngff_image.py | 4 +- py/ngff_zarr/v04/zarr_metadata.py | 36 ++- py/ngff_zarr/v05/zarr_metadata.py | 13 +- py/ngff_zarr/v06/zarr_metadata.py | 13 +- py/test/test_rfc3_axes.py | 298 +++++++++++++++++++ py/test/test_structural_validation.py | 93 ++++++ py/test/test_structural_validation_parity.py | 187 ++++++++++-- py/test/test_unknown_axis_fields.py | 18 +- ts/src/types/ngff_image.ts | 6 +- ts/src/types/units.ts | 19 ++ ts/src/types/zarr_metadata.ts | 9 +- ts/src/utils/factory.ts | 4 +- ts/src/utils/from_zarr_attrs.ts | 53 +++- ts/src/utils/py_format.ts | 33 +- ts/src/utils/structural_validation.ts | 183 +++++++++--- ts/test/structural_validation_parity_test.ts | 202 ++++++++++--- ts/test/structural_validation_reader_test.ts | 118 ++++++++ ts/test/structural_validation_test.ts | 114 +++++++ ts/test/to_multiscales_itkwasm_test.ts | 12 +- 25 files changed, 1504 insertions(+), 246 deletions(-) create mode 100644 py/test/test_rfc3_axes.py diff --git a/docs/validation/api.md b/docs/validation/api.md index 88abe5b3..4733f151 100644 --- a/docs/validation/api.md +++ b/docs/validation/api.md @@ -41,11 +41,12 @@ from ngff_zarr import ( ) ``` -`validate_structural(metadata, options=None)` runs the image/multiscales rules -against either metadata model — the flat v0.4/v0.5 `Metadata` or the v0.6 -`coordinateSystems` one that `from_ome_zarr` and `to_multiscales` return (see -[[overview]]). When `options` is `None` it uses `ValidateOptions()`, i.e. -`ValidationLevel.STRICT`. A `ValidationError` carries `.rule` (a `SpecRule`), +`validate_structural(metadata, options=None, version=None)` runs the +image/multiscales rules. When `options` is `None` it uses `ValidateOptions()`, +i.e. `ValidationLevel.STRICT`. `version` is the OME-Zarr version the metadata +declares; the axis rules are inert for the versions that adopt the RFC-3 axis +model (see [[parity]]), so omitting it holds every store to the v0.4 axis +caps. A `ValidationError` carries `.rule` (a `SpecRule`), `.message` (str), and `.location` (`str | None`); `str(exc)` is `Spec rule [] violated: `. @@ -99,9 +100,12 @@ import { } from "@fideus-labs/ngff-zarr"; ``` -`validateStructural(metadata, options?)` runs the image/multiscales rules. When -`options` (or its `level`) is omitted, the level resolves to -`ValidationLevel.Strict`. A `ValidationError` carries a readonly `rule` +`validateStructural(metadata, options?, version?)` runs the image/multiscales +rules. When `options` (or its `level`) is omitted, the level resolves to +`ValidationLevel.Strict`. `version` is the OME-Zarr version the metadata +declares; the axis rules are inert for the versions that adopt the RFC-3 axis +model (see [[parity]]), so omitting it holds every store to the v0.4 axis +caps. A `ValidationError` carries a readonly `rule` (`SpecRule`) and an optional `location` (`string`); its `message` is `Spec rule [] violated: `. diff --git a/docs/validation/parity.md b/docs/validation/parity.md index 66677717..a7bb1f27 100644 --- a/docs/validation/parity.md +++ b/docs/validation/parity.md @@ -26,7 +26,7 @@ for usage, see [[api]]. ## The contract -Both ports must agree on four observable dimensions: +Both ports must agree on five observable dimensions: 1. **Rule identifiers** — the same `SpecRule` string values, in the same canonical declaration/iteration order. @@ -38,6 +38,10 @@ Both ports must agree on four observable dimensions: (`validate_structural` / `validateStructural`) evaluates rules in the same canonical order, so the same metadata yields the same first violation in both languages. +5. **The RFC-3 version set** — the four axis rules take a `version` and are + inert for the versions that adopt the RFC-3 free-form axis model. Both ports + must treat exactly the same version strings as RFC-3, or the same metadata + validates in one language and not the other. Because both test suites assert these facts against the **same literal identifier list**, adding, removing, renaming, or reordering a rule — or @@ -53,23 +57,34 @@ The canonical `SpecRule` set, in evaluation order: 1. `axis-count` 2. `axis-type` 3. `axis-order` -4. `scale-length-mismatch` -5. `global-coord-transform-after-per-level` -6. `dataset-order-highest-to-lowest` -7. `omero-channel-color-format` -8. `axis-orientation-anatomical-type` -9. `axis-orientation-on-non-space` -10. `axis-orientation-unique-axis` -11. `zarr-format` -12. `ome-namespace` -13. `plate-row-index-consistency` -14. `well-acquisition-missing` - -Entries 11–12 are the v0.5 namespacing rules; they fire only for v0.5 metadata +4. `axis-names-unique` +5. `scale-length-mismatch` +6. `global-coord-transform-after-per-level` +7. `dataset-order-highest-to-lowest` +8. `omero-channel-color-format` +9. `axis-orientation-anatomical-type` +10. `axis-orientation-on-non-space` +11. `axis-orientation-unique-axis` +12. `zarr-format` +13. `ome-namespace` +14. `plate-row-index-consistency` +15. `well-acquisition-missing` + +Entries 12–13 are the v0.5 namespacing rules; they fire only for v0.5 metadata and are inert for v0.4. Each suite pins this list as a `CANONICAL_SPEC_RULE_IDS` literal — byte-identical between the two languages so the tests are line-for-line comparable. +The versions that adopt the RFC-3 axis model are pinned the same way, as a +`CANONICAL_RFC3_VERSIONS` literal: + +1. `0.9.dev1` + +Rules 1–3 (and the spatial arm of 3) are inert at those versions and enforced +at every other. Rule 4, `axis-names-unique`, is never inert: RFC-3 *adds* it, +and ngff-zarr applies it at all versions as a strictness choice (see +[[rule-reference]]). + ## The parity tests | Language | Test file | @@ -93,17 +108,21 @@ Each suite independently locks: `strict`, and `strict` is the default. - **Fail-fast evaluation order** — driving the orchestrator with v0.4 metadata that violates the earliest rule plus every later rule, then repairing exactly - one rule per stage across ten stages, the rule raised at each stage builds a - sequence equal to a shared `EXPECTED_EVALUATION_ORDER`. This proves every rule - is evaluated strictly before all rules after it; a final, fully-repaired + one rule per stage across eleven stages, the rule raised at each stage builds + a sequence equal to a shared `EXPECTED_EVALUATION_ORDER`. This proves every + rule is evaluated strictly before all rules after it; a final, fully-repaired metadata is accepted with no violation. - -The two v0.5 namespacing rules (`zarr-format`, `ome-namespace`) run last in the -image orchestrator but are inert for the v0.4 metadata the order test exercises, -so they never appear in `EXPECTED_EVALUATION_ORDER`. The two HCS rules -(`plate-row-index-consistency`, `well-acquisition-missing`) are deliberately -absent from the image orchestrator's evaluation order; they are dispatched by -the separate plate/well orchestrators (see [[rule-reference]]). +- **RFC-3 version set** — the axis rules are inert at exactly the versions in + `CANONICAL_RFC3_VERSIONS` and enforced at every other supported version and + when no version is given, asserted through the public orchestrator rather + than through the internal predicate. + +Four of the fifteen rules never appear in `EXPECTED_EVALUATION_ORDER`, each for +its own reason. The two v0.5 namespacing rules (`zarr-format`, `ome-namespace`) +run last in the image orchestrator but are inert for the v0.4 metadata the order +test exercises. The two HCS rules (`plate-row-index-consistency`, +`well-acquisition-missing`) are deliberately absent; they are dispatched by the +separate plate/well orchestrators (see [[rule-reference]]). ## Sanctioned TypeScript-only adaptations @@ -115,12 +134,12 @@ departures in the TypeScript suite: Python an `UPPER_SNAKE` `str, Enum` (`SpecRule.AXIS_COUNT`, `ValidationLevel.STRICT`). The `.value` strings are identical, so the cross-language assertions hold. -- **Axis-name labels and the default check** — TypeScript's ordering fixtures - use valid `SupportedDims` members in place of the Python helper's free-form - axis names, and assert the `strict` default *observably* (because the - TypeScript options type is a bare interface with no constructor) rather than - through a constructable options object. Neither alters the observed rule set - or evaluation order. +- **The default check** — TypeScript asserts the `strict` default *observably*, + because its options type is a bare interface with no constructor, rather than + through a constructable options object. This alters neither the observed rule + set nor the evaluation order. The ordering fixtures themselves are identical + in both ports: `Axis.name` is `AxisName`, so TypeScript uses the same + free-form axis names as Python. Internal message-fidelity helpers in the TypeScript port (rendering whole-number scales with a trailing `.0`, Python-style quoted name lists, and `repr`-style diff --git a/docs/validation/rule-reference.md b/docs/validation/rule-reference.md index 85bc2749..2aea8ae1 100644 --- a/docs/validation/rule-reference.md +++ b/docs/validation/rule-reference.md @@ -36,20 +36,21 @@ the `SpecRule` enum declares them and the orchestrators evaluate them. | # | Identifier | Applies to | What it checks | Spec MUST | Example location | | -- | ---------- | ---------- | -------------- | --------- | ---------------- | -| 1 | `axis-count` | images/multiscales | The number of axes is within the closed range 2–5. | v0.4: between 2 and 5 axes, inclusive. | `multiscales[0].axes` | -| 2 | `axis-type` | images/multiscales | At most one `time` axis and at most one `channel` axis. | v0.4: ≤ 1 time axis and ≤ 1 channel axis. | `multiscales[0].axes` | -| 3 | `axis-order` | images/multiscales | Axes ordered time → channel → space; at most 3 space axes; the space-axis names are the length-N suffix of `(z, y, x)`. | v0.4: axes ordered time, then channel, then space, with spatial names a suffix of `(z, y, x)`. | `multiscales[0].axes[1]` | -| 4 | `scale-length-mismatch` | images/multiscales | Every `scale`/`translation` vector length equals the axis count, for both the global and per-dataset transforms. At v0.6 the multiscale-level transforms map between named coordinate systems, so only the per-dataset ones are measured. | v0.4: each scale/translation has exactly one entry per axis. | `multiscales[0].datasets[2].coordinateTransformations[0]` | -| 5 | `global-coord-transform-after-per-level` | images/multiscales | Exactly one `scale` per dataset, and a `translation` must follow — not precede — its `scale`. | v0.4: each dataset defines exactly one scale; a translation follows its scale. | `multiscales[0].datasets[1].coordinateTransformations` | -| 6 | `dataset-order-highest-to-lowest` | images/multiscales | Datasets ordered finest → coarsest; the spatial scale must not decrease as the level index rises. | v0.4: multiscale datasets ordered from highest to lowest resolution. | `multiscales[0].datasets[2]` | -| 7 | `omero-channel-color-format` | OMERO | Each OMERO channel `color` is exactly six hexadecimal digits (RGB). | v0.4: OMERO channel color is 6 hex digits. | `multiscales[0].omero.channels[0].color` | -| 8 | `axis-orientation-anatomical-type` | RFC 4 orientation | Every declared spatial-axis `orientation` has `type` `anatomical`. | RFC 4: an orientation's `type` is `anatomical`. | `multiscales[0].axes` | -| 9 | `axis-orientation-on-non-space` | RFC 4 orientation | An `orientation` is declared only on `space` axes, never on a non-spatial axis. | RFC 4: orientation applies to spatial axes only. | `multiscales[0].axes[0]` | -| 10 | `axis-orientation-unique-axis` | RFC 4 orientation | No two spatial axes declare orientations describing the same anatomical axis. | RFC 4: each spatial axis describes a distinct anatomical axis. | `multiscales[0].axes` | -| 11 | `zarr-format` | images/multiscales (v0.5) | A v0.5 entry implies a Zarr v3 store; a `zarr_format` value that leaked into the entry must be exactly `3`. Inert for v0.4. | v0.5: metadata is backed by a Zarr v3 store (`zarr_format == 3`). | `multiscales[0]` | -| 12 | `ome-namespace` | images/multiscales (v0.5) | A v0.5 entry must not retain a group-level `ome` or `multiscales` wrapper key — the `ome` namespace wraps the group attributes, not each entry. Inert for v0.4. | v0.5: multiscales live under the top-level `ome` namespace, with `version` hoisted to `ome.version`. | `multiscales[0]` | -| 13 | `plate-row-index-consistency` | HCS plate | Each well's `path` is `/`, naming declared row/column entries, with `rowIndex`/`columnIndex` equal to those entries' positions. | v0.4: well `rowIndex`/`columnIndex` match the named row/column positions in `plate.rows`/`plate.columns`. | `plate.wells[3]` | -| 14 | `well-acquisition-missing` | HCS well | When the plate declares more than one acquisition, every well image references one via `acquisition`. | v0.4: with multiple acquisitions, each well image references an acquisition. | `well.images[0].acquisition` | +| 1 | `axis-count` | images/multiscales | The number of axes is within the closed range 2–5. Inert for 0.9.dev1. | v0.4: between 2 and 5 axes, inclusive. | `multiscales[0].axes` | +| 2 | `axis-type` | images/multiscales | At most one `time` axis and at most one `channel` axis. Inert for 0.9.dev1. | v0.4: ≤ 1 time axis and ≤ 1 channel axis. | `multiscales[0].axes` | +| 3 | `axis-order` | images/multiscales | Axes ordered time → channel → space; 2 or 3 space axes; the space-axis names are the length-N suffix of `(z, y, x)`. Inert for 0.9.dev1; a v0.6 `array` coordinate system is exempt from the two-space-axis floor, but not from the three-axis cap or the suffix check. | v0.4: axes ordered time, then channel, then space, with 2 or 3 `space` axes whose names are a suffix of `(z, y, x)`. | `multiscales[0].axes[1]` | +| 4 | `axis-names-unique` | images/multiscales | No two axes share a `name`. Never inert. | RFC-3 rule 5: axis names MUST NOT be repeated within a dataset. No released schema states it, so below 0.9.dev1 this is a strictness choice, not a spec MUST of those versions. | `multiscales[0].axes[2]` | +| 5 | `scale-length-mismatch` | images/multiscales | Every `scale`/`translation` vector length equals the axis count, for both the global and per-dataset transforms. At v0.6 the multiscale-level transforms map between named coordinate systems, so only the per-dataset ones are measured. | v0.4: each scale/translation has exactly one entry per axis. | `multiscales[0].datasets[2].coordinateTransformations[0]` | +| 6 | `global-coord-transform-after-per-level` | images/multiscales | Exactly one `scale` per dataset, and a `translation` must follow — not precede — its `scale`. | v0.4: each dataset defines exactly one scale; a translation follows its scale. | `multiscales[0].datasets[1].coordinateTransformations` | +| 7 | `dataset-order-highest-to-lowest` | images/multiscales | Datasets ordered finest → coarsest; the spatial scale must not decrease as the level index rises. | v0.4: multiscale datasets ordered from highest to lowest resolution. | `multiscales[0].datasets[2]` | +| 8 | `omero-channel-color-format` | OMERO | Each OMERO channel `color` is exactly six hexadecimal digits (RGB). | v0.4: OMERO channel color is 6 hex digits. | `multiscales[0].omero.channels[0].color` | +| 9 | `axis-orientation-anatomical-type` | RFC 4 orientation | Every declared spatial-axis `orientation` has `type` `anatomical`. | RFC 4: an orientation's `type` is `anatomical`. | `multiscales[0].axes` | +| 10| `axis-orientation-on-non-space` | RFC 4 orientation | An `orientation` is declared only on `space` axes, never on a non-spatial axis. | RFC 4: orientation applies to spatial axes only. | `multiscales[0].axes[0]` | +| 11| `axis-orientation-unique-axis` | RFC 4 orientation | No two spatial axes declare orientations describing the same anatomical axis. | RFC 4: each spatial axis describes a distinct anatomical axis. | `multiscales[0].axes` | +| 12| `zarr-format` | images/multiscales (v0.5) | A v0.5 entry implies a Zarr v3 store; a `zarr_format` value that leaked into the entry must be exactly `3`. Inert for v0.4. | v0.5: metadata is backed by a Zarr v3 store (`zarr_format == 3`). | `multiscales[0]` | +| 13| `ome-namespace` | images/multiscales (v0.5) | A v0.5 entry must not retain a group-level `ome` or `multiscales` wrapper key — the `ome` namespace wraps the group attributes, not each entry. Inert for v0.4. | v0.5: multiscales live under the top-level `ome` namespace, with `version` hoisted to `ome.version`. | `multiscales[0]` | +| 14| `plate-row-index-consistency` | HCS plate | Each well's `path` is `/`, naming declared row/column entries, with `rowIndex`/`columnIndex` equal to those entries' positions. | v0.4: well `rowIndex`/`columnIndex` match the named row/column positions in `plate.rows`/`plate.columns`. | `plate.wells[3]` | +| 15| `well-acquisition-missing` | HCS well | When the plate declares more than one acquisition, every well image references one via `acquisition`. | v0.4: with multiple acquisitions, each well image references an acquisition. | `well.images[0].acquisition` | ## Evaluation order and orchestrators @@ -57,31 +58,42 @@ The rules are evaluated fail-fast: the first violation raises a `ValidationError` and later rules do not run. Three orchestrators dispatch the rules: -- **`validate_structural` / `validateStructural`** evaluates rules **1–12** (the +- **`validate_structural` / `validateStructural`** evaluates rules **1–13** (the image/multiscales rules, including the OMERO color check, the RFC 4 - orientation checks, and the two v0.5 namespacing rules). Rules 3 and 5 each + orientation checks, and the two v0.5 namespacing rules). Rules 3 and 6 each have two internal enforcement points that share a single identifier — axis class-ordering then spatial-name ordering for `axis-order`; per-dataset scale-count then transform-ordering for - `global-coord-transform-after-per-level`. The v0.5 namespacing rules (11 and - 12) run last and are inert for v0.4 input. The orientation rules - `axis-orientation-on-non-space` (9) and `axis-orientation-unique-axis` (10) - fire only for specific orientation shapes, so the linear fail-fast cascade for - a v0.4 metadata is a 10-step sequence ending at - `axis-orientation-anatomical-type` (identifiers 1–8). -- **`validate_plate` / `validatePlate`** evaluates rule **13** + `global-coord-transform-after-per-level`. The v0.5 namespacing rules (12 and + 13) run last and are inert for v0.4 input. The orientation rules + `axis-orientation-on-non-space` (10) and `axis-orientation-unique-axis` (11) + fire only for specific axis shapes, so the linear fail-fast cascade for a v0.4 + metadata is an 11-step sequence ending at + `axis-orientation-anatomical-type`. +- **`validate_plate` / `validatePlate`** evaluates rule **14** (`plate-row-index-consistency`). -- **`validate_well` / `validateWell`** evaluates rule **14** +- **`validate_well` / `validateWell`** evaluates rule **15** (`well-acquisition-missing`), in the context of its parent plate. -The two HCS rules (13 and 14) are deliberately absent from the image +The two HCS rules (14 and 15) are deliberately absent from the image orchestrator's order; they operate on separate metadata objects. The exact fail-fast sequence is locked by the parity tests described in [[parity]]. +A fourth dispatcher sits on the write path: `_gate_axis_model` / +`gateAxisModel` refuses to serialize an axis model the target version cannot +express. It is **not** the same pass as `validate_structural`. It runs rules +**1–4** only, and it applies them to *every* coordinate system the target +version serializes, where the structural orchestrator reads one flat axis list. +It sees only what will be written: a v0.4/v0.5 target writes a single flat +`axes` and drops the coordinate systems, so a system the downgrade discards is +not gated. Python reaches that set by gating after `Metadata.to_version`; +TypeScript by checking the target version in `axisViews`. + ## Rule categories at a glance - **Image / multiscales** — `axis-count`, `axis-type`, `axis-order`, - `scale-length-mismatch`, `global-coord-transform-after-per-level`, + `axis-names-unique`, `scale-length-mismatch`, + `global-coord-transform-after-per-level`, `dataset-order-highest-to-lowest`. - **OMERO** — `omero-channel-color-format`. - **RFC 4 orientation** — `axis-orientation-anatomical-type`, diff --git a/py/ngff_zarr/multiscales.py b/py/ngff_zarr/multiscales.py index c6d9a079..6cd78a98 100644 --- a/py/ngff_zarr/multiscales.py +++ b/py/ngff_zarr/multiscales.py @@ -12,12 +12,13 @@ from .v04.zarr_metadata import Metadata as Metadata_v04 from .v05.zarr_metadata import Metadata as Metadata_v05 from .v06.zarr_metadata import Metadata as Metadata_v06 +from .v09.zarr_metadata import Metadata as Metadata_v09 @dataclass class NgffMultiscales: images: list[NgffImage] - metadata: Metadata_v04 | Metadata_v05 | Metadata_v06 + metadata: Metadata_v04 | Metadata_v05 | Metadata_v06 | Metadata_v09 scale_factors: Sequence[dict[str, int] | int] | None = None method: Methods | None = None chunks: ( diff --git a/py/ngff_zarr/ngff_image.py b/py/ngff_zarr/ngff_image.py index ce5a2542..16bbf46b 100644 --- a/py/ngff_zarr/ngff_image.py +++ b/py/ngff_zarr/ngff_image.py @@ -6,7 +6,7 @@ from dask.array.core import Array as DaskArray from .rfc4 import AnatomicalOrientation -from .v04.zarr_metadata import Units +from .v04.zarr_metadata import AxisUnit ComputedCallback = Callable[[], None] @@ -18,7 +18,7 @@ class NgffImage: scale: dict[str, float] translation: dict[str, float] name: str = "image" - axes_units: Mapping[str, Units] | None = None + axes_units: Mapping[str, AxisUnit] | None = None axes_orientations: Mapping[str, AnatomicalOrientation] | None = None axes_types: Mapping[str, str] | None = None channel_names: list[str] | None = None diff --git a/py/ngff_zarr/structural_validation.py b/py/ngff_zarr/structural_validation.py index 70bc0c54..a15d1b3b 100644 --- a/py/ngff_zarr/structural_validation.py +++ b/py/ngff_zarr/structural_validation.py @@ -45,6 +45,9 @@ # axis-order # time before channel before space; space names a suffix of (z, y, x) # e.g. multiscales[0].axes[1] +# axis-names-unique +# no two axes share a name (RFC-3 rule 5) +# e.g. multiscales[0].axes[2] # scale-length-mismatch # every scale/translation length == axis count (global + per-dataset) # e.g. multiscales[0].datasets[2].coordinateTransformations[0] @@ -85,6 +88,8 @@ from enum import StrEnum from typing import TYPE_CHECKING, Any +from ._supported_versions import NgffVersion, is_v06_version + if TYPE_CHECKING: from .v04.zarr_metadata import Axis, Dataset, Metadata, Plate, Transform, Well @@ -101,6 +106,7 @@ class SpecRule(StrEnum): AXIS_COUNT = "axis-count" AXIS_TYPE = "axis-type" AXIS_ORDER = "axis-order" + AXIS_NAMES_UNIQUE = "axis-names-unique" SCALE_LENGTH_MISMATCH = "scale-length-mismatch" GLOBAL_COORD_TRANSFORM_AFTER_PER_LEVEL = "global-coord-transform-after-per-level" DATASET_ORDER_HIGHEST_TO_LOWEST = "dataset-order-highest-to-lowest" @@ -328,11 +334,45 @@ def _flat_model(metadata: Any) -> Any: # 3 -> ("z", "y", "x") (see ``validate_spatial_axis_order``). _SPATIAL_AXIS_NAMES = ("z", "y", "x") +#: The v0.6 ``axes`` schema is a ``oneOf``: either 2 or 3 ``space`` axes, or +#: two or more ``array`` axes. An RFC-5 array coordinate system takes the +#: second branch and declares no ``space`` axis at all (see +#: ``_takes_array_schema_branch``). +_MIN_ARRAY_AXES = 2 + + +def _takes_array_schema_branch(axes: list, version: object | None) -> bool: + """Whether ``axes`` satisfies the ``array`` arm of the v0.6 axes schema. + + Only v0.6 has that arm; the v0.4 and v0.5 schemas require 2 or 3 ``space`` + axes unconditionally, so the space-axis floor is not relaxed for them. + """ + if not is_v06_version(version): + return False + return sum(1 for ax in axes if ax.type == "array") >= _MIN_ARRAY_AXES -def validate_axis_count(metadata: Metadata) -> None: + +def is_rfc3_axis_model_allowed(version: object | None = None) -> bool: + """Whether ``version`` adopts the RFC-3 free-form axis model. + + Only OME-Zarr ``0.9.dev1`` does: the bundled 0.4, 0.5 and 0.6 ``axes`` + schemas all cap the axis count at 5, and require 2 or 3 ``space`` axes + (0.6 excepted for an ``array`` coordinate system, see + :func:`_takes_array_schema_branch`). ``None`` applies the restrictions. + + Compared by string equality: a dev release precedes its release, so + ``packaging.version.parse("0.9.dev1")`` sorts below ``0.9`` and a ``>=`` + test against ``"0.9"`` is ``False``. + """ + return version is not None and version == NgffVersion.V09dev1 + + +def validate_axis_count(metadata: Metadata, version: object | None = None) -> None: """Validate that the axis count is within the v0.4-permitted range. - OME-Zarr v0.4 requires between 2 and 5 axes, inclusive. + OME-Zarr v0.4, v0.5 and v0.6 require between 2 and 5 axes, inclusive. + Inert when ``version`` adopts RFC-3 (see + :func:`is_rfc3_axis_model_allowed`). Raises ------ @@ -340,19 +380,24 @@ def validate_axis_count(metadata: Metadata) -> None: With :attr:`SpecRule.AXIS_COUNT` when ``len(metadata.axes)`` lies outside ``2..=5``; location ``multiscales[0].axes``. """ + if is_rfc3_axis_model_allowed(version): + return count = len(metadata.axes) if not (2 <= count <= 5): raise ValidationError( SpecRule.AXIS_COUNT, - f"OME-Zarr v0.4 requires between 2 and 5 axes, inclusive; found {count}.", + f"OME-Zarr v0.4, v0.5 and v0.6 require between 2 and 5 axes," + f" inclusive; found {count}.", "multiscales[0].axes", ) -def validate_axis_type(metadata: Metadata) -> None: +def validate_axis_type(metadata: Metadata, version: object | None = None) -> None: """Validate axis-type multiplicity. At most one ``time`` axis and at most one ``channel`` axis may be present. + Inert when ``version`` adopts RFC-3 (see + :func:`is_rfc3_axis_model_allowed`). Raises ------ @@ -361,6 +406,8 @@ def validate_axis_type(metadata: Metadata) -> None: more than one ``channel`` axis is present; location ``multiscales[0].axes``. """ + if is_rfc3_axis_model_allowed(version): + return time_count = sum(1 for ax in metadata.axes if ax.type == "time") if time_count > 1: raise ValidationError( @@ -377,13 +424,14 @@ def validate_axis_type(metadata: Metadata) -> None: ) -def validate_axis_order(metadata: Metadata) -> None: +def validate_axis_order(metadata: Metadata, version: object | None = None) -> None: """Validate the class ordering of axes. Axes are ranked by type (``time`` < ``channel`` < ``space``) and must be listed in non-decreasing rank order: every ``time`` axis precedes every ``channel`` axis, which precedes every ``space`` axis. The first adjacent - pair that inverts this ranking is reported. + pair that inverts this ranking is reported. Inert when ``version`` adopts + RFC-3 (see :func:`is_rfc3_axis_model_allowed`). Raises ------ @@ -392,6 +440,8 @@ def validate_axis_order(metadata: Metadata) -> None: lower-ranked axis type follows a higher-ranked one; location ``multiscales[0].axes[i+1]``. """ + if is_rfc3_axis_model_allowed(version): + return axes = metadata.axes for i in range(len(axes) - 1): current = axes[i] @@ -410,27 +460,42 @@ def validate_axis_order(metadata: Metadata) -> None: ) -def validate_spatial_axis_order(metadata: Metadata) -> None: +def validate_spatial_axis_order( + metadata: Metadata, version: object | None = None +) -> None: """Validate the count and names of spatial axes. - The ``space`` axes, taken in order, must be the matching-length suffix of - ``(z, y, x)``: one spatial axis must be ``(x,)``, two must be - ``(y, x)``, and three must be ``(z, y, x)``. At most three ``space`` axes - are permitted. + The spec requires 2 or 3 ``space`` axes, and, taken in order, they must be + the matching-length suffix of ``(z, y, x)``: two spatial axes must be + ``(y, x)`` and three must be ``(z, y, x)``. Inert when ``version`` adopts + RFC-3 (see :func:`is_rfc3_axis_model_allowed`). + + At v0.6 the space-axis floor does not apply to an RFC-5 *array* coordinate + system, which the schema's ``oneOf`` admits with no ``space`` axis at all + (see :func:`_takes_array_schema_branch`). Raises ------ ValidationError - With :attr:`SpecRule.AXIS_ORDER` when there are more than three - ``space`` axes, or when their names are not the expected suffix of + With :attr:`SpecRule.AXIS_ORDER` when there are not 2 or 3 ``space`` + axes, or when their names are not the expected suffix of ``(z, y, x)``. """ + if is_rfc3_axis_model_allowed(version): + return space_indices = [i for i, ax in enumerate(metadata.axes) if ax.type == "space"] count = len(space_indices) if count > 3: raise ValidationError( SpecRule.AXIS_ORDER, - f"OME-Zarr v0.4 permits at most 3 'space' axes; found {count}.", + f"OME-Zarr v0.4, v0.5 and v0.6 permit at most 3 'space' axes;" + f" found {count}.", + "multiscales[0].axes", + ) + if count < 2 and not _takes_array_schema_branch(metadata.axes, version): + raise ValidationError( + SpecRule.AXIS_ORDER, + f"OME-Zarr v0.4, v0.5 and v0.6 require 2 or 3 'space' axes; found {count}.", "multiscales[0].axes", ) expected = _SPATIAL_AXIS_NAMES[len(_SPATIAL_AXIS_NAMES) - count :] @@ -445,6 +510,40 @@ def validate_spatial_axis_order(metadata: Metadata) -> None: ) +def validate_axis_names_unique( + metadata: Metadata, version: object | None = None +) -> None: + """Validate that axis names are unique within the dataset. + + This rule is **never** inert. It states RFC-3 rule 5, "axis names MUST NOT + be repeated within a dataset". No released schema carries it: v0.4 and v0.5 + say nothing, and v0.6 has only a non-normative ``description`` on + ``axis.name``. Below 0.9.dev1 it is therefore a strictness choice rather + than a spec MUST of those versions. + + RFC-3 rule 5 also says names SHOULD NOT differ only by case. That is a + SHOULD and is not enforced. + + ``version`` is accepted so callers can invoke every axis rule uniformly. + + Raises + ------ + ValidationError + With :attr:`SpecRule.AXIS_NAMES_UNIQUE` for the first axis whose + ``name`` duplicates an earlier one; location ``multiscales[0].axes[i]``. + """ + seen: set[str] = set() + for i, axis in enumerate(metadata.axes): + if axis.name in seen: + raise ValidationError( + SpecRule.AXIS_NAMES_UNIQUE, + f"Axis name '{axis.name}' is repeated; axis names must be unique " + f"within a dataset.", + f"multiscales[0].axes[{i}]", + ) + seen.add(axis.name) + + def validate_per_dataset_scale_count(metadata: Metadata) -> None: """Validate that each dataset defines exactly one ``scale`` transform. @@ -904,7 +1003,9 @@ def validate_well_acquisition(plate: Plate, well: Well) -> None: def validate_structural( - metadata: Metadata, options: ValidateOptions | None = None + metadata: Metadata, + options: ValidateOptions | None = None, + version: object | None = None, ) -> None: """Run the structural image/multiscales rules in canonical spec order. @@ -919,16 +1020,17 @@ def validate_structural( 2. :func:`validate_axis_type` 3. :func:`validate_axis_order` 4. :func:`validate_spatial_axis_order` - 5. :func:`validate_per_dataset_scale_count` - 6. :func:`validate_scale_length` - 7. :func:`validate_transform_order` - 8. :func:`validate_dataset_order` - 9. :func:`validate_omero_color_hex` - 10. :func:`validate_axis_orientation` - 11. :func:`validate_zarr_format_for_version` - 12. :func:`validate_ome_namespace` - - Rules 11 and 12 are the OME-Zarr v0.5 namespacing checks; they fire only for + 5. :func:`validate_axis_names_unique` + 6. :func:`validate_per_dataset_scale_count` + 7. :func:`validate_scale_length` + 8. :func:`validate_transform_order` + 9. :func:`validate_dataset_order` + 10. :func:`validate_omero_color_hex` + 11. :func:`validate_axis_orientation` + 12. :func:`validate_zarr_format_for_version` + 13. :func:`validate_ome_namespace` + + Rules 12 and 13 are the OME-Zarr v0.5 namespacing checks; they fire only for v0.5 metadata and are inert (a no-op) for v0.4. Parameters @@ -945,6 +1047,10 @@ def validate_structural( without running any structural rule -- shape/schema validation is the separate concern of :mod:`ngff_zarr.validate`, which checks the raw attribute dict. + version: + The OME-Zarr version the metadata declares. Rules 1-3 are inert for the + versions that adopt the RFC-3 axis model, so omitting it holds every + store to the v0.4 axis caps. Raises ------ @@ -966,10 +1072,11 @@ def validate_structural( if options.level == ValidationLevel.SCHEMA_ONLY: return metadata = _flat_model(metadata) - validate_axis_count(metadata) - validate_axis_type(metadata) - validate_axis_order(metadata) - validate_spatial_axis_order(metadata) + validate_axis_count(metadata, version) + validate_axis_type(metadata, version) + validate_axis_order(metadata, version) + validate_spatial_axis_order(metadata, version) + validate_axis_names_unique(metadata, version) validate_per_dataset_scale_count(metadata) validate_scale_length(metadata) validate_transform_order(metadata) diff --git a/py/ngff_zarr/to_ngff_image.py b/py/ngff_zarr/to_ngff_image.py index 76fd6171..d063292f 100644 --- a/py/ngff_zarr/to_ngff_image.py +++ b/py/ngff_zarr/to_ngff_image.py @@ -16,7 +16,7 @@ ) from .methods._support import _spatial_dims from .ngff_image import NgffImage -from .v04.zarr_metadata import SupportedDims, Units +from .v04.zarr_metadata import AxisUnit, SupportedDims # Group node types across the read backends: the compat-layer local reader # and the pure-Python mapping-store reader. @@ -87,7 +87,7 @@ def to_ngff_image( scale: Mapping[Hashable, float] | None = None, translation: Mapping[Hashable, float] | None = None, name: str = "image", - axes_units: Mapping[str, Units] | None = None, + axes_units: Mapping[str, AxisUnit] | None = None, channel_names: Sequence[str] | None = None, ) -> NgffImage: """ diff --git a/py/ngff_zarr/v04/zarr_metadata.py b/py/ngff_zarr/v04/zarr_metadata.py index 31602dc0..22393d38 100644 --- a/py/ngff_zarr/v04/zarr_metadata.py +++ b/py/ngff_zarr/v04/zarr_metadata.py @@ -16,6 +16,7 @@ from ..ngff_image import NgffImage from ..v05.zarr_metadata import Metadata as Metadata_v05 from ..v06.zarr_metadata import Metadata as Metadata_v06 + from ..v09.zarr_metadata import Metadata as Metadata_v09 logger = logging.getLogger(__name__) @@ -80,6 +81,13 @@ ] Units = Union[SpaceUnits, TimeUnits] +#: Axis unit as RFC-3 allows it: the controlled vocabulary, or any other +#: string. Every published axes schema declares ``"unit": {"type": "string"}`` +#: with no ``enum``, and an axis of an arbitrary type has no unit in the closed +#: space/time vocabulary. The union keeps the vocabulary so editors still +#: complete it. Mirrors the TypeScript port's ``AxisUnit``. +AxisUnit = Union[Units, str] + supported_dims = ["x", "y", "z", "c", "t"] space_units = [ @@ -163,17 +171,15 @@ def _filter_axis_dict(axis_dict: dict) -> dict: Logs a warning if unknown fields are encountered. Raises: - ValueError: If required fields 'name' or 'type' are missing from the axis dictionary. + ValueError: If the required field 'name' is missing from the axis + dictionary. """ - # Check for required fields before filtering + # `name` is the only required axis field: the v0.4 schema accepts an axis + # that declares no `type`, so a missing one is read as undefined. if "name" not in axis_dict: raise ValueError( f"Axis dictionary is missing required field 'name': {axis_dict}" ) - if "type" not in axis_dict: - raise ValueError( - f"Axis dictionary is missing required field 'type': {axis_dict}" - ) axis_fields = _get_axis_fields() unknown_fields = set(axis_dict.keys()) - axis_fields @@ -183,13 +189,15 @@ def _filter_axis_dict(axis_dict: dict) -> dict: f"Ignoring unknown fields {unknown_fields} in axis '{axis_name}'. " f"These fields are not part of the OME-NGFF v0.4 specification." ) - return {k: v for k, v in axis_dict.items() if k in axis_fields} + filtered = {k: v for k, v in axis_dict.items() if k in axis_fields} + filtered.setdefault("type", None) + return filtered @dataclass class Axis: name: SupportedDims - type: AxesType + type: AxesType | None unit: Units | None = None orientation: AnatomicalOrientation | None = None @@ -343,7 +351,7 @@ class Metadata: def to_version( self, version: Union[str, NgffVersion] - ) -> Union["Metadata", "Metadata_v05", "Metadata_v06"]: + ) -> Union["Metadata", "Metadata_v05", "Metadata_v06", "Metadata_v09"]: if isinstance(version, str): # raise error for invalid version string version = NgffVersion(version) @@ -354,17 +362,25 @@ def to_version( return self._to_v05() if version == NgffVersion.V06: return self._to_v05()._to_v06() + if version == NgffVersion.V09dev1: + from ..v09.zarr_metadata import Metadata as Metadata_v09 + + return Metadata_v09.from_version(self) raise ValueError(f"Unsupported version conversion: 0.4 -> {version}") @classmethod def from_version( - cls, metadata: Union["Metadata", "Metadata_v05", "Metadata_v06"] + cls, + metadata: Union["Metadata", "Metadata_v05", "Metadata_v06", "Metadata_v09"], ) -> "Metadata": from ..v05.zarr_metadata import Metadata as Metadata_v05 from ..v06.zarr_metadata import Metadata as Metadata_v06 + from ..v09.zarr_metadata import Metadata as Metadata_v09 if isinstance(metadata, Metadata_v05): return cls._from_v05(metadata) + if isinstance(metadata, Metadata_v09): + return cls._from_v05(Metadata_v05._from_v06(metadata._to_v06())) if isinstance(metadata, Metadata_v06): return cls._from_v05(Metadata_v05._from_v06(metadata)) raise ValueError(f"Unsupported metadata type: {type(metadata)}") diff --git a/py/ngff_zarr/v05/zarr_metadata.py b/py/ngff_zarr/v05/zarr_metadata.py index f0535fb8..abdd94bd 100644 --- a/py/ngff_zarr/v05/zarr_metadata.py +++ b/py/ngff_zarr/v05/zarr_metadata.py @@ -10,6 +10,7 @@ if TYPE_CHECKING: from ..v04.zarr_metadata import Metadata as Metadata_v04 from ..v06.zarr_metadata import Metadata as Metadata_v06 + from ..v09.zarr_metadata import Metadata as Metadata_v09 @dataclass @@ -29,7 +30,7 @@ class Metadata: def to_version( self, version: str | NgffVersion - ) -> Union["Metadata", "Metadata_v04", "Metadata_v06"]: + ) -> Union["Metadata", "Metadata_v04", "Metadata_v06", "Metadata_v09"]: """Convert metadata to specified NGFF version.""" if isinstance(version, str): version = NgffVersion(version) @@ -40,18 +41,26 @@ def to_version( return self if version == NgffVersion.V06: return self._to_v06() + if version == NgffVersion.V09dev1: + from ..v09.zarr_metadata import Metadata as Metadata_v09 + + return Metadata_v09.from_version(self) raise ValueError(f"Unsupported version conversion: 0.5 -> {version}") @classmethod def from_version( - cls, metadata: Union["Metadata", "Metadata_v04", "Metadata_v06"] + cls, + metadata: Union["Metadata", "Metadata_v04", "Metadata_v06", "Metadata_v09"], ) -> "Metadata": """Convert metadata from specified NGFF version.""" from ..v04.zarr_metadata import Metadata as Metadata_v04 from ..v06.zarr_metadata import Metadata as Metadata_v06 + from ..v09.zarr_metadata import Metadata as Metadata_v09 if isinstance(metadata, Metadata_v04): return cls._from_v04(metadata) + if isinstance(metadata, Metadata_v09): + return cls._from_v06(metadata._to_v06()) if isinstance(metadata, Metadata_v06): return cls._from_v06(metadata) raise ValueError(f"Unsupported metadata type: {type(metadata)}") diff --git a/py/ngff_zarr/v06/zarr_metadata.py b/py/ngff_zarr/v06/zarr_metadata.py index 7a767622..e405b210 100644 --- a/py/ngff_zarr/v06/zarr_metadata.py +++ b/py/ngff_zarr/v06/zarr_metadata.py @@ -27,6 +27,7 @@ from ..ngff_image import NgffImage from ..v04.zarr_metadata import Metadata as Metadata_v04 from ..v05.zarr_metadata import Metadata as Metadata_v05 + from ..v09.zarr_metadata import Metadata as Metadata_v09 # OME-Zarr v0.6 (RFC-5) extends the axis types with the discrete vector-field @@ -514,7 +515,7 @@ def axes(self) -> list[Axis]: def to_version( self, version: Union[str, NgffVersion] - ) -> Union["Metadata", "Metadata_v05", "Metadata_v04"]: + ) -> Union["Metadata", "Metadata_v05", "Metadata_v04", "Metadata_v09"]: if isinstance(version, str): # raise error for invalid version string version = NgffVersion(version) @@ -525,15 +526,23 @@ def to_version( return self._to_v05() if version == NgffVersion.V06: return self + if version == NgffVersion.V09dev1: + from ..v09.zarr_metadata import Metadata as Metadata_v09 + + return Metadata_v09.from_version(self) raise ValueError(f"Unsupported version conversion: 0.6 -> {version}") @classmethod def from_version( - cls, metadata: Union["Metadata", "Metadata_v05", "Metadata_v04"] + cls, + metadata: Union["Metadata", "Metadata_v05", "Metadata_v04", "Metadata_v09"], ) -> "Metadata": from ..v04.zarr_metadata import Metadata as Metadata_v04 from ..v05.zarr_metadata import Metadata as Metadata_v05 + from ..v09.zarr_metadata import Metadata as Metadata_v09 + if isinstance(metadata, Metadata_v09): + return metadata._to_v06() if isinstance(metadata, Metadata_v05): return cls._from_v05(metadata) if isinstance(metadata, Metadata_v04): diff --git a/py/test/test_rfc3_axes.py b/py/test/test_rfc3_axes.py new file mode 100644 index 00000000..087dcb78 --- /dev/null +++ b/py/test/test_rfc3_axes.py @@ -0,0 +1,298 @@ +# SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC +# SPDX-License-Identifier: MIT +"""RFC-3 ("More Dimensions for Thee") end-to-end axis handling. + +RFC-3 removes the historical OME-Zarr caps on the number, names, types, and +ordering of axes. These tests exercise the read path and a read/write round-trip +against local, in-memory synthetic fixtures mirroring the official sample +datasets (astronaut_xcy, ecg_1d, ramp_6d, FLIM, EBSD) -- no network access. The +focus is that reading keeps the exact axis order, names and types, and that +writing the result back at 0.9.dev1 -- which runs the write gate -- preserves +all three. +""" + +import numpy as np +import pytest +import zarr +from ngff_zarr import from_ome_zarr, to_ome_zarr +from packaging import version as packaging_version + +#: Writing OME-Zarr 0.5 and later needs a Zarr v3 hierarchy. +needs_zarr_v3 = pytest.mark.skipif( + packaging_version.parse(zarr.__version__) < packaging_version.parse("3.0.0b2"), + reason="zarr version >= 3.0.0b2 required for OME-Zarr version >= 0.5", +) + + +def _open_group(root): + """Open a writable v2 group at ``root`` across zarr-python 2 and 3. + + v0.4 OME-Zarr is a Zarr v2 hierarchy, so force ``zarr_format=2`` under + zarr-python 3 (which defaults to v3). + """ + if hasattr(zarr.storage, "LocalStore"): # zarr-python 3 + store = zarr.storage.LocalStore(root) + return zarr.open_group(store, mode="w", zarr_format=2) + store = zarr.DirectoryStore(root) # zarr-python 2 + return zarr.open_group(store, mode="w") + + +def _ramp(shape): + """Deterministic uint8 payload for a shape (values wrap modulo 256).""" + return np.arange(int(np.prod(shape)), dtype="uint8").reshape(shape) + + +def _axis_pairs(multiscales): + """The ``(name, type)`` of every axis, read off the metadata as parsed. + + Axis types are declared per axis and are not recoverable from + :attr:`NgffImage.dims`, so they need their own assertion: a regression that + coerced or dropped a custom type would leave ``dims`` intact. + """ + axes = multiscales.metadata.coordinateSystems[0].axes + return [(axis.name, axis.type) for axis in axes] + + +def _write_v04(root, axes, shape): + """Write a minimal single-level v0.4 OME-Zarr with the given axes/shape.""" + group = _open_group(root) + data = _ramp(shape) + if hasattr(group, "create_array"): # zarr-python 3 + array = group.create_array("0", shape=shape, dtype="uint8", chunks=shape) + else: # zarr-python 2 + array = group.create_dataset("0", shape=shape, dtype="uint8") + array[...] = data + group.attrs["multiscales"] = [ + { + "axes": axes, + "datasets": [ + { + "path": "0", + "coordinateTransformations": [ + {"type": "scale", "scale": [1.0] * len(axes)} + ], + } + ], + "version": "0.4", + } + ] + return str(root) + + +# (label, axes, shape): one entry per official RFC-3 sample-dataset shape. +_RFC3_SHAPES = [ + # ecg_1d: a single time axis (breaks the historical 2-5 count floor). + ("ecg_1d", [{"name": "t", "type": "time"}], (16,)), + # ramp_6d: six axes (exceeds the historical max of 5). + ("ramp_6d", [{"name": n, "type": "space"} for n in "abcdef"], (2,) * 6), + # astronaut_xcy: non-TCZYX order (x, c, y). + ( + "astronaut_xcy", + [ + {"name": "x", "type": "space"}, + {"name": "c", "type": "channel"}, + {"name": "y", "type": "space"}, + ], + (4, 3, 5), + ), + # FLIM-like: multiple axes of the same type (two channel-family axes). + ( + "flim_multi", + [ + {"name": "c", "type": "channel"}, + {"name": "tau", "type": "channel"}, + {"name": "y", "type": "space"}, + {"name": "x", "type": "space"}, + ], + (2, 3, 4, 5), + ), + # EBSD-like: arbitrary names and custom (non-standard) axis types. The + # official EBSD sample uses four type:space axes; this variant additionally + # exercises a custom type string, which RFC-3 permits. + ( + "ebsd_custom", + [ + {"name": "sy", "type": "space"}, + {"name": "sx", "type": "space"}, + {"name": "dy", "type": "diffraction"}, + {"name": "dx", "type": "diffraction"}, + ], + (2, 2, 4, 5), + ), +] + + +@pytest.mark.parametrize("label, axes, shape", _RFC3_SHAPES, ids=lambda v: v) +def test_rfc3_read_preserves_axis_order(tmp_path, label, axes, shape): + """Reading an RFC-3 dataset keeps the exact declared axis order/names.""" + root = _write_v04(tmp_path / f"{label}.ome.zarr", axes, shape) + multiscales = from_ome_zarr(root, validate=False) + + image = multiscales.images[0] + dims = tuple(image.dims) + assert dims == tuple(ax["name"] for ax in axes) + # The array is read at full dimensionality (not squeezed or transposed) and + # its payload is intact. + assert image.data.ndim == len(axes) + assert image.data.shape == shape + assert np.array_equal(np.asarray(image.data), _ramp(shape)) + # Types too, including the custom ones RFC-3 permits (ebsd_custom). + assert _axis_pairs(multiscales) == [(ax["name"], ax["type"]) for ax in axes] + + +@needs_zarr_v3 +@pytest.mark.parametrize("label, axes, shape", _RFC3_SHAPES, ids=lambda v: v) +def test_rfc3_round_trip_preserves_axis_order(tmp_path, label, axes, shape): + """A read then write then read keeps the axis order and data.""" + src = _write_v04(tmp_path / f"{label}-src.ome.zarr", axes, shape) + multiscales = from_ome_zarr(src, validate=False) + dims_in = tuple(multiscales.images[0].dims) + pairs_in = _axis_pairs(multiscales) + + # 0.9.dev1 is the only version that can express an RFC-3 axis model; writing + # these shapes at 0.4/0.5/0.6 is refused by the write gate (asserted below). + out = str(tmp_path / f"{label}-out.ome.zarr") + to_ome_zarr(out, multiscales, version="0.9.dev1") + multiscales_out = from_ome_zarr(out, validate=False) + image_out = multiscales_out.images[0] + + assert tuple(image_out.dims) == dims_in + assert _axis_pairs(multiscales_out) == pairs_in + assert np.array_equal(np.asarray(image_out.data), _ramp(shape)) + + +@pytest.mark.parametrize("version", ["0.4", "0.5", "0.6"]) +@pytest.mark.parametrize("label, axes, shape", _RFC3_SHAPES, ids=lambda v: v) +def test_rfc3_write_is_refused_below_v09dev1(tmp_path, label, axes, shape, version): + """Serializing an RFC-3 axis model to a pre-0.9.dev1 version is refused.""" + src = _write_v04(tmp_path / f"{label}-{version}-src.ome.zarr", axes, shape) + multiscales = from_ome_zarr(src, validate=False) + + out = str(tmp_path / f"{label}-{version}-out.ome.zarr") + with pytest.raises(ValueError, match="Cannot write OME-Zarr"): + to_ome_zarr(out, multiscales, version=version) + + +@needs_zarr_v3 +@pytest.mark.parametrize("version", ["0.4", "0.5", "0.6"]) +def test_channel_last_write_is_refused_below_v09dev1(tmp_path, version): + """Axis order is a spec MUST, so the writer refuses it, it does not warn. + + ``(z, y, x, c)`` satisfies every other axis rule: 4 axes, one channel, and + the space names are the ``(z, y, x)`` suffix. Only the class ordering is + wrong, so this reaches ``validate_axis_order`` and nothing else. + """ + axes = [{"name": n, "type": "space"} for n in "zyx"] + axes.append({"name": "c", "type": "channel"}) + src = _write_v04(tmp_path / f"cl-{version}-src.ome.zarr", axes, (2, 3, 4, 2)) + multiscales = from_ome_zarr(src, validate=False) + + out = str(tmp_path / f"cl-{version}-out.ome.zarr") + with pytest.raises(ValueError, match="axis-order"): + to_ome_zarr(out, multiscales, version=version) + + # RFC-3 lifts the ordering rule, so the same model writes at 0.9.dev1. + to_ome_zarr( + str(tmp_path / f"cl-{version}-ok.ome.zarr"), multiscales, version="0.9.dev1" + ) + + +@needs_zarr_v3 +@pytest.mark.parametrize("version", ["0.4", "0.5", "0.6", "0.9.dev1"]) +def test_conventional_axes_write_at_every_version(tmp_path, version): + """A conventional (z, y, x) image still writes everywhere, 0.9.dev1 included.""" + axes = [{"name": n, "type": "space"} for n in "zyx"] + src = _write_v04(tmp_path / f"zyx-{version}-src.ome.zarr", axes, (2, 3, 4)) + multiscales = from_ome_zarr(src, validate=False) + + out = str(tmp_path / f"zyx-{version}-out.ome.zarr") + to_ome_zarr(out, multiscales, version=version) + image_out = from_ome_zarr(out, validate=False).images[0] + assert tuple(image_out.dims) == ("z", "y", "x") + assert np.array_equal(np.asarray(image_out.data), _ramp((2, 3, 4))) + + +# The gate's message bytes are part of the cross-language contract: these +# literals are pinned identically in ``ts/test/write_gate_test.ts``. +CANONICAL_GATE_MESSAGE_REPEATED_NAME = ( + "Cannot write OME-Zarr version=\"0.9.dev1\": Axis name 'z' is repeated; " + "axis names must be unique within a dataset. Axes at multiscales[0].axes: " + "['z'(type='space'), 'z'(type='space'), 'x'(type='space')]." +) + +CANONICAL_GATE_MESSAGE_AXIS_COUNT = ( + 'Cannot write OME-Zarr version="0.4": this axis model violates that ' + "version's [axis-count] rule. OME-Zarr v0.4, v0.5 and v0.6 require between " + "2 and 5 axes, inclusive; found 6. Axes at multiscales[0].axes: " + "['a'(type='space'), 'b'(type='space'), 'c'(type='space'), " + "'d'(type='space'), 'e'(type='space'), 'f'(type='space')]. Pass " + 'version="0.9.dev1" to write it: 0.9.dev1 is the only OME-Zarr version ' + "that adopts RFC-3 (arbitrary axis count, names, types and ordering)." +) + + +def _flat_metadata(names): + """Single-level v0.4 metadata over ``names``, all ``space`` axes.""" + from ngff_zarr.v04.zarr_metadata import Axis, Dataset, Metadata, Scale + + return Metadata( + axes=[Axis(name=name, type="space") for name in names], + datasets=[ + Dataset( + path="0", + coordinateTransformations=[Scale([1.0] * len(names))], + ) + ], + coordinateTransformations=None, + ) + + +def test_gate_message_bytes_match_the_typescript_port(): + """The refusal text is part of the contract, not an implementation detail.""" + from ngff_zarr.to_ngff_zarr import _gate_axis_model + + with pytest.raises(ValueError) as exc_info: + _gate_axis_model(_flat_metadata(["z", "z", "x"]), "0.9.dev1") + assert str(exc_info.value) == CANONICAL_GATE_MESSAGE_REPEATED_NAME + + with pytest.raises(ValueError) as exc_info: + _gate_axis_model(_flat_metadata(list("abcdef")), "0.4") + assert str(exc_info.value) == CANONICAL_GATE_MESSAGE_AXIS_COUNT + + +@needs_zarr_v3 +@pytest.mark.parametrize("version", ["0.4", "0.5"]) +def test_gate_skips_coordinate_systems_the_target_version_drops(tmp_path, version): + """A coordinate system the downgrade discards must not block the write. + + ``_gate_axis_model`` runs after ``Metadata.to_version``, so a 0.4/0.5 target + sees only the surviving system. The TypeScript port reaches the same set by + checking the target version in ``axisViews``. + """ + from ngff_zarr.v06.zarr_metadata import Axis as AxisV06 + from ngff_zarr.v06.zarr_metadata import CoordinateSystem + + src = _write_v04( + tmp_path / "src.ome.zarr", + [{"name": n, "type": "space"} for n in ("z", "y", "x")], + (2, 3, 4), + ) + multiscales = from_ome_zarr(src, validate=False) + multiscales.metadata.coordinateSystems.append( + CoordinateSystem( + name="extra", + axes=[AxisV06(name=n, type="space") for n in "abcdef"], + ) + ) + + out = str(tmp_path / f"out-{version}.ome.zarr") + to_ome_zarr(out, multiscales, version=version) + assert tuple(from_ome_zarr(out, validate=False).images[0].dims) == ( + "z", + "y", + "x", + ) + + # 0.6 writes both systems, so there the six-axis one is refused. + with pytest.raises(ValueError, match="coordinateSystems\\[1\\].axes"): + to_ome_zarr(str(tmp_path / "out-0.6.ome.zarr"), multiscales, version="0.6") diff --git a/py/test/test_structural_validation.py b/py/test/test_structural_validation.py index a4011082..41c35a9a 100644 --- a/py/test/test_structural_validation.py +++ b/py/test/test_structural_validation.py @@ -20,6 +20,7 @@ ValidationError, ValidationLevel, validate_axis_count, + validate_axis_names_unique, validate_axis_order, validate_axis_type, validate_dataset_order, @@ -153,6 +154,7 @@ def test_spatial_axis_order_valid(valid_metadata): [ ("suffix-mismatch", "multiscales[0].axes[1]"), ("too-many-spatial", "multiscales[0].axes"), + ("too-few-spatial", "multiscales[0].axes"), ], ) def test_spatial_axis_order_invalid(valid_metadata, case, location): @@ -163,6 +165,12 @@ def test_spatial_axis_order_invalid(valid_metadata, case, location): Axis(name="x", type="space"), Axis(name="y", type="space"), ] + elif case == "too-few-spatial": + # The bundled axes schemas state minContains: 2 for 'space'. + valid_metadata.axes = [ + Axis(name="c", type="channel"), + Axis(name="x", type="space"), + ] else: # Four space axes exceed the v0.4 maximum of three. valid_metadata.axes = [ @@ -177,6 +185,91 @@ def test_spatial_axis_order_invalid(valid_metadata, case, location): assert exc_info.value.location == location +class _BackportStyleVersion(str): + """A ``str`` subclass whose ``str()`` is not its value. + + This is how the ``str, Enum`` backport renders below Python 3.11, where the + stdlib ``StrEnum`` is unavailable. Reproducing it here keeps the assertion + below meaningful on every interpreter rather than only on 3.10. + """ + + def __str__(self) -> str: + return "NgffVersion.V06dev4" + + +def test_is_v06_version_accepts_enum_members_and_strings(): + """The v0.6 predicate must not depend on the Python version. + + ``NgffVersion`` is a stdlib ``StrEnum`` from 3.11 and a ``str, Enum`` + backport below it. Only the former renders as its value under ``str()``, + so a ``str()``-based check silently returns ``False`` for enum members on + 3.10 -- the version the zarr-python 2 CI matrix runs. + """ + from ngff_zarr._supported_versions import NgffVersion, is_v06_version + + assert is_v06_version(NgffVersion.V06) + assert is_v06_version(NgffVersion.V06dev4) + assert is_v06_version("0.6") + assert is_v06_version("0.6.dev4") + assert not is_v06_version(NgffVersion.V05) + assert not is_v06_version("0.4") + assert not is_v06_version(NgffVersion.V09dev1) + assert not is_v06_version(None) + assert not is_v06_version(6) + # The value is what counts, not what ``str()`` renders. + assert is_v06_version(_BackportStyleVersion("0.6.dev4")) + assert not is_v06_version(_BackportStyleVersion("0.4")) + + +def test_spatial_axis_order_accepts_v06_array_coordinate_system(valid_metadata): + """An RFC-5 array coordinate system declares no ``space`` axis, and may. + + The v0.6 ``axes`` schema is a ``oneOf``: 2 or 3 ``space`` axes, *or* two or + more ``array`` axes. The space-axis floor must not fire on the second arm, + or a store the bundled schema accepts becomes unwritable at v0.6. + """ + valid_metadata.axes = [Axis(name=f"i{i}", type="array") for i in range(3)] + + validate_spatial_axis_order(valid_metadata, "0.6") + validate_spatial_axis_order(valid_metadata, "0.6.dev4") + + +def test_spatial_axis_order_array_branch_is_v06_only(valid_metadata): + """v0.4 and v0.5 have no ``array`` arm, so the floor applies unconditionally.""" + valid_metadata.axes = [Axis(name=f"i{i}", type="array") for i in range(3)] + + for version in ("0.4", "0.5", None): + with pytest.raises(ValidationError) as exc_info: + validate_spatial_axis_order(valid_metadata, version) + assert exc_info.value.rule == SpecRule.AXIS_ORDER + + +def test_spatial_axis_order_needs_two_array_axes(valid_metadata): + """A single ``array`` axis does not reach the schema's ``minContains: 2``.""" + valid_metadata.axes = [ + Axis(name="i0", type="array"), + Axis(name="c", type="channel"), + ] + with pytest.raises(ValidationError): + validate_spatial_axis_order(valid_metadata, "0.6") + + +def test_axis_names_unique_valid(valid_metadata): + validate_axis_names_unique(valid_metadata) + + +def test_axis_names_unique_invalid(valid_metadata): + valid_metadata.axes = [ + Axis(name="c", type="channel"), + Axis(name="y", type="space"), + Axis(name="y", type="space"), + ] + with pytest.raises(ValidationError) as exc_info: + validate_axis_names_unique(valid_metadata) + assert exc_info.value.rule == SpecRule.AXIS_NAMES_UNIQUE + assert exc_info.value.location == "multiscales[0].axes[2]" + + def test_per_dataset_scale_count_valid(valid_metadata): validate_per_dataset_scale_count(valid_metadata) diff --git a/py/test/test_structural_validation_parity.py b/py/test/test_structural_validation_parity.py index 6047dbd9..6e6b1841 100644 --- a/py/test/test_structural_validation_parity.py +++ b/py/test/test_structural_validation_parity.py @@ -16,13 +16,16 @@ """ import re +from pathlib import Path import pytest from ngff_zarr import ( + SUPPORTED_VERSIONS, SpecRule, ValidateOptions, ValidationError, ValidationLevel, + structural_validation, validate_structural, ) from ngff_zarr.v04.zarr_metadata import ( @@ -38,8 +41,8 @@ # The locked rule manifest: every active OME-Zarr structural rule, in canonical # declaration order. This identical literal list appears in the Deno mirror test -# so the two are directly comparable. The first eleven entries are the -# image/multiscales rules dispatched by validate_structural -- the first nine +# so the two are directly comparable. The first thirteen entries are the +# image/multiscales rules dispatched by validate_structural -- the first eleven # are the v0.4 rules and the next two (zarr-format, ome-namespace) are the v0.5 # namespacing rules, inert for v0.4; the final two are the HCS plate/well rules # dispatched by validate_plate / validate_well. @@ -47,6 +50,7 @@ "axis-count", "axis-type", "axis-order", + "axis-names-unique", "scale-length-mismatch", "global-coord-transform-after-per-level", "dataset-order-highest-to-lowest", @@ -60,6 +64,26 @@ "well-acquisition-missing", ] + +# The locked RFC-3 version manifest: the versions whose axis model is +# unrestricted, so that validate_axis_count / validate_axis_type / +# validate_axis_order / validate_spatial_axis_order are inert for them. This +# identical literal list appears in the Deno mirror test. axis-names-unique is +# deliberately absent: RFC-3 adds that rule rather than lifting it, so it is +# never inert (see docs/validation/rule-reference.md). +CANONICAL_RFC3_VERSIONS = [ + "0.9.dev1", +] + +# Every other supported version, plus the no-version default, must enforce the +# axis rules. Read off SUPPORTED_VERSIONS so a newly supported version has to +# be classified here rather than silently defaulting to "restricted". +NON_RFC3_VERSIONS = [ + version.value + for version in SUPPORTED_VERSIONS + if version.value not in CANONICAL_RFC3_VERSIONS +] + [None] + # The canonical fail-fast evaluation order of the image/multiscales # orchestrator (validate_structural). Each entry is the SpecRule the # orchestrator must raise when that rule -- and every rule after it -- is @@ -67,7 +91,7 @@ # share each: validate_axis_order and validate_spatial_axis_order both surface # AXIS_ORDER (positions 3-4), and validate_per_dataset_scale_count and # validate_transform_order both surface GLOBAL_COORD_TRANSFORM_AFTER_PER_LEVEL -# (positions 5 and 7). The two HCS rules are absent: they are not part of the +# (positions 6 and 8). The two HCS rules are absent: they are not part of the # image/multiscales orchestrator. The two v0.5 namespacing rules (zarr-format, # ome-namespace) run last in the orchestrator but are inert for the v0.4 # metadata exercised here, so they never appear in the observed order. @@ -76,6 +100,7 @@ SpecRule.AXIS_TYPE, SpecRule.AXIS_ORDER, # validate_axis_order (class ordering) SpecRule.AXIS_ORDER, # validate_spatial_axis_order (spatial suffix) + SpecRule.AXIS_NAMES_UNIQUE, SpecRule.GLOBAL_COORD_TRANSFORM_AFTER_PER_LEVEL, # per-dataset scale count SpecRule.SCALE_LENGTH_MISMATCH, SpecRule.GLOBAL_COORD_TRANSFORM_AFTER_PER_LEVEL, # transform order @@ -131,6 +156,19 @@ def _valid_axes_with_inconsistent_orientation() -> list[Axis]: ] +def _axes_with_repeated_name() -> list[Axis]: + """Return valid ``[c, z, y, x]`` axes with the channel axis renamed ``z``. + + Every rule before :attr:`SpecRule.AXIS_NAMES_UNIQUE` still passes: the + class order is channel-then-space, the spatial names remain the ``(z, y, + x)`` suffix, and the count is 4. Only the repeated ``z`` is left for the + orchestrator to catch, which pins the rule's position in the cascade. + """ + axes = _valid_axes_with_inconsistent_orientation() + axes[0].name = "z" + return axes + + def _first_violated_rule(metadata: Metadata) -> SpecRule: """Run the orchestrator and return the single :class:`SpecRule` it raises.""" with pytest.raises(ValidationError) as exc_info: @@ -187,8 +225,9 @@ def test_orchestrator_evaluation_order(): bad_omero = _omero("xyz") # OMERO violation (rule 9), present until repaired # Stage 1 -> AXIS_COUNT. Six axes simultaneously trip axis-type (two - # channels), axis-order (a space precedes a channel), and spatial-order - # (names are not the (z, y, x) suffix); axis-count is evaluated first. + # channels), axis-order (a space precedes a channel), spatial-order (names + # are not the (z, y, x) suffix) and axis-names-unique (a repeated "z"); + # axis-count is evaluated first. metadata = Metadata( axes=[ Axis(name="x", type="space"), @@ -196,7 +235,7 @@ def test_orchestrator_evaluation_order(): Axis(name="c2", type="channel"), Axis(name="t", type="time"), Axis(name="z", type="space"), - Axis(name="w", type="space"), + Axis(name="z", type="space"), ], datasets=[ Dataset( @@ -216,44 +255,51 @@ def test_orchestrator_evaluation_order(): metadata.omero = bad_omero observed.append(_first_violated_rule(metadata)) - # Stage 2 -> AXIS_TYPE. Count fixed (5 axes); two channels remain, and a - # space still precedes a channel and the spatial names are still wrong. + # Stage 2 -> AXIS_TYPE. Count fixed (5 axes); two channels remain, a space + # still precedes a channel, the spatial names are still wrong and "z" is + # still repeated. metadata.axes = [ Axis(name="x", type="space"), Axis(name="c", type="channel"), Axis(name="c2", type="channel"), Axis(name="z", type="space"), - Axis(name="w", type="space"), + Axis(name="z", type="space"), ] observed.append(_first_violated_rule(metadata)) # Stage 3 -> AXIS_ORDER (class ordering). One channel now, but a space axis - # still precedes it; the spatial names are still not the (z, y, x) suffix. + # still precedes it; the spatial names are still not the (z, y, x) suffix + # and "z" is still repeated. metadata.axes = [ Axis(name="x", type="space"), Axis(name="c", type="channel"), Axis(name="z", type="space"), - Axis(name="w", type="space"), + Axis(name="z", type="space"), ] observed.append(_first_violated_rule(metadata)) # Stage 4 -> AXIS_ORDER (spatial suffix). Class order fixed (channel first), - # but spatial names (x, z, w) are not the length-3 suffix of (z, y, x). + # but spatial names (x, z, z) are not the length-3 suffix of (z, y, x). metadata.axes = [ Axis(name="c", type="channel"), Axis(name="x", type="space"), Axis(name="z", type="space"), - Axis(name="w", type="space"), + Axis(name="z", type="space"), ] observed.append(_first_violated_rule(metadata)) - # Stage 5 -> per-dataset scale count. Axes are now fully valid [c, z, y, x] + # Stage 5 -> AXIS_NAMES_UNIQUE. Every axis rule before it now passes, but + # the channel axis is named "z" like the first space axis. + metadata.axes = _axes_with_repeated_name() + observed.append(_first_violated_rule(metadata)) + + # Stage 6 -> per-dataset scale count. Axes are now fully valid [c, z, y, x] # (with an inconsistent-orientation violation lurking as rule 10). Dataset # 0 still has two scales, so the per-dataset-scale-count rule fires. metadata.axes = _valid_axes_with_inconsistent_orientation() observed.append(_first_violated_rule(metadata)) - # Stage 6 -> SCALE_LENGTH_MISMATCH. Dataset 0 now has exactly one scale, but + # Stage 7 -> SCALE_LENGTH_MISMATCH. Dataset 0 now has exactly one scale, but # its length is 3 against 4 axes; a scale-after-translation (rule 7) lurks. metadata.datasets[0].coordinateTransformations = [ Translation([0.0, 0.0, 0.0]), @@ -261,7 +307,7 @@ def test_orchestrator_evaluation_order(): ] observed.append(_first_violated_rule(metadata)) - # Stage 7 -> transform order. Lengths fixed to 4; dataset 0's scale still + # Stage 8 -> transform order. Lengths fixed to 4; dataset 0's scale still # follows a translation. Dataset 1 is made coarser-but-smaller so the # dataset-order rule (8) lurks behind the transform-order violation. metadata.datasets[0].coordinateTransformations = [ @@ -271,7 +317,7 @@ def test_orchestrator_evaluation_order(): metadata.datasets[1].coordinateTransformations = [Scale([1.0, 0.5, 0.5, 0.5])] observed.append(_first_violated_rule(metadata)) - # Stage 8 -> dataset order. Transform order fixed (scale before + # Stage 9 -> dataset order. Transform order fixed (scale before # translation); dataset 1 is still coarser-but-smaller than dataset 0. metadata.datasets[0].coordinateTransformations = [ Scale([1.0, 1.0, 1.0, 1.0]), @@ -279,12 +325,12 @@ def test_orchestrator_evaluation_order(): ] observed.append(_first_violated_rule(metadata)) - # Stage 9 -> OMERO color. Dataset order fixed (level 1 coarser-larger); + # Stage 10 -> OMERO color. Dataset order fixed (level 1 coarser-larger); # only the bad OMERO color and the orientation violation remain. metadata.datasets[1].coordinateTransformations = [Scale([1.0, 2.0, 2.0, 2.0])] observed.append(_first_violated_rule(metadata)) - # Stage 10 -> orientation. OMERO color fixed; the y axis still declares a + # Stage 11 -> orientation. OMERO color fixed; the y axis still declares a # different orientation type than its spatial siblings. metadata.omero = _omero("00FF88") observed.append(_first_violated_rule(metadata)) @@ -299,3 +345,106 @@ def test_orchestrator_evaluation_order(): orientation=_orientation("anatomical", "anterior-to-posterior"), ) validate_structural(metadata) + + +# --------------------------------------------------------------------------- +# Manifest: the locked RFC-3 version set +# --------------------------------------------------------------------------- + + +def _rfc3_axis_metadata() -> Metadata: + """Six same-type axes: legal under RFC-3, illegal at every other version. + + Violates axis-count (6 > 5) and the spatial-axis rules (6 > 3 ``space`` + axes) at once, and nothing else, so the orchestrator accepts it exactly + when the axis rules are inert. + """ + names = ["a", "b", "c", "d", "e", "f"] + return Metadata( + axes=[Axis(name=name, type="space") for name in names], + datasets=[ + Dataset( + path="0", + coordinateTransformations=[ + Scale([1.0] * len(names)), + Translation([0.0] * len(names)), + ], + ) + ], + coordinateTransformations=None, + ) + + +def test_rfc3_version_manifest_is_locked(): + # Inert at exactly the manifest versions... + for version in CANONICAL_RFC3_VERSIONS: + validate_structural(_rfc3_axis_metadata(), version=version) + + # ...and enforced at every other supported version, and by default. + for version in NON_RFC3_VERSIONS: + with pytest.raises(ValidationError) as exc_info: + validate_structural(_rfc3_axis_metadata(), version=version) + assert exc_info.value.rule == SpecRule.AXIS_COUNT, version + + +def _repeated_name_metadata() -> Metadata: + """A repeated axis name that no *other* axis rule can catch. + + ``(time "x", space "y", space "x")`` satisfies all four restricted axis + rules: 3 axes, one ``time`` and two ``space``, ordered time then space, and + the spatial names are the ``(y, x)`` suffix. ``axis-names-unique`` is + therefore the only rule that can fire, at every version. + """ + return Metadata( + axes=[ + Axis(name="x", type="time"), + Axis(name="y", type="space"), + Axis(name="x", type="space"), + ], + datasets=[ + Dataset( + path="0", + coordinateTransformations=[ + Scale([1.0, 1.0, 1.0]), + Translation([0.0, 0.0, 0.0]), + ], + ) + ], + coordinateTransformations=None, + ) + + +def test_axis_names_unique_is_never_inert(): + # RFC-3 *adds* this rule rather than lifting one, so unlike the other four + # axis rules it fires at the RFC-3 versions too -- and at every other. + for version in CANONICAL_RFC3_VERSIONS + NON_RFC3_VERSIONS: + with pytest.raises(ValidationError) as exc_info: + validate_structural(_repeated_name_metadata(), version=version) + assert exc_info.value.rule == SpecRule.AXIS_NAMES_UNIQUE, version + + +def _commented_rule_ids() -> list[str]: + """The rule ids listed in the canonical table at the top of the module. + + The table is a comment block, so nothing but this test keeps it in step + with :class:`SpecRule`; it lost ``axis-names-unique`` once already. + """ + source = Path(structural_validation.__file__).read_text() + table = source.split("# Canonical rule table", 1)[1].split("\nfrom ", 1)[0] + return re.findall(r"^# ([a-z0-9-]+)$", table, re.M) + + +def _documented_rule_ids() -> list[str]: + """The rule ids listed in the rule-reference table.""" + reference = ( + Path(__file__).parents[2] / "docs" / "validation" / "rule-reference.md" + ).read_text() + return re.findall(r"^\| *\d+ *\| *`([a-z0-9-]+)`", reference, re.M) + + +def test_module_rule_table_matches_the_manifest(): + assert _commented_rule_ids() == CANONICAL_SPEC_RULE_IDS + + +def test_rule_reference_doc_matches_the_manifest(): + assert _documented_rule_ids() == CANONICAL_SPEC_RULE_IDS diff --git a/py/test/test_unknown_axis_fields.py b/py/test/test_unknown_axis_fields.py index 121fcf31..b07c941c 100644 --- a/py/test/test_unknown_axis_fields.py +++ b/py/test/test_unknown_axis_fields.py @@ -153,8 +153,8 @@ def test_missing_required_name_field(zarr_helpers, tmp_path): from_ngff_zarr(store, version=version) -def test_missing_required_type_field(zarr_helpers, tmp_path): - """Test that missing 'type' field raises ValueError.""" +def test_missing_type_field_is_optional(zarr_helpers, tmp_path): + """A type-less axis is read with type None instead of being rejected.""" # Create a basic image data = np.random.rand(10, 20, 30).astype(np.float32) image = to_ngff_image( @@ -171,17 +171,17 @@ def test_missing_required_type_field(zarr_helpers, tmp_path): version = "0.4" to_ngff_zarr(store, multiscales, version=version) - # Manually corrupt the metadata by removing 'type' field + # Drop the 'type' field from the first axis. attrs = zarr_helpers["get"](store) - - # Remove the 'type' field from one axis del attrs["multiscales"][0]["axes"][0]["type"] - zarr_helpers["set"](store, attrs) - # Try to load the data - should raise ValueError - with pytest.raises(ValueError, match="missing required field 'type'"): - from_ngff_zarr(store, version=version) + # The axis reads with type None; the 'name' field stays required (below). + result = from_ngff_zarr(store, version=version) + axes = result.metadata.to_version("0.4").axes + assert axes[0].type is None + assert axes[0].name == "z" + assert axes[1].type == "space" def test_only_unknown_fields(zarr_helpers, tmp_path): diff --git a/ts/src/types/ngff_image.ts b/ts/src/types/ngff_image.ts index ae57348b..b71e0de5 100644 --- a/ts/src/types/ngff_image.ts +++ b/ts/src/types/ngff_image.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC // SPDX-License-Identifier: MIT import * as zarr from "zarrita"; -import type { Units } from "./units.ts"; +import type { AxisUnit } from "./units.ts"; import type { AnatomicalOrientation } from "./rfc4.ts"; export type ComputedCallback = () => void; @@ -12,7 +12,7 @@ export interface NgffImageOptions { scale: Record; translation: Record; name: string | undefined; - axesUnits: Record | undefined; + axesUnits: Record | undefined; axesOrientations?: Record | undefined; axesTypes?: Record | undefined; computedCallbacks: ComputedCallback[] | undefined; @@ -24,7 +24,7 @@ export class NgffImage { public readonly scale: Record; public readonly translation: Record; public readonly name: string; - public readonly axesUnits: Record | undefined; + public readonly axesUnits: Record | undefined; public readonly axesOrientations: | Record | undefined; diff --git a/ts/src/types/units.ts b/ts/src/types/units.ts index ad99315f..2e50151e 100644 --- a/ts/src/types/units.ts +++ b/ts/src/types/units.ts @@ -12,6 +12,19 @@ export type AxesType = | "coordinate" | "displacement"; +/** + * Axis name. RFC-3 (OME-Zarr 0.9.dev1) permits any string; below 0.9.dev1 the + * `SupportedDims` convention is enforced by structural validation, not the + * type system. The union with the literal set keeps editor completion. + */ +export type AxisName = SupportedDims | (string & Record); + +/** + * Axis type. RFC-3 (OME-Zarr 0.9.dev1) permits any string alongside the + * spec-defined `AxesType` set, and `type` may be omitted entirely. + */ +export type AxisType = AxesType | (string & Record); + export type SpaceUnits = | "angstrom" | "attometer" @@ -67,6 +80,12 @@ export type TimeUnits = export type Units = SpaceUnits | TimeUnits; +/** + * Axis unit. RFC-3 (OME-Zarr 0.9.dev1) permits any string alongside the + * spec-defined `Units` vocabulary, and `unit` may be omitted entirely. + */ +export type AxisUnit = Units | (string & Record); + export const supportedDims: SupportedDims[] = ["x", "y", "z", "c", "t"]; export const spaceUnits: SpaceUnits[] = [ diff --git a/ts/src/types/zarr_metadata.ts b/ts/src/types/zarr_metadata.ts index a197ad50..241ad572 100644 --- a/ts/src/types/zarr_metadata.ts +++ b/ts/src/types/zarr_metadata.ts @@ -1,6 +1,6 @@ // SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC // SPDX-License-Identifier: MIT -import type { AxesType, SupportedDims, Units } from "./units.ts"; +import type { AxisName, AxisType, AxisUnit, SupportedDims } from "./units.ts"; import { NgffVersion } from "./supported_versions.ts"; import type { NgffImage } from "./ngff_image.ts"; import type { AnatomicalOrientation } from "./rfc4.ts"; @@ -22,9 +22,10 @@ export interface AxisOrientation { } export interface Axis { - name: SupportedDims; - type: AxesType; - unit: Units | undefined; + name: AxisName; + // The v0.4 schema accepts an axis that declares no `type`. + type: AxisType | undefined; + unit: AxisUnit | undefined; orientation?: AxisOrientation | AnatomicalOrientation | undefined; discrete?: boolean; } diff --git a/ts/src/utils/factory.ts b/ts/src/utils/factory.ts index 1da70d6b..b9d8f880 100644 --- a/ts/src/utils/factory.ts +++ b/ts/src/utils/factory.ts @@ -12,7 +12,7 @@ import type { Scale, Translation, } from "../types/zarr_metadata.ts"; -import type { AxesType, SupportedDims, Units } from "../types/units.ts"; +import type { AxesType, AxisUnit, SupportedDims } from "../types/units.ts"; import type { Methods } from "../types/methods.ts"; import type { AnatomicalOrientation } from "../types/rfc4.ts"; @@ -64,7 +64,7 @@ export async function createNgffImage( export function createAxis( name: SupportedDims, type: AxesType, - unit?: Units, + unit?: AxisUnit, orientation?: AxisOrientation | AnatomicalOrientation, ): Axis { const axis: Axis = { diff --git a/ts/src/utils/from_zarr_attrs.ts b/ts/src/utils/from_zarr_attrs.ts index 7c347a61..90e00ad8 100644 --- a/ts/src/utils/from_zarr_attrs.ts +++ b/ts/src/utils/from_zarr_attrs.ts @@ -21,7 +21,7 @@ import type { import { SUPPORTED_DIMS } from "../types/zarr_metadata.ts"; import { extractScaleTranslation, parseV06Transforms } from "./v06_metadata.ts"; import { NgffImage } from "../types/ngff_image.ts"; -import type { AxesType, SupportedDims, Units } from "../types/units.ts"; +import type { AxesType, AxisUnit, SupportedDims } from "../types/units.ts"; import { parseOmero } from "./parse_metadata.ts"; import type { MemoryStore } from "../io/from_ngff_zarr.ts"; import { @@ -215,7 +215,7 @@ export async function fromZarrAttrsV04( // Handle backwards compatibility for version <= 0.3 let dims: string[]; let axes: Axis[]; - let units: Record; + let units: Record; if (!("axes" in multiscalesMetadata)) { // Version <= 0.3 - use default dims @@ -257,9 +257,14 @@ export async function fromZarrAttrsV04( axes = axesData.map((axis) => { const axisObj = axis as Record; return { - name: String(axisObj.name) as SupportedDims, - type: String(axisObj.type) as AxesType, - unit: axisObj.unit as Units | undefined, + // RFC-3 permits any axis name, so the parsed value stays a string + // rather than being narrowed to the closed `SupportedDims` union. + name: String(axisObj.name), + // `type` is optional; keep undefined rather than the "undefined" string + type: axisObj.type === undefined || axisObj.type === null + ? undefined + : String(axisObj.type) as AxesType, + unit: axisObj.unit as AxisUnit | undefined, // Preserve RFC 4 orientation on the parsed axis so the strict // structural pass below can run validateAxisOrientation; dropping it // here makes that rule a no-op. This mirrors the Python port, whose @@ -294,7 +299,7 @@ export async function fromZarrAttrsV04( if (typeof axis === "object") { const axisObj = axis as Record; const name = axisObj.name as string; - const unit = axisObj.unit as Units | undefined; + const unit = axisObj.unit as AxisUnit | undefined; if (name !== undefined && unit !== undefined) { units[name] = unit; } @@ -398,7 +403,7 @@ export async function fromZarrAttrsV04( coordinateTransformations, }); - const filteredUnits: Record = {}; + const filteredUnits: Record = {}; for (const [axis, unit] of Object.entries(units)) { if (unit !== undefined && unit !== null) { filteredUnits[axis] = unit; @@ -473,7 +478,11 @@ export async function fromZarrAttrsV04( // scoped to v0.4 and newer. if (validate) { if (isSpecVersionAtLeastV04(metadata.version)) { - validateStructural(metadata, { level: ValidationLevel.Strict }); + validateStructural( + metadata, + { level: ValidationLevel.Strict }, + metadata.version, + ); } } @@ -551,6 +560,9 @@ export async function fromZarrAttrsV06( // v0.6 wraps the multiscales under the "ome" key; tolerate a root-level // layout for symmetry with the v0.5 reader. const omeData = rootAttrs.ome as Record | undefined; + // v0.6 and 0.9.dev1 share this reader; the group-level `version` says which. + const declaredVersion = (omeData?.version as string | undefined) ?? + (rootAttrs.version as string | undefined) ?? "0.6"; let entry: Record; let omeroRaw: unknown; if (omeData && "multiscales" in omeData) { @@ -600,9 +612,14 @@ export async function fromZarrAttrsV06( return { name: String(cs.name), axes: (cs.axes as Array>).map((axis) => ({ - name: String(axis.name) as SupportedDims, - type: String(axis.type) as AxesType, - unit: axis.unit as Units | undefined, + // RFC-3 permits any axis name, so the parsed value stays a string + // rather than being narrowed to the closed `SupportedDims` union. + name: String(axis.name), + // `type` is optional; keep undefined rather than the "undefined" string + type: axis.type === undefined || axis.type === null + ? undefined + : String(axis.type) as AxesType, + unit: axis.unit as AxisUnit | undefined, ...(axis.orientation !== undefined && axis.orientation !== null ? { orientation: axis.orientation as Axis["orientation"] } : {}), @@ -623,7 +640,7 @@ export async function fromZarrAttrsV06( >; const dims = intrinsic.axes.map((axis) => String(axis.name)); - const units: Record = {}; + const units: Record = {}; for (const axis of intrinsic.axes) { if (axis.unit !== undefined && axis.unit !== null) { units[axis.name] = axis.unit; @@ -774,8 +791,18 @@ export async function fromZarrAttrsV06( // reconstructed into the same shape, so the same rules apply. Full RFC-5 wire // validation (coordinate-system graphs, arbitrary transform chains) remains // deferred. + // + // The declared version is what decides whether the axis rules apply: it + // selects the RFC-3 axis model at 0.9.dev1 and the `array` coordinate-system + // arm of the v0.6 axes schema. Validating without it enforces the v0.4 caps + // on every store, so a document this port's own writer emits is refused on + // the way back in. if (validate) { - validateStructural(metadata, { level: ValidationLevel.Strict }); + validateStructural( + metadata, + { level: ValidationLevel.Strict }, + declaredVersion, + ); } return { metadata, images }; diff --git a/ts/src/utils/py_format.ts b/ts/src/utils/py_format.ts index 077cba9b..6bbc14e9 100644 --- a/ts/src/utils/py_format.ts +++ b/ts/src/utils/py_format.ts @@ -7,16 +7,41 @@ * which the cross-language parity tests assert. */ +/** + * Render a string as Python's `repr()` renders it. + * + * A single-quoted literal, switching to double quotes when the value contains a + * single quote but no double quote. RFC-3 puts arbitrary strings in `name` and + * `type`, and no schema excludes a quote, so the switch is reachable. + */ +export function pyRepr(value: string): string { + const quote = value.includes("'") && !value.includes('"') ? '"' : "'"; + const escaped = value.replaceAll("\\", "\\\\").replaceAll( + quote, + `\\${quote}`, + ); + return `${quote}${escaped}${quote}`; +} + /** * Render a list of names as a Python-style list literal, e.g. `['z', 'y']`. * * Mirrors Python's `str(list_of_str)` / f-string rendering: bracketed, - * single-quoted elements, comma-space separated. Both the structural + * `repr`-rendered elements, comma-space separated. Both the structural * spatial-axis-order message and the RFC 4 axis-orientation-on-non-space * message interpolate this verbatim, so the two ports stay byte-for-byte - * identical. Axis names reaching these rules are single-quote-free, so the - * plain single-quoted element form is exact. + * identical. */ export function formatNameList(names: readonly string[]): string { - return `[${names.map((name) => `'${name}'`).join(", ")}]`; + return `[${names.map(pyRepr).join(", ")}]`; +} + +/** + * Render an optional string the way Python renders `{value!r}`. + * + * An axis may declare no `type`; Python renders that `None`, TypeScript would + * render `undefined` (or `null` when the JSON carried an explicit null). + */ +export function pyReprOptional(value: string | null | undefined): string { + return value === undefined || value === null ? "None" : pyRepr(value); } diff --git a/ts/src/utils/structural_validation.ts b/ts/src/utils/structural_validation.ts index e08c91e9..c605fd9b 100644 --- a/ts/src/utils/structural_validation.ts +++ b/ts/src/utils/structural_validation.ts @@ -37,7 +37,11 @@ import { hasRfc4OrientationMetadata, validateRfc4Orientation, } from "./rfc4_validation.ts"; -import { formatNameList } from "./py_format.ts"; +import { formatNameList, pyRepr } from "./py_format.ts"; +import { + isRfc3AxisModelAllowed, + isV06Version, +} from "../types/supported_versions.ts"; /** * Stable, kebab-case identifiers for the structural specification rules. @@ -54,6 +58,8 @@ export const SpecRule = { AxisType: "axis-type", /** time before channel before space; spatial names suffix (z, y, x). */ AxisOrder: "axis-order", + /** Axis names are unique within a dataset. */ + AxisNamesUnique: "axis-names-unique", /** Every scale/translation vector length equals the axis count. */ ScaleLengthMismatch: "scale-length-mismatch", /** Exactly one scale per dataset; a translation must follow it. */ @@ -128,9 +134,17 @@ export class ValidationError extends Error { readonly rule: SpecRule; /** Dotted-segment location of the offending metadata node, if known. */ readonly location?: string; + /** + * The rule text without the `Spec rule [...] violated: ` prefix. + * + * `Error.message` is the prefixed form, matching Python's `str(exc)`; this is + * Python's `exc.message`, for callers that supply their own prefix. + */ + readonly detail: string; constructor(rule: SpecRule, message: string, location?: string) { super(`Spec rule [${rule}] violated: ${message}`); + this.detail = message; this.name = "ValidationError"; this.rule = rule; if (location !== undefined) { @@ -209,6 +223,29 @@ const AXIS_TYPE_RANK: Record = { */ const SPATIAL_AXIS_NAMES = ["z", "y", "x"] as const; +/** + * The v0.6 `axes` schema is a `oneOf`: either 2 or 3 `space` axes, or two or + * more `array` axes. An RFC-5 array coordinate system takes the second branch + * and declares no `space` axis at all. + */ +const MIN_ARRAY_AXES = 2; + +/** + * Whether `axes` satisfies the `array` arm of the v0.6 axes schema. + * + * Only v0.6 has that arm; the v0.4 and v0.5 schemas require 2 or 3 `space` + * axes unconditionally, so the space-axis floor is not relaxed for them. + */ +function takesArraySchemaBranch( + axes: Pick[], + version?: string, +): boolean { + if (version === undefined || !isV06Version(version)) { + return false; + } + return axes.filter((ax) => ax.type === "array").length >= MIN_ARRAY_AXES; +} + /** * Render a number the way Python's `str()`/f-string renders a `float`: a * whole-number value keeps a trailing `.0` (e.g. `2` -> `"2.0"`), matching the @@ -225,18 +262,25 @@ function pyFloat(value: number): string { /** * Validate that the axis count is within the v0.4-permitted range. * - * OME-Zarr v0.4 requires between 2 and 5 axes, inclusive. + * OME-Zarr v0.4, v0.5 and v0.6 require between 2 and 5 axes, inclusive. * * @param metadata - The parsed multiscales metadata to validate. * @throws {ValidationError} With {@link SpecRule.AxisCount} when * `metadata.axes.length` lies outside `2..5`; location `multiscales[0].axes`. */ -export function validateAxisCount(metadata: Metadata): void { +export function validateAxisCount( + metadata: Pick, + version?: string, +): void { + if (isRfc3AxisModelAllowed(version)) { + return; + } const count = metadata.axes.length; if (count < 2 || count > 5) { throw new ValidationError( SpecRule.AxisCount, - `OME-Zarr v0.4 requires between 2 and 5 axes, inclusive; found ${count}.`, + `OME-Zarr v0.4, v0.5 and v0.6 require between 2 and 5 axes, ` + + `inclusive; found ${count}.`, "multiscales[0].axes", ); } @@ -252,7 +296,13 @@ export function validateAxisCount(metadata: Metadata): void { * `time` axis or more than one `channel` axis is present; location * `multiscales[0].axes`. */ -export function validateAxisType(metadata: Metadata): void { +export function validateAxisType( + metadata: Pick, + version?: string, +): void { + if (isRfc3AxisModelAllowed(version)) { + return; + } const timeCount = metadata.axes.filter((ax) => ax.type === "time").length; if (timeCount > 1) { throw new ValidationError( @@ -286,13 +336,25 @@ export function validateAxisType(metadata: Metadata): void { * adjacent pair where a lower-ranked axis type follows a higher-ranked one; * location `multiscales[0].axes[i+1]`. */ -export function validateAxisOrder(metadata: Metadata): void { +export function validateAxisOrder( + metadata: Pick, + version?: string, +): void { + if (isRfc3AxisModelAllowed(version)) { + return; + } const axes = metadata.axes; for (let i = 0; i < axes.length - 1; i++) { const current = axes[i]; const following = axes[i + 1]; - const currentRank = AXIS_TYPE_RANK[current.type]; - const followingRank = AXIS_TYPE_RANK[following.type]; + // An axis with no `type` has no rank, mirroring the Python port's dict + // `.get()`; the pair is then skipped by the guard below. + const currentRank = current.type === undefined + ? undefined + : AXIS_TYPE_RANK[current.type]; + const followingRank = following.type === undefined + ? undefined + : AXIS_TYPE_RANK[following.type]; if (currentRank === undefined || followingRank === undefined) { continue; } @@ -320,7 +382,13 @@ export function validateAxisOrder(metadata: Metadata): void { * than three `space` axes, or when their names are not the expected suffix of * `(z, y, x)`; location `multiscales[0].axes` or the first offending axis. */ -export function validateSpatialAxisOrder(metadata: Metadata): void { +export function validateSpatialAxisOrder( + metadata: Pick, + version?: string, +): void { + if (isRfc3AxisModelAllowed(version)) { + return; + } const spaceIndices: number[] = []; metadata.axes.forEach((ax, i) => { if (ax.type === "space") { @@ -331,7 +399,16 @@ export function validateSpatialAxisOrder(metadata: Metadata): void { if (count > 3) { throw new ValidationError( SpecRule.AxisOrder, - `OME-Zarr v0.4 permits at most 3 'space' axes; found ${count}.`, + `OME-Zarr v0.4, v0.5 and v0.6 permit at most 3 'space' axes; ` + + `found ${count}.`, + "multiscales[0].axes", + ); + } + if (count < 2 && !takesArraySchemaBranch(metadata.axes, version)) { + throw new ValidationError( + SpecRule.AxisOrder, + `OME-Zarr v0.4, v0.5 and v0.6 require 2 or 3 'space' axes; ` + + `found ${count}.`, "multiscales[0].axes", ); } @@ -348,6 +425,38 @@ export function validateSpatialAxisOrder(metadata: Metadata): void { } } +/** + * Validate that axis names are unique within the dataset. + * + * Never inert. It states RFC-3 rule 5, "axis names MUST NOT be repeated within + * a dataset". No released schema carries it, so below `0.9.dev1` it is a + * strictness choice rather than a spec MUST of those versions. Rule 5 also says + * names SHOULD NOT differ only by case. That is a SHOULD and is not enforced. + * + * @param metadata - The parsed multiscales metadata to validate. + * @throws {ValidationError} With {@link SpecRule.AxisNamesUnique} for the first + * axis whose `name` duplicates an earlier one; location + * `multiscales[0].axes[i]`. + */ +export function validateAxisNamesUnique( + metadata: Pick, + _version?: string, +): void { + const seen = new Set(); + for (let i = 0; i < metadata.axes.length; i++) { + const name = metadata.axes[i].name; + if (seen.has(name)) { + throw new ValidationError( + SpecRule.AxisNamesUnique, + `Axis name '${name}' is repeated; axis names must be unique ` + + `within a dataset.`, + `multiscales[0].axes[${i}]`, + ); + } + seen.add(name); + } +} + /** * Validate that each dataset defines exactly one `scale` transform. * @@ -668,26 +777,6 @@ export function validateAxisOrientation(metadata: Metadata): void { } } -/** - * Render a string as Python's `repr()` renders it, for byte-identical messages. - * - * The plate/well rule messages interpolate row, column, and well-path strings - * the way the Python implementation does with `{value!r}`: a single-quoted - * literal, switching to double quotes only when the value contains a single - * quote but no double quote. Keeping this byte-for-byte identical to Python is a - * property the cross-language parity tests assert. Inputs reaching these rules - * are alphanumeric path segments, so this resolves to a plain single-quoted - * form in practice. - */ -function pyRepr(value: string): string { - const quote = value.includes("'") && !value.includes('"') ? '"' : "'"; - const escaped = value.replaceAll("\\", "\\\\").replaceAll( - quote, - `\\${quote}`, - ); - return `${quote}${escaped}${quote}`; -} - /** * Validate that a v0.5 multiscales entry implies a Zarr v3 store. * @@ -876,16 +965,17 @@ export function validateWellAcquisition( * 2. {@link validateAxisType} * 3. {@link validateAxisOrder} * 4. {@link validateSpatialAxisOrder} - * 5. {@link validatePerDatasetScaleCount} - * 6. {@link validateScaleLength} - * 7. {@link validateTransformOrder} - * 8. {@link validateDatasetOrder} - * 9. {@link validateOmeroColorHex} - * 10. {@link validateAxisOrientation} - * 11. {@link validateZarrFormatForVersion} - * 12. {@link validateOmeNamespace} - * - * Rules 11 and 12 are the OME-Zarr v0.5 namespacing checks; they fire only for + * 5. {@link validateAxisNamesUnique} + * 6. {@link validatePerDatasetScaleCount} + * 7. {@link validateScaleLength} + * 8. {@link validateTransformOrder} + * 9. {@link validateDatasetOrder} + * 10. {@link validateOmeroColorHex} + * 11. {@link validateAxisOrientation} + * 12. {@link validateZarrFormatForVersion} + * 13. {@link validateOmeNamespace} + * + * Rules 12 and 13 are the OME-Zarr v0.5 namespacing checks; they fire only for * v0.5 metadata and are inert (a no-op) for v0.4. * * Under {@link ValidationLevel.SchemaOnly} this function returns immediately @@ -902,6 +992,9 @@ export function validateWellAcquisition( * @param metadata - The parsed OME-Zarr v0.4 multiscales metadata to validate. * @param options - Validation options; defaults to * `{ level: "strict", allowUnknownFields: true }`. + * @param version - The OME-Zarr version the metadata declares. Rules 1-3 are + * inert for the versions that adopt the RFC-3 axis model, so omitting it holds + * every store to the v0.4 axis caps. * @throws {ValidationError} For the first structural rule violated, carrying * the offending {@link SpecRule} and `location`. Never thrown under * {@link ValidationLevel.SchemaOnly}, which runs no structural rule. @@ -909,6 +1002,7 @@ export function validateWellAcquisition( export function validateStructural( metadata: Metadata, options?: ValidateOptions, + version?: string, ): void { const resolved: Required = { level: options?.level ?? ValidationLevel.Strict, @@ -917,10 +1011,11 @@ export function validateStructural( if (resolved.level === ValidationLevel.SchemaOnly) { return; } - validateAxisCount(metadata); - validateAxisType(metadata); - validateAxisOrder(metadata); - validateSpatialAxisOrder(metadata); + validateAxisCount(metadata, version); + validateAxisType(metadata, version); + validateAxisOrder(metadata, version); + validateSpatialAxisOrder(metadata, version); + validateAxisNamesUnique(metadata, version); validatePerDatasetScaleCount(metadata); validateScaleLength(metadata); validateTransformOrder(metadata); diff --git a/ts/test/structural_validation_parity_test.ts b/ts/test/structural_validation_parity_test.ts index dc7f586e..f7270b14 100644 --- a/ts/test/structural_validation_parity_test.ts +++ b/ts/test/structural_validation_parity_test.ts @@ -17,18 +17,13 @@ * therefore be a deliberate edit to {@link CANONICAL_SPEC_RULE_IDS} (and its * Python twin), not an accident. * - * Two adaptations to TypeScript are unavoidable and are the only intentional - * departures from the Python twin: - * - * 1. Axis names. TypeScript constrains `Axis.name` to the closed `SupportedDims` - * union (`"c" | "x" | "y" | "z" | "t"`), so the Python ordering test's - * free-form helper names (`"c2"`, `"w"`) are replaced with valid members - * (a repeated `"c"`, a `"y"`). The per-stage violations -- and therefore the - * observed evaluation order -- are identical; only the labels differ. - * 2. The default level. Python pins it on a constructable options object - * (`ValidateOptions().level == STRICT`); TypeScript's `ValidateOptions` is a - * bare interface whose default is resolved inside {@link validateStructural}. - * The same default is asserted observably instead (see the level test). + * One adaptation to TypeScript is unavoidable and is the only intentional + * departure from the Python twin: the default level. Python pins it on a + * constructable options object (`ValidateOptions().level == STRICT`); + * TypeScript's `ValidateOptions` is a bare interface whose default is resolved + * inside {@link validateStructural}. The same default is asserted observably + * instead (see the level test). `Axis.name` is `AxisName`, so the ordering + * fixtures use the same free-form names as the Python twin. * * The validation surface is imported from the package root (`../src/mod.ts`), * mirroring the Python test's import from `ngff_zarr`; the metadata constructors @@ -39,6 +34,7 @@ import { assertEquals, assertMatch, assertThrows } from "@std/assert"; import { SpecRule, + SUPPORTED_VERSIONS, validateStructural, ValidationError, ValidationLevel, @@ -54,15 +50,16 @@ import { // The locked rule manifest: every active OME-Zarr structural rule, in canonical // declaration order. This identical literal list appears in the Python twin so -// the two are directly comparable. The first eleven entries are the -// image/multiscales rules dispatched by validateStructural -- the first nine are -// the v0.4 rules and the next two (zarr-format, ome-namespace) are the v0.5 +// the two are directly comparable. The first thirteen entries are the +// image/multiscales rules dispatched by validateStructural -- the first eleven +// are the v0.4 rules and the next two (zarr-format, ome-namespace) are the v0.5 // namespacing rules, inert for v0.4; the final two are the HCS plate/well rules // dispatched by validatePlate / validateWell. const CANONICAL_SPEC_RULE_IDS: string[] = [ "axis-count", "axis-type", "axis-order", + "axis-names-unique", "scale-length-mismatch", "global-coord-transform-after-per-level", "dataset-order-highest-to-lowest", @@ -76,6 +73,26 @@ const CANONICAL_SPEC_RULE_IDS: string[] = [ "well-acquisition-missing", ]; +// The locked RFC-3 version manifest: the versions whose axis model is +// unrestricted, so that validateAxisCount / validateAxisType / +// validateAxisOrder / validateSpatialAxisOrder are inert for them. This +// identical literal list appears in the Python twin. axis-names-unique is +// deliberately absent: RFC-3 adds that rule rather than lifting it, so it is +// never inert (see docs/validation/rule-reference.md). +const CANONICAL_RFC3_VERSIONS: string[] = [ + "0.9.dev1", +]; + +// Every other supported version, plus the no-version default, must enforce the +// axis rules. Read off SUPPORTED_VERSIONS so a newly supported version has to +// be classified here rather than silently defaulting to "restricted". +const NON_RFC3_VERSIONS: (string | undefined)[] = [ + ...SUPPORTED_VERSIONS + .map((version) => version as string) + .filter((version) => !CANONICAL_RFC3_VERSIONS.includes(version)), + undefined, +]; + // The canonical fail-fast evaluation order of the image/multiscales // orchestrator (validateStructural). Each entry is the SpecRule the // orchestrator must raise when that rule -- and every rule after it -- is @@ -83,7 +100,7 @@ const CANONICAL_SPEC_RULE_IDS: string[] = [ // share each: validateAxisOrder and validateSpatialAxisOrder both surface // AxisOrder (positions 3-4), and validatePerDatasetScaleCount and // validateTransformOrder both surface GlobalCoordTransformAfterPerLevel -// (positions 5 and 7). The two HCS rules are absent: they are not part of the +// (positions 6 and 8). The two HCS rules are absent: they are not part of the // image/multiscales orchestrator. The two v0.5 namespacing rules (zarr-format, // ome-namespace) run last in the orchestrator but are inert for the v0.4 // metadata exercised here, so they never appear in the observed order. @@ -92,6 +109,7 @@ const EXPECTED_EVALUATION_ORDER: SpecRule[] = [ SpecRule.AxisType, SpecRule.AxisOrder, // validateAxisOrder (class ordering) SpecRule.AxisOrder, // validateSpatialAxisOrder (spatial suffix) + SpecRule.AxisNamesUnique, SpecRule.GlobalCoordTransformAfterPerLevel, // per-dataset scale count SpecRule.ScaleLengthMismatch, SpecRule.GlobalCoordTransformAfterPerLevel, // transform order @@ -100,6 +118,20 @@ const EXPECTED_EVALUATION_ORDER: SpecRule[] = [ SpecRule.AxisOrientationAnatomicalType, ]; +/** + * Valid `[c, z, y, x]` axes with the channel axis renamed `z`. + * + * Every rule before AxisNamesUnique still passes: the class order is + * channel-then-space, the spatial names remain the `(z, y, x)` suffix, and the + * count is 4. Only the repeated `z` is left for the orchestrator to catch, + * which pins the rule's position in the cascade. + */ +function axesWithRepeatedName(): Axis[] { + const axes = validAxesWithInconsistentOrientation(); + axes[0] = { ...axes[0], name: "z" }; + return axes; +} + /** Build a single-channel OMERO block with the given channel `color`. */ function makeOmero(color: string): Omero { return { @@ -226,16 +258,17 @@ Deno.test("validateStructural evaluates rules in canonical order", () => { const badOmero = makeOmero("xyz"); // OMERO violation (rule 9), until repaired // Stage 1 -> AxisCount. Six axes simultaneously trip axis-type (two - // channels), axis-order (a space precedes a channel), and spatial-order - // (names are not the (z, y, x) suffix); axis-count is evaluated first. + // channels), axis-order (a space precedes a channel), spatial-order (names + // are not the (z, y, x) suffix) and axis-names-unique (a repeated "z"); + // axis-count is evaluated first. const metadata: Metadata = { axes: [ { name: "x", type: "space", unit: undefined }, { name: "c", type: "channel", unit: undefined }, - { name: "c", type: "channel", unit: undefined }, + { name: "c2", type: "channel", unit: undefined }, { name: "t", type: "time", unit: undefined }, { name: "z", type: "space", unit: undefined }, - { name: "y", type: "space", unit: undefined }, + { name: "z", type: "space", unit: undefined }, ], datasets: [ { @@ -257,44 +290,51 @@ Deno.test("validateStructural evaluates rules in canonical order", () => { }; observed.push(firstViolatedRule(metadata)); - // Stage 2 -> AxisType. Count fixed (5 axes); two channels remain, and a - // space still precedes a channel and the spatial names are still wrong. + // Stage 2 -> AxisType. Count fixed (5 axes); two channels remain, a space + // still precedes a channel, the spatial names are still wrong and "z" is + // still repeated. metadata.axes = [ { name: "x", type: "space", unit: undefined }, { name: "c", type: "channel", unit: undefined }, - { name: "c", type: "channel", unit: undefined }, + { name: "c2", type: "channel", unit: undefined }, + { name: "z", type: "space", unit: undefined }, { name: "z", type: "space", unit: undefined }, - { name: "y", type: "space", unit: undefined }, ]; observed.push(firstViolatedRule(metadata)); // Stage 3 -> AxisOrder (class ordering). One channel now, but a space axis - // still precedes it; the spatial names are still not the (z, y, x) suffix. + // still precedes it; the spatial names are still not the (z, y, x) suffix + // and "z" is still repeated. metadata.axes = [ { name: "x", type: "space", unit: undefined }, { name: "c", type: "channel", unit: undefined }, { name: "z", type: "space", unit: undefined }, - { name: "y", type: "space", unit: undefined }, + { name: "z", type: "space", unit: undefined }, ]; observed.push(firstViolatedRule(metadata)); // Stage 4 -> AxisOrder (spatial suffix). Class order fixed (channel first), - // but spatial names (x, z, y) are not the length-3 suffix of (z, y, x). + // but spatial names (x, z, z) are not the length-3 suffix of (z, y, x). metadata.axes = [ { name: "c", type: "channel", unit: undefined }, { name: "x", type: "space", unit: undefined }, { name: "z", type: "space", unit: undefined }, - { name: "y", type: "space", unit: undefined }, + { name: "z", type: "space", unit: undefined }, ]; observed.push(firstViolatedRule(metadata)); - // Stage 5 -> per-dataset scale count. Axes are now fully valid [c, z, y, x] + // Stage 5 -> AxisNamesUnique. Every axis rule before it now passes, but the + // channel axis is named "z" like the first space axis. + metadata.axes = axesWithRepeatedName(); + observed.push(firstViolatedRule(metadata)); + + // Stage 6 -> per-dataset scale count. Axes are now fully valid [c, z, y, x] // (with an inconsistent-orientation violation lurking as rule 10). Dataset 0 // still has two scales, so the per-dataset-scale-count rule fires. metadata.axes = validAxesWithInconsistentOrientation(); observed.push(firstViolatedRule(metadata)); - // Stage 6 -> ScaleLengthMismatch. Dataset 0 now has exactly one scale, but a + // Stage 7 -> ScaleLengthMismatch. Dataset 0 now has exactly one scale, but a // length-3 vector against 4 axes; a scale-after-translation (rule 7) lurks. metadata.datasets[0].coordinateTransformations = [ createTranslation([0.0, 0.0, 0.0]), @@ -302,7 +342,7 @@ Deno.test("validateStructural evaluates rules in canonical order", () => { ]; observed.push(firstViolatedRule(metadata)); - // Stage 7 -> transform order. Lengths fixed to 4; dataset 0's scale still + // Stage 8 -> transform order. Lengths fixed to 4; dataset 0's scale still // follows a translation. Dataset 1 is made coarser-but-smaller so the // dataset-order rule (8) lurks behind the transform-order violation. metadata.datasets[0].coordinateTransformations = [ @@ -314,7 +354,7 @@ Deno.test("validateStructural evaluates rules in canonical order", () => { ]; observed.push(firstViolatedRule(metadata)); - // Stage 8 -> dataset order. Transform order fixed (scale before + // Stage 9 -> dataset order. Transform order fixed (scale before // translation); dataset 1 is still coarser-but-smaller than dataset 0. metadata.datasets[0].coordinateTransformations = [ createScale([1.0, 1.0, 1.0, 1.0]), @@ -322,14 +362,14 @@ Deno.test("validateStructural evaluates rules in canonical order", () => { ]; observed.push(firstViolatedRule(metadata)); - // Stage 9 -> OMERO color. Dataset order fixed (level 1 coarser-larger); only + // Stage 10 -> OMERO color. Dataset order fixed (level 1 coarser-larger); only // the bad OMERO color and the orientation violation remain. metadata.datasets[1].coordinateTransformations = [ createScale([1.0, 2.0, 2.0, 2.0]), ]; observed.push(firstViolatedRule(metadata)); - // Stage 10 -> orientation. OMERO color fixed; the y axis still declares a + // Stage 11 -> orientation. OMERO color fixed; the y axis still declares a // different orientation type than its spatial siblings. metadata.omero = makeOmero("00FF88"); observed.push(firstViolatedRule(metadata)); @@ -346,3 +386,97 @@ Deno.test("validateStructural evaluates rules in canonical order", () => { }; validateStructural(metadata); }); + +// --------------------------------------------------------------------------- +// Manifest: the locked RFC-3 version set +// --------------------------------------------------------------------------- + +/** + * Six same-type axes: legal under RFC-3, illegal at every other version. + * + * Violates axis-count (6 > 5) and the spatial-axis rules (6 > 3 `space` axes) + * at once, and nothing else, so the orchestrator accepts it exactly when the + * axis rules are inert. + */ +function rfc3AxisMetadata(): Metadata { + const names = ["a", "b", "c", "d", "e", "f"]; + return { + axes: names.map((name) => ({ + name, + type: "space", + unit: undefined, + } as Axis)), + datasets: [ + { + path: "0", + coordinateTransformations: [ + createScale(names.map(() => 1.0)), + createTranslation(names.map(() => 0.0)), + ], + }, + ], + coordinateTransformations: undefined, + omero: undefined, + name: "image", + version: "0.4", + }; +} + +Deno.test("RFC-3 version manifest is locked", () => { + // Inert at exactly the manifest versions... + for (const version of CANONICAL_RFC3_VERSIONS) { + validateStructural(rfc3AxisMetadata(), undefined, version); + } + + // ...and enforced at every other supported version, and by default. + for (const version of NON_RFC3_VERSIONS) { + const error = assertThrows( + () => validateStructural(rfc3AxisMetadata(), undefined, version), + ValidationError, + ); + assertEquals(error.rule, SpecRule.AxisCount, String(version)); + } +}); + +/** + * A repeated axis name that no *other* axis rule can catch. + * + * `(time "x", space "y", space "x")` satisfies all four restricted axis rules: + * 3 axes, one `time` and two `space`, ordered time then space, and the spatial + * names are the `(y, x)` suffix. `axis-names-unique` is therefore the only rule + * that can fire, at every version. + */ +function repeatedNameMetadata(): Metadata { + return { + axes: [ + { name: "x", type: "time", unit: undefined } as Axis, + { name: "y", type: "space", unit: undefined } as Axis, + { name: "x", type: "space", unit: undefined } as Axis, + ], + datasets: [ + { + path: "0", + coordinateTransformations: [ + createScale([1.0, 1.0, 1.0]), + createTranslation([0.0, 0.0, 0.0]), + ], + }, + ], + coordinateTransformations: undefined, + omero: undefined, + name: "image", + version: "0.4", + }; +} + +Deno.test("axis-names-unique is never inert", () => { + // RFC-3 *adds* this rule rather than lifting one, so unlike the other four + // axis rules it fires at the RFC-3 versions too -- and at every other. + for (const version of [...CANONICAL_RFC3_VERSIONS, ...NON_RFC3_VERSIONS]) { + const error = assertThrows( + () => validateStructural(repeatedNameMetadata(), undefined, version), + ValidationError, + ); + assertEquals(error.rule, SpecRule.AxisNamesUnique, String(version)); + } +}); diff --git a/ts/test/structural_validation_reader_test.ts b/ts/test/structural_validation_reader_test.ts index a620f9c8..dc0c5dfd 100644 --- a/ts/test/structural_validation_reader_test.ts +++ b/ts/test/structural_validation_reader_test.ts @@ -309,3 +309,121 @@ Deno.test( assertEquals(result.metadata.extra, {}); }, ); + +// --- The declared version drives the axis rules on the read path --- + +/** + * Build an in-memory store whose group attributes are the given `ome` block, + * with one zarr array per declared dataset path. + * + * This is the v0.6 / 0.9.dev1 layout: the multiscales sit under `ome`, and the + * axes under a coordinate system rather than a flat `axes` list. + */ +async function createOmeNamespacedStore( + ome: Record, + shape: number[], +): Promise { + const store: MemoryStore = new Map(); + const root = zarr.root(store); + await zarr.create(root, { attributes: { ome } }); + const entry = (ome.multiscales as Array>)[0]; + for (const dataset of entry.datasets as Array<{ path: string }>) { + await zarr.create(root.resolve(dataset.path), { + shape, + data_type: "uint8", + chunk_shape: shape, + fill_value: 0, + }); + } + return store; +} + +/** A single-level `ome` block over `axes`, tagged with the given version. */ +function omeBlock( + version: string, + axes: Array<{ name: string; type: string }>, +): Record { + const rank = axes.length; + return { + version, + multiscales: [{ + name: "image", + coordinateSystems: [{ name: "intrinsic", axes }], + datasets: [{ + path: "0", + coordinateTransformations: [{ + input: { path: "0" }, + output: { name: "intrinsic" }, + name: "scale0_to_intrinsic", + type: "sequence", + transformations: [ + { type: "scale", scale: Array(rank).fill(1.0) }, + { type: "translation", translation: Array(rank).fill(0.0) }, + ], + }], + }], + }], + }; +} + +const SIX_SPACE_AXES = ["a", "b", "c", "d", "e", "f"].map((name) => ({ + name, + type: "space", +})); + +Deno.test( + "reader - a 0.9.dev1 store with six axes passes strict validation", + async () => { + // The axis rules are inert at 0.9.dev1, so this store is legal. Reading it + // under `validate: true` only works if the declared version reaches + // validateStructural: without it every store is held to the v0.4 caps, and + // a document this port's own writer emits is refused on the way back in. + const store = await createOmeNamespacedStore( + omeBlock("0.9.dev1", SIX_SPACE_AXES), + [2, 2, 2, 2, 2, 2], + ); + + const result = await fromOmeZarr(store, { validate: true }); + assertEquals( + result.images[0].dims, + ["a", "b", "c", "d", "e", "f"], + ); + }, +); + +Deno.test( + "reader - the same six-axis store tagged 0.6 is refused", + async () => { + // The negative control for the test above: the version is what relaxes the + // rules, not the read path going soft on every store. + const store = await createOmeNamespacedStore( + omeBlock("0.6.dev4", SIX_SPACE_AXES), + [2, 2, 2, 2, 2, 2], + ); + + const error = await assertRejects( + () => fromOmeZarr(store, { validate: true }), + Error, + ); + assertStringIncludes(error.message, SpecRule.AxisCount); + }, +); + +Deno.test( + "reader - a v0.6 array coordinate system needs no space axis", + async () => { + // The v0.6 axes schema is a `oneOf`: 2 or 3 `space` axes, *or* two or more + // `array` axes. The reader must not hold the second arm to the space-axis + // floor, or a store this port's own writer accepts at 0.6 cannot be read. + const store = await createOmeNamespacedStore( + omeBlock("0.6.dev4", [ + { name: "i", type: "array" }, + { name: "j", type: "array" }, + ]), + [4, 4], + ); + + const result = await fromOmeZarr(store, { validate: true }); + assertEquals(result.images[0].dims, ["i", "j"]); + }, +); diff --git a/ts/test/structural_validation_test.ts b/ts/test/structural_validation_test.ts index 547ad685..b5a620a7 100644 --- a/ts/test/structural_validation_test.ts +++ b/ts/test/structural_validation_test.ts @@ -22,6 +22,7 @@ import { assertEquals, assertInstanceOf, assertThrows } from "@std/assert"; import { SpecRule, validateAxisCount, + validateAxisNamesUnique, validateAxisOrder, validateAxisType, validateDatasetOrder, @@ -197,6 +198,24 @@ Deno.test("validateSpatialAxisOrder - rejects bad suffix and too many", () => { ); }); +Deno.test("validateAxisNamesUnique - accepts distinct names", () => { + validateAxisNamesUnique(buildValidMetadata()); +}); + +Deno.test("validateAxisNamesUnique - rejects a repeated name", () => { + const repeated = buildValidMetadata(); + repeated.axes = [ + { name: "c", type: "channel", unit: undefined }, + { name: "y", type: "space", unit: undefined }, + { name: "y", type: "space", unit: undefined }, + ]; + assertRuleViolation( + () => validateAxisNamesUnique(repeated), + SpecRule.AxisNamesUnique, + "multiscales[0].axes[2]", + ); +}); + Deno.test("validatePerDatasetScaleCount - accepts one scale per level", () => { validatePerDatasetScaleCount(buildValidMetadata()); }); @@ -436,3 +455,98 @@ Deno.test("validateStructural - option defaults: strict, allowUnknownFields true "multiscales[0].datasets[0].coordinateTransformations[0]", ); }); + +Deno.test("axis rules are inert at 0.9.dev1 but enforced below it", () => { + // Six axes exceed the 2..5 range every released version imposes. + const sixAxes = buildValidMetadata(); + sixAxes.axes = ["a", "b", "c", "d", "e", "f"].map((name) => ({ + name, + type: "space" as const, + unit: undefined, + })); + + assertRuleViolation( + () => validateAxisCount(sixAxes), + SpecRule.AxisCount, + "multiscales[0].axes", + ); + for (const version of ["0.4", "0.5", "0.6"]) { + assertRuleViolation( + () => validateAxisCount(sixAxes, version), + SpecRule.AxisCount, + "multiscales[0].axes", + ); + } + // RFC-3 lifts the restriction, and only at 0.9.dev1. + validateAxisCount(sixAxes, "0.9.dev1"); + validateAxisType(sixAxes, "0.9.dev1"); + validateAxisOrder(sixAxes, "0.9.dev1"); + validateSpatialAxisOrder(sixAxes, "0.9.dev1"); +}); + +Deno.test("axis names must stay unique at 0.9.dev1", () => { + // RFC-3 *adds* "axis names MUST NOT be repeated" (rule 5), so unlike the + // other axis rules this one is never inert. + const repeated = buildValidMetadata(); + repeated.axes = [ + { name: "c", type: "channel", unit: undefined }, + { name: "y", type: "space", unit: undefined }, + { name: "y", type: "space", unit: undefined }, + ]; + assertRuleViolation( + () => validateAxisNamesUnique(repeated, "0.9.dev1"), + SpecRule.AxisNamesUnique, + "multiscales[0].axes[2]", + ); +}); + +Deno.test("fewer than two space axes is rejected below 0.9.dev1", () => { + // The v0.4 and v0.5 axes schemas state minContains: 2 on `space` axes. + // v0.6 adds an `array` arm, covered separately below. + const oneSpace = buildValidMetadata(); + oneSpace.axes = [ + { name: "c", type: "channel", unit: undefined }, + { name: "x", type: "space", unit: undefined }, + ]; + assertRuleViolation( + () => validateSpatialAxisOrder(oneSpace, "0.4"), + SpecRule.AxisOrder, + "multiscales[0].axes", + ); + validateSpatialAxisOrder(oneSpace, "0.9.dev1"); +}); + +Deno.test("a v0.6 array coordinate system needs no space axis", () => { + // The v0.6 axes schema is a `oneOf`: 2-3 `space` axes, *or* two or more + // `array` axes. The space-axis floor must not fire on the second arm, or a + // store the bundled schema accepts becomes unwritable at v0.6. + const arrayCs = buildValidMetadata(); + arrayCs.axes = [ + { name: "i0", type: "array", unit: undefined }, + { name: "i1", type: "array", unit: undefined }, + { name: "i2", type: "array", unit: undefined }, + ]; + validateSpatialAxisOrder(arrayCs, "0.6"); + validateSpatialAxisOrder(arrayCs, "0.6.dev4"); + + // v0.4 and v0.5 have no `array` arm, so the floor applies unconditionally. + for (const version of ["0.4", "0.5"]) { + assertRuleViolation( + () => validateSpatialAxisOrder(arrayCs, version), + SpecRule.AxisOrder, + "multiscales[0].axes", + ); + } + + // A single `array` axis does not reach the schema's minContains: 2. + const oneArray = buildValidMetadata(); + oneArray.axes = [ + { name: "i0", type: "array", unit: undefined }, + { name: "c", type: "channel", unit: undefined }, + ]; + assertRuleViolation( + () => validateSpatialAxisOrder(oneArray, "0.6"), + SpecRule.AxisOrder, + "multiscales[0].axes", + ); +}); diff --git a/ts/test/to_multiscales_itkwasm_test.ts b/ts/test/to_multiscales_itkwasm_test.ts index 2cade1e1..5351d854 100644 --- a/ts/test/to_multiscales_itkwasm_test.ts +++ b/ts/test/to_multiscales_itkwasm_test.ts @@ -57,7 +57,11 @@ Deno.test("downsample zycx", async () => { }); const store: MemoryStore = new Map(); - await toNgffZarr(store, multiscales); + // These dims are not the spec order (time, channel, space), which the + // writer requires below 0.9.dev1. The test is about downsampling with + // the channel axis in that position, so write at the version whose + // axis model allows it. + await toNgffZarr(store, multiscales, { version: "0.9.dev1" }); // The non-canonical input is normalized to (c, z, y, x). Mirrors // py/test/test_to_ngff_zarr_itkwasm.py::test_downsample_zycx. @@ -134,7 +138,11 @@ Deno.test("downsample tzycx", async () => { }); const store: MemoryStore = new Map(); - await toNgffZarr(store, multiscales); + // These dims are not the spec order (time, channel, space), which the + // writer requires below 0.9.dev1. The test is about downsampling with + // the channel axis in that position, so write at the version whose + // axis model allows it. + await toNgffZarr(store, multiscales, { version: "0.9.dev1" }); // The non-canonical input is normalized to (t, c, z, y, x). Mirrors // py/test/test_to_ngff_zarr_itkwasm.py::test_downsample_tzycx. From a022dca968a7f7fc13886e374e92df58d5f44ba5 Mon Sep 17 00:00:00 2001 From: Valentin Boussot Date: Tue, 25 Aug 2026 12:30:35 +0200 Subject: [PATCH 3/7] fix(py,ts): gate the axes each version reads and writes A store is validated against the version it declares rather than the version the caller asked for, and the reader reports the version it read. The writer refuses axes a target version cannot express, with one message naming the axis and the version, instead of writing metadata that version's schema rejects. --- py/ngff_zarr/from_ngff_zarr.py | 20 ++- py/ngff_zarr/to_ngff_zarr.py | 103 +++++++++++- ts/src/io/from_ngff_zarr-browser.ts | 6 +- ts/src/io/from_ngff_zarr.ts | 15 +- ts/src/io/to_ngff_zarr-browser.ts | 2 +- ts/src/io/to_ngff_zarr.ts | 2 +- ts/src/io/to_ngff_zarr_ozx_common.ts | 119 +++++++++++++- ts/test/write_gate_test.ts | 231 +++++++++++++++++++++++++++ 8 files changed, 483 insertions(+), 15 deletions(-) create mode 100644 ts/test/write_gate_test.ts diff --git a/py/ngff_zarr/from_ngff_zarr.py b/py/ngff_zarr/from_ngff_zarr.py index 3e369058..f47cfb62 100644 --- a/py/ngff_zarr/from_ngff_zarr.py +++ b/py/ngff_zarr/from_ngff_zarr.py @@ -358,7 +358,19 @@ def from_ome_zarr( "within the plate (e.g., 'plate.zarr/A/1/0' for well A1, field 0)." ) - if version.startswith("0.6"): + if version == "0.9.dev1": + from .v09.zarr_metadata import Metadata + + # No 0.9.dev1 JSON Schema is published; `validate` is forwarded so + # validate=True reports that rather than validating nothing. + metadata_obj, images = Metadata._from_zarr_attrs( + root_attrs, store, validate=validate, subpath=subpath + ) + method, method_type, method_metadata = _extract_method_metadata( + root_attrs["ome"]["multiscales"][0] + ) + + elif version.startswith("0.6"): from .v06.zarr_metadata import Metadata metadata_obj, images = Metadata._from_zarr_attrs( @@ -416,7 +428,11 @@ def from_ome_zarr( root_attrs["multiscales"][0] ) - metadata_obj = metadata_obj.to_version("0.6") + # Normalize to the richest model the store can express. A 0.9.dev1 store + # stays 0.9.dev1; downgrading it to 0.6 would discard its RFC-3 axis model. + metadata_obj = metadata_obj.to_version( + "0.9.dev1" if version == "0.9.dev1" else "0.6" + ) metadata_obj.type = method_type metadata_obj.metadata = method_metadata diff --git a/py/ngff_zarr/to_ngff_zarr.py b/py/ngff_zarr/to_ngff_zarr.py index 3ca07f72..8827a29f 100644 --- a/py/ngff_zarr/to_ngff_zarr.py +++ b/py/ngff_zarr/to_ngff_zarr.py @@ -4,7 +4,7 @@ import shutil import tempfile import warnings -from dataclasses import asdict +from dataclasses import asdict, dataclass from pathlib import Path, PurePosixPath from typing import Any, Literal @@ -256,7 +256,12 @@ def _validate_ngff_parameters( if isinstance(version, str): version = NgffVersion(version) - if version not in [NgffVersion.V04, NgffVersion.V05, NgffVersion.V06]: + if version not in [ + NgffVersion.V04, + NgffVersion.V05, + NgffVersion.V06, + NgffVersion.V09dev1, + ]: raise ValueError(f"Unsupported version: {version}") if chunks_per_shard is not None and version == NgffVersion.V04: @@ -287,6 +292,99 @@ def _gate_top_level_transforms(metadata, version: str) -> None: ) +@dataclass(frozen=True) +class _AxisView: + """Minimal stand-in exposing only ``axes``. + + The axis rules in :mod:`ngff_zarr.structural_validation` read nothing else + off the metadata. A v0.6 ``Metadata`` has no ``axes`` attribute, only + ``coordinateSystems``. + """ + + axes: list + + +def _axis_views(metadata) -> list[tuple[str, _AxisView]]: + """Every axis list ``metadata`` will serialize, paired with its location. + + v0.4/v0.5 metadata carry a flat ``axes``; v0.6 and 0.9.dev1 carry + ``coordinateSystems``, and ``coordinate_systems.schema`` applies + ``axes.schema`` to each one, so every system is returned. + + ``coordinateSystems`` is checked first: v0.9.dev1 also exposes an ``axes`` + property, which returns only the intrinsic system's axes. + """ + systems = list(getattr(metadata, "coordinateSystems", None) or []) + if systems: + return [ + (f"multiscales[0].coordinateSystems[{i}].axes", _AxisView(list(cs.axes))) + for i, cs in enumerate(systems) + ] + axes = getattr(metadata, "axes", None) + if axes is not None: + return [("multiscales[0].axes", _AxisView(list(axes)))] + return [] + + +def _gate_axis_model(metadata, version) -> None: + """Refuse to serialize an axis model the target ``version`` cannot express. + + Reuses the axis rules of :mod:`ngff_zarr.structural_validation`, but this + is a second dispatcher, not the same pass as + :func:`ngff_zarr.validate_structural`, and it differs from it two ways: + + 1. It runs only the five axis rules, not the full image cascade. + 2. It checks **every** coordinate system (see :func:`_axis_views`), while + ``validate_structural`` reads a single flat ``axes`` list. + + Called after ``to_version``: the converted object is what ``asdict`` + serializes, and for a 0.4/0.5 target it exposes the axes that survive the + downgrade. The corollary is that this cannot refuse what the downgrade has + already dropped -- ``Metadata._to_v05`` keeps only the coordinate system + the datasets reference -- so a model refused at 0.6 may be accepted at 0.4 + with the other systems silently discarded. + """ + from .structural_validation import ( + SpecRule, + ValidationError, + validate_axis_count, + validate_axis_names_unique, + validate_axis_order, + validate_axis_type, + validate_spatial_axis_order, + ) + + rules = ( + validate_axis_count, + validate_axis_type, + validate_axis_order, + validate_spatial_axis_order, + validate_axis_names_unique, + ) + for location, view in _axis_views(metadata): + for rule in rules: + try: + rule(view, version) + except ValidationError as exc: + rendered = ", ".join( + f"{ax.name!r}(type={ax.type!r})" for ax in view.axes + ) + if exc.rule is SpecRule.AXIS_NAMES_UNIQUE: + # Required at every version, 0.9.dev1 included. + raise ValueError( + f'Cannot write OME-Zarr version="{version}": {exc.message} ' + f"Axes at {location}: [{rendered}]." + ) from exc + raise ValueError( + f'Cannot write OME-Zarr version="{version}": this axis model ' + f"violates that version's [{exc.rule.value}] rule. " + f"{exc.message} Axes at {location}: [{rendered}]. " + f'Pass version="{NgffVersion.V09dev1.value}" to write it: ' + "0.9.dev1 is the only OME-Zarr version that adopts RFC-3 " + "(arbitrary axis count, names, types and ordering)." + ) from exc + + def _prepare_metadata( multiscales: NgffMultiscales, version: str ) -> tuple[Metadata_v04 | Metadata_v05, tuple[str, ...], dict]: @@ -301,6 +399,7 @@ def _prepare_metadata( method_metadata = get_method_metadata(multiscales.method) metadata = metadata.to_version(version) + _gate_axis_model(metadata, version) metadata.type = method_type metadata.metadata = method_metadata diff --git a/ts/src/io/from_ngff_zarr-browser.ts b/ts/src/io/from_ngff_zarr-browser.ts index 3abf6055..2d3cbf66 100644 --- a/ts/src/io/from_ngff_zarr-browser.ts +++ b/ts/src/io/from_ngff_zarr-browser.ts @@ -7,7 +7,7 @@ import * as zarr from "zarrita"; import { MetadataSchema } from "../schemas/zarr_metadata.ts"; import { NgffMultiscales } from "../types/multiscales.ts"; import { NgffImage } from "../types/ngff_image.ts"; -import type { Units } from "../types/units.ts"; +import type { AxisUnit } from "../types/units.ts"; import type { Metadata, Omero } from "../types/zarr_metadata.ts"; import { extractMethodMetadata } from "../utils/parse_metadata.ts"; import { fromZarrAttrsV06 } from "../utils/from_zarr_attrs.ts"; @@ -19,7 +19,7 @@ export interface FromOmeZarrOptions { /** Enable schema validation of OME-Zarr metadata. */ validate?: boolean; /** Expected OME-Zarr version. */ - version?: "0.4" | "0.5" | "0.6"; + version?: "0.4" | "0.5" | "0.6" | "0.9.dev1"; /** * Optional decoded-chunk cache passed to `zarrGet` calls. * @@ -278,7 +278,7 @@ export async function fromOmeZarr( } return acc; }, - {} as Record, + {} as Record, ); const ngffImage = new NgffImage({ diff --git a/ts/src/io/from_ngff_zarr.ts b/ts/src/io/from_ngff_zarr.ts index 922ffa40..ed0a7c70 100644 --- a/ts/src/io/from_ngff_zarr.ts +++ b/ts/src/io/from_ngff_zarr.ts @@ -21,7 +21,7 @@ export interface FromOmeZarrOptions { /** Enable schema validation of OME-Zarr metadata. */ validate?: boolean; /** Expected OME-Zarr version. */ - version?: "0.4" | "0.5" | "0.6"; + version?: "0.4" | "0.5" | "0.6" | "0.9.dev1"; /** * Optional decoded-chunk cache passed to `zarrGet` calls. * @@ -141,10 +141,19 @@ export async function fromOmeZarr( } // Parse metadata using version-specific function. The v0.6 reader handles - // both `0.6` and the pre-release on-disk version strings. + // both `0.6` and the pre-release on-disk version strings, and `0.9.dev1`, + // which uses the same coordinate-system layout. let result; - if (isV06Version(detectedVersion)) { + if ( + isV06Version(detectedVersion) || detectedVersion === NgffVersion.V09dev1 + ) { result = await fromZarrAttrsV06(rootAttrs, resolvedStore, validate); + // The v0.6 parser records `0.6`, which is right for the whole 0.6 + // family but not for a 0.9.dev1 store: that string is the on-disk + // version, and callers inspect `metadata.version` to tell them apart. + if (detectedVersion === NgffVersion.V09dev1) { + result.metadata.version = NgffVersion.V09dev1; + } } else if (detectedVersion === NgffVersion.V05) { result = await fromZarrAttrsV05(rootAttrs, resolvedStore, validate); } else { diff --git a/ts/src/io/to_ngff_zarr-browser.ts b/ts/src/io/to_ngff_zarr-browser.ts index cb8855ce..c0e07830 100644 --- a/ts/src/io/to_ngff_zarr-browser.ts +++ b/ts/src/io/to_ngff_zarr-browser.ts @@ -20,7 +20,7 @@ export { isOzxPath } from "./rfc9_zip.ts"; export interface ToOmeZarrOptions { overwrite?: boolean; - version?: "0.4" | "0.5" | "0.6"; + version?: "0.4" | "0.5" | "0.6" | "0.9.dev1"; chunksPerShard?: number | number[] | Record; /** * Custom codec pipeline for array compression. When omitted the default diff --git a/ts/src/io/to_ngff_zarr.ts b/ts/src/io/to_ngff_zarr.ts index 7921ecda..cfc0a6ad 100644 --- a/ts/src/io/to_ngff_zarr.ts +++ b/ts/src/io/to_ngff_zarr.ts @@ -23,7 +23,7 @@ export interface ToOmeZarrOptions { * it defaults to 0.5. If explicitly set to any other value for .ozx files, * an error will be thrown. */ - version?: "0.4" | "0.5" | "0.6"; + version?: "0.4" | "0.5" | "0.6" | "0.9.dev1"; chunksPerShard?: number | number[] | Record; /** * Custom codec pipeline for array compression. When omitted the default diff --git a/ts/src/io/to_ngff_zarr_ozx_common.ts b/ts/src/io/to_ngff_zarr_ozx_common.ts index 7bd0aaa7..56fda731 100644 --- a/ts/src/io/to_ngff_zarr_ozx_common.ts +++ b/ts/src/io/to_ngff_zarr_ozx_common.ts @@ -12,12 +12,111 @@ import * as zarr from "zarrita"; import type { NgffMultiscales } from "../types/multiscales.ts"; import type { NgffImage } from "../types/ngff_image.ts"; import type { Axis, MetadataInterface } from "../types/zarr_metadata.ts"; +import type { MemoryStore } from "./rfc9_zip.ts"; import { buildV06MultiscalesEntry, legacyTopLevelTransforms, } from "../utils/v06_metadata.ts"; -import { V06_ONDISK_VERSION } from "../types/supported_versions.ts"; -import type { MemoryStore } from "./rfc9_zip.ts"; +import { + NgffVersion, + V06_ONDISK_VERSION, +} from "../types/supported_versions.ts"; +import { + SpecRule, + validateAxisCount, + validateAxisNamesUnique, + validateAxisOrder, + validateAxisType, + validateSpatialAxisOrder, + ValidationError, +} from "../utils/structural_validation.ts"; +import { pyRepr, pyReprOptional } from "../utils/py_format.ts"; + +/** + * Every axis list `metadata` will serialize at `version`, paired with its + * location. + * + * Only the v0.6 family writes `coordinateSystems`, and + * `coordinate_systems.schema` applies `axes.schema` to each one, so every + * system is returned there. A v0.4/v0.5 target writes a single flat `axes` and + * drops the systems, so that is the only list to gate: refusing a system the + * downgrade discards would reject a document the writer can emit, and name a + * node absent from it. Python reaches the same set by gating after + * `Metadata.to_version`. + */ +function axisViews( + metadata: MetadataInterface, + version: string, +): Array<{ location: string; axes: Axis[] }> { + const systems = metadata.coordinateSystems ?? []; + const serializesSystems = version === "0.6" || + version === NgffVersion.V09dev1; + if (serializesSystems && systems.length > 0) { + // `buildV06MultiscalesEntry` serializes the first system from + // `metadata.axes` and the later ones verbatim, and `MetadataInterface` + // does not tie `axes` to `coordinateSystems[0].axes`. Gate what is + // written, not what is declared. + return systems.map((system, index) => ({ + location: `multiscales[0].coordinateSystems[${index}].axes`, + axes: index === 0 ? metadata.axes : system.axes, + })); + } + return [{ location: "multiscales[0].axes", axes: metadata.axes }]; +} + +/** + * Refuse to serialize an axis model the target `version` cannot express. + * + * Reuses the axis rules of the structural pass, but this is a second + * dispatcher, not the same pass as {@link validateStructural}, and it differs + * from it two ways: + * + * 1. It runs only the five axis rules, not the full image cascade. + * 2. Where the target version serializes coordinate systems, it checks *every* + * one of them (see {@link axisViews}), while the structural pass reduces the + * metadata to the intrinsic system's axes. + */ +function gateAxisModel( + metadata: MetadataInterface, + version: string, +): void { + const rules = [ + validateAxisCount, + validateAxisType, + validateAxisOrder, + validateSpatialAxisOrder, + validateAxisNamesUnique, + ]; + for (const view of axisViews(metadata, version)) { + for (const rule of rules) { + try { + rule({ axes: view.axes }, version); + } catch (error) { + if (!(error instanceof ValidationError)) { + throw error; + } + const rendered = view.axes + .map((ax) => `${pyRepr(ax.name)}(type=${pyReprOptional(ax.type)})`) + .join(", "); + if (error.rule === SpecRule.AxisNamesUnique) { + // Required at every version, 0.9.dev1 included. + throw new Error( + `Cannot write OME-Zarr version="${version}": ${error.detail} ` + + `Axes at ${view.location}: [${rendered}].`, + ); + } + throw new Error( + `Cannot write OME-Zarr version="${version}": this axis model violates ` + + `that version's [${error.rule}] rule. ${error.detail} ` + + `Axes at ${view.location}: [${rendered}]. ` + + `Pass version="${NgffVersion.V09dev1}" to write it: 0.9.dev1 is the ` + + `only OME-Zarr version that adopts RFC-3 (arbitrary axis count, ` + + `names, types and ordering).`, + ); + } + } + } +} /** * Process axes for serialization. @@ -72,11 +171,25 @@ export function processAxes( */ export function buildRootAttributes( metadata: MetadataInterface, - version: "0.4" | "0.5" | "0.6", + version: "0.4" | "0.5" | "0.6" | "0.9.dev1", ): Record { + gateAxisModel(metadata, version); + // Process axes (orientation included when present). const processedAxes = processAxes(metadata.axes); + if (version === "0.9.dev1") { + // "0.9.dev1" is already the on-disk string. + const v09Entry = buildV06MultiscalesEntry(metadata, processedAxes); + return { + ome: { + version: NgffVersion.V09dev1, + multiscales: [v09Entry], + ...(metadata.omero && { omero: metadata.omero }), + }, + }; + } + if (version === "0.6") { const v06Entry = buildV06MultiscalesEntry(metadata, processedAxes); return { diff --git a/ts/test/write_gate_test.ts b/ts/test/write_gate_test.ts new file mode 100644 index 00000000..2addd352 --- /dev/null +++ b/ts/test/write_gate_test.ts @@ -0,0 +1,231 @@ +// SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC +// SPDX-License-Identifier: MIT +/** + * Tests for the RFC-3 write gate in `buildRootAttributes`, the single function + * behind every TypeScript writer. + * + * An RFC-3 axis model can only be serialized at OME-Zarr 0.9.dev1; targeting + * 0.4, 0.5 or 0.6 throws. Mirrors `py/test/test_rfc3_axes.py`. + */ + +import { assertEquals, assertStringIncludes, assertThrows } from "@std/assert"; +import { buildRootAttributes } from "../src/io/to_ngff_zarr_ozx_common.ts"; +import { + type Axis, + createScale, + type MetadataInterface, +} from "../src/types/zarr_metadata.ts"; + +type TargetVersion = "0.4" | "0.5" | "0.6" | "0.9.dev1"; + +const PRE_RFC3: TargetVersion[] = ["0.4", "0.5", "0.6"]; + +/** Multiscales metadata carrying `axes`, with one matching scale per level. */ +function buildMetadata(axes: Axis[]): MetadataInterface { + return { + axes, + datasets: [ + { + path: "0", + coordinateTransformations: [createScale(axes.map(() => 1.0))], + }, + ], + coordinateTransformations: undefined, + omero: undefined, + name: "image", + version: "0.4", + }; +} + +function space(name: string): Axis { + return { name, type: "space", unit: undefined }; +} + +/** One entry per RFC-3 sample-dataset shape, with the rule each one trips. */ +const RFC3_SHAPES: Array<[label: string, axes: Axis[]]> = [ + ["ramp_6d", ["a", "b", "c", "d", "e", "f"].map(space)], + ["ecg_1d", [{ name: "t", type: "time", unit: undefined }]], + ["astronaut_xcy", [ + space("x"), + { name: "c", type: "channel", unit: undefined }, + space("y"), + ]], +]; + +Deno.test("write gate - refuses RFC-3 axis models below 0.9.dev1", () => { + for (const [label, axes] of RFC3_SHAPES) { + for (const version of PRE_RFC3) { + const error = assertThrows( + () => buildRootAttributes(buildMetadata(axes), version), + Error, + undefined, + `${label} should be refused at ${version}`, + ); + assertStringIncludes(error.message, "Cannot write OME-Zarr"); + assertStringIncludes(error.message, "0.9.dev1"); + } + } +}); + +Deno.test("write gate - accepts RFC-3 axis models at 0.9.dev1", () => { + for (const [, axes] of RFC3_SHAPES) { + const attrs = buildRootAttributes(buildMetadata(axes), "0.9.dev1") as { + ome: { version: string }; + }; + assertEquals(attrs.ome.version, "0.9.dev1"); + } +}); + +Deno.test("write gate - conventional axes still write at every version", () => { + const axes = ["z", "y", "x"].map(space); + for (const version of [...PRE_RFC3, "0.9.dev1"] as TargetVersion[]) { + buildRootAttributes(buildMetadata(axes), version); + } +}); + +Deno.test("write gate - a non-canonical class order is refused below 0.9.dev1", () => { + // Axis order is a spec MUST: the writer refuses what validateStructural + // rejects, rather than reporting it and writing anyway. + const channelLast = buildMetadata([ + { name: "z", type: "space", unit: undefined }, + { name: "y", type: "space", unit: undefined }, + { name: "x", type: "space", unit: undefined }, + { name: "c", type: "channel", unit: undefined }, + ]); + for (const version of PRE_RFC3) { + const error = assertThrows( + () => buildRootAttributes(channelLast, version), + Error, + ); + assertStringIncludes(error.message, "axis-order"); + } + // RFC-3 lifts the ordering rule, so the same model writes at 0.9.dev1. + buildRootAttributes(channelLast, "0.9.dev1"); +}); + +Deno.test("write gate - repeated axis names are refused at every version", () => { + // RFC-3 *adds* the unique-name rule rather than lifting one, so 0.9.dev1 + // is not a way out of this one. + const axes = [space("y"), space("y"), space("x")]; + for (const version of [...PRE_RFC3, "0.9.dev1"] as TargetVersion[]) { + const error = assertThrows( + () => buildRootAttributes(buildMetadata(axes), version), + Error, + ); + assertStringIncludes(error.message, "Cannot write OME-Zarr"); + } +}); + +Deno.test("the gate reads the axes the writer serializes", () => { + // `buildV06MultiscalesEntry` writes the first coordinate system from + // `metadata.axes`; `coordinateSystems[0].axes` is not tied to it, so a gate + // reading the declared list would miss what actually lands on disk. + const metadata = buildMetadata([ + { name: "z", type: "space", unit: undefined }, + { name: "z", type: "space", unit: undefined }, + { name: "x", type: "space", unit: undefined }, + ]); + metadata.coordinateSystems = [ + { + name: "intrinsic", + axes: [ + { name: "z", type: "space", unit: undefined }, + { name: "y", type: "space", unit: undefined }, + { name: "x", type: "space", unit: undefined }, + ], + }, + ]; + assertThrows( + () => buildRootAttributes(metadata, "0.9.dev1"), + Error, + "axis names must be unique", + ); +}); + +Deno.test("the gate skips coordinate systems the target version drops", () => { + // v0.4 and v0.5 serialize the flat `axes` and drop `coordinateSystems`, so a + // system the downgrade discards must not block the write. Mirrors Python, + // which gates after `Metadata.to_version`. + const metadata = buildMetadata([space("z"), space("y"), space("x")]); + metadata.coordinateSystems = [ + { name: "intrinsic", axes: [space("z"), space("y"), space("x")] }, + { name: "extra", axes: ["a", "b", "c", "d", "e", "f"].map(space) }, + ]; + + for (const version of ["0.4", "0.5"] as TargetVersion[]) { + buildRootAttributes(metadata, version); + } + // 0.6 writes both systems, so there the six-axis one is refused. + const error = assertThrows( + () => buildRootAttributes(metadata, "0.6"), + Error, + ); + assertStringIncludes( + error.message, + "multiscales[0].coordinateSystems[1].axes", + ); +}); + +// The gate's message bytes are part of the cross-language contract: this +// literal is pinned identically in `py/test/test_rfc3_axes.py`. +const CANONICAL_GATE_MESSAGE_REPEATED_NAME = + "Cannot write OME-Zarr version=\"0.9.dev1\": Axis name 'z' is repeated; " + + "axis names must be unique within a dataset. Axes at multiscales[0].axes: " + + "['z'(type='space'), 'z'(type='space'), 'x'(type='space')]."; + +const CANONICAL_GATE_MESSAGE_AXIS_COUNT = + 'Cannot write OME-Zarr version="0.4": this axis model violates that ' + + "version's [axis-count] rule. OME-Zarr v0.4, v0.5 and v0.6 require between " + + "2 and 5 axes, inclusive; found 6. Axes at multiscales[0].axes: " + + "['a'(type='space'), 'b'(type='space'), 'c'(type='space'), " + + "'d'(type='space'), 'e'(type='space'), 'f'(type='space')]. Pass " + + 'version="0.9.dev1" to write it: 0.9.dev1 is the only OME-Zarr version ' + + "that adopts RFC-3 (arbitrary axis count, names, types and ordering)."; + +Deno.test("write gate - message bytes match the Python port", () => { + const repeated = assertThrows( + () => + buildRootAttributes( + buildMetadata([space("z"), space("z"), space("x")]), + "0.9.dev1", + ), + Error, + ); + assertEquals(repeated.message, CANONICAL_GATE_MESSAGE_REPEATED_NAME); + + const sixAxis = assertThrows( + () => + buildRootAttributes( + buildMetadata(["a", "b", "c", "d", "e", "f"].map(space)), + "0.4", + ), + Error, + ); + assertEquals(sixAxis.message, CANONICAL_GATE_MESSAGE_AXIS_COUNT); +}); + +Deno.test("0.9.dev1 round-trip goes through the v0.6 reader", async () => { + // `isV06Version` does not cover 0.9.dev1, so a store tagged 0.9.dev1 would + // otherwise fall through to the v0.4 reader and fail on its coordinate + // systems. + const { fromNgffZarr, toNgffZarr } = await import("../src/mod.ts"); + + const testStorePath = new URL( + "../../py/test/data/input/v04/6001240.zarr", + import.meta.url, + ); + const resolvedPath = testStorePath.pathname.replace(/^\/([A-Za-z]:)/, "$1"); + const source = await fromNgffZarr(resolvedPath, { version: "0.4" }); + + const store: Map = new Map(); + await toNgffZarr(store, source, { version: "0.9.dev1" }); + + const roundTripped = await fromNgffZarr(store, { version: "0.9.dev1" }); + assertEquals( + roundTripped.metadata.axes.map((ax) => ax.name), + source.metadata.axes.map((ax) => ax.name), + ); + // The v0.6 parser records `0.6`; a 0.9.dev1 store must report its own + // version, which is how a caller tells the two models apart. + assertEquals(roundTripped.metadata.version, "0.9.dev1"); +}); From c256ea3af4816a09c9bcfdf17fccf3403187d184 Mon Sep 17 00:00:00 2001 From: Valentin Boussot Date: Tue, 25 Aug 2026 12:30:48 +0200 Subject: [PATCH 4/7] feat(py,ts): offer 0.9.dev1 as an upgrade target and document it `ngff-zarr upgrade --to 0.9.dev1` converts a store to the RFC-3 version, in both ports. The guides describe what the version lifts and that reaching it is opt-in. --- README.md | 4 +++- docs/cli.md | 6 ++++-- docs/python.md | 8 +++++--- docs/spec_features.md | 5 +++++ docs/typescript.md | 14 ++++++++------ py/ngff_zarr/cli.py | 6 ++++-- py/ngff_zarr/upgrade_ome_zarr.py | 17 ++++++++++++----- py/test/test_cli_upgrade.py | 28 +++++++++++++++++++++++++++- ts/src/io/upgrade_ome_zarr_common.ts | 25 ++++++++++++++++++++----- 9 files changed, 88 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index f113f74c..a721feec 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,8 @@ The main Python package provides: - Optional OME-Zarr data model validation during reading - Writes OME-Zarr v0.4 to v0.6 - v0.6 adds RFC-5 coordinate systems and transformations +- Reads and writes the opt-in development version `0.9.dev1`, which adopts RFC-3 + (expanded axis count, names, types and order) - [Sharded Zarr] stores - Optional writing via zarr-python 2, zarr-python 3, [zarrista] or zarrita (TypeScript) - [Anatomical orientation metadata](./docs/rfc4.md) (RFC-4) @@ -67,7 +69,7 @@ The TypeScript package provides universal OME-Zarr support for modern JavaScript - 🦕 **Deno-first** with first-class TypeScript support - 📦 **Universal compatibility** - Works in Deno, Node.js, and browsers - 🔍 **Type-safe** with Zod schema validation -- 🗂️ **OME-Zarr v0.4, v0.5, and v0.6** support (v0.6 adds RFC-5 coordinate systems and transformations) +- 🗂️ **OME-Zarr v0.4, v0.5, and v0.6** support (v0.6 adds RFC-5 coordinate systems and transformations), plus the opt-in development version `0.9.dev1` which adopts RFC-3 (expanded axis count, names, types and order) - 🌐 **Web ready** - No filesystem dependencies, works with remote stores - 🏗️ **Mirrors Python API** - Familiar interfaces for Python users - 📚 **Lazy loading** - Efficient handling of large datasets diff --git a/docs/cli.md b/docs/cli.md index a8bed89b..b0134bfc 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -196,8 +196,10 @@ ngff-zarr upgrade src.zarr -o dst.zarr --to 0.5 ``` The target version is selected with `--to` (alias `--version`), one of `0.4`, -`0.5`, or `0.6` (default `0.6`). Add `--validate` to validate the source -metadata against the NGFF schema while reading. For the write-to-new-store mode, +`0.5`, `0.6`, or `0.9.dev1` (default `0.6`). `0.9.dev1` is the development +version that adopts RFC-3; OME publishes no JSON Schema for it yet, so +`--validate` cannot check a store at that version. Add `--validate` to validate +the source metadata against the NGFF schema while reading. For the write-to-new-store mode, `--overwrite` (the default) replaces any pre-existing data at the output store, while `--no-overwrite` refuses to; both flags are ignored for an in-place upgrade, which never overwrites array data. diff --git a/docs/python.md b/docs/python.md index f2615c1c..d68523ae 100644 --- a/docs/python.md +++ b/docs/python.md @@ -213,7 +213,7 @@ To read an OME-Zarr file, use [`from_ngff_zarr`], which returns the >>> multiscales = nz.from_ngff_zarr('cthead1.ome.zarr') ``` -OME-Zarr version 0.1 to 0.6 is supported. Version 0.6 adds RFC-5 coordinate systems and transformations. +OME-Zarr version 0.1 to 0.6 is supported. Version 0.6 adds RFC-5 coordinate systems and transformations. The opt-in development version `0.9.dev1` additionally adopts RFC-3, that expands supported axis counts, names, types and order. The `store` argument accepts: @@ -551,8 +551,10 @@ boundary (0.5/0.6 to 0.4) -- cannot preserve chunk keys and raises a **Write-to-new-store.** When an `output` store distinct from `input` is given, the source is read lazily and re-written to `output` at the requested version -through the standard write pipeline. Every supported transition (0.4, 0.5, 0.6, -in either direction) works in this mode, and the source store is never erased. +through the standard write pipeline. Every transition among 0.4, 0.5, 0.6 and +0.9.dev1 works in this mode whenever the target version can express the axis +model, and the source store is never erased. An RFC-3 axis model is refused +below 0.9.dev1, so a store using one only converts upward. Upgrade a 0.5 store to 0.6 in place, keeping every array chunk: diff --git a/docs/spec_features.md b/docs/spec_features.md index 5aebbad5..a453f580 100644 --- a/docs/spec_features.md +++ b/docs/spec_features.md @@ -38,6 +38,11 @@ supported by `ngff-zarr`. - **OME-Zarr v0.4 to v0.6**: Writes OME-Zarr versions 0.4 to 0.6, including RFC-4 anatomical orientation. v0.6 additionally adds RFC-5 coordinate systems and transformations. +- **OME-Zarr 0.9.dev1**: Reads and writes the development version that adopts + RFC-3, which extends support for the number, names, types and order + of axes. It is opt-in: pass `version="0.9.dev1"` explicitly. The default + target is unchanged, and OME publishes no JSON Schema for it yet, so schema + validation is unavailable at that version. ## High Content Screening (HCS) diff --git a/docs/typescript.md b/docs/typescript.md index b4310b53..43336ac5 100644 --- a/docs/typescript.md +++ b/docs/typescript.md @@ -9,7 +9,7 @@ NGFF-Zarr provides a TypeScript implementation for working with OME-Zarr data st - 🦕 **Deno-first**: Built for Deno with first-class TypeScript support - 📦 **Universal compatibility**: Works in Deno, Node.js, and browsers - 🔍 **Type-safe**: Full TypeScript support with Zod schema validation -- 🗂️ **OME-Zarr support**: Read and write OME-Zarr v0.4, v0.5, and v0.6 (v0.6 adds RFC-5 coordinate systems and transformations) +- 🗂️ **OME-Zarr support**: Read and write OME-Zarr v0.4, v0.5, and v0.6 (v0.6 adds RFC-5 coordinate systems and transformations), plus the opt-in development version `0.9.dev1` which adopts RFC-3 (extended axis count, names, types and order) - 🧪 **Well-tested**: Comprehensive test suite with browser validation - 🏗️ **Mirrors Python API**: Familiar interfaces for Python users - 📖 **Lazy loading**: Efficient handling of large datasets @@ -378,7 +378,7 @@ async function fromNgffZarr( store: string | MemoryStore | FetchStore, options?: { validate?: boolean; - version?: "0.4" | "0.5" | "0.6"; + version?: "0.4" | "0.5" | "0.6" | "0.9.dev1"; } ): Promise ``` @@ -414,7 +414,7 @@ async function toNgffZarr( store: string, multiscales: NgffMultiscales, options?: { - version?: "0.4" | "0.5" | "0.6"; + version?: "0.4" | "0.5" | "0.6" | "0.9.dev1"; chunksPerShard?: number | number[] | Record; } ): Promise @@ -815,7 +815,7 @@ function upgradeOmeZarr( input: string | MemoryStore | FetchStore | Readable, options?: { output?: string | MemoryStore; // FetchStore is read-only, not a destination - version?: "0.4" | "0.5" | "0.6"; // default "0.6" + version?: "0.4" | "0.5" | "0.6" | "0.9.dev1"; // default "0.6" validate?: boolean; overwrite?: boolean; // write-to-new-store only; default true }, @@ -870,8 +870,10 @@ await upgradeOmeZarr(store, { version: "0.6" }); **Write-to-new-store.** When `output` is a store distinct from `input`, the source is read lazily and re-written to `output` at the requested version -through the standard write pipeline. Every supported transition (0.4, 0.5, 0.6, -in either direction) works in this mode, and the source store is never mutated. +through the standard write pipeline. Every transition among 0.4, 0.5, 0.6 and +0.9.dev1 works in this mode whenever the target version can express the axis +model, and the source store is never mutated. An RFC-3 axis model is refused +below 0.9.dev1, so a store using one only converts upward. ```typescript import { upgradeOmeZarr, type MemoryStore } from "@fideus-labs/ngff-zarr"; diff --git a/py/ngff_zarr/cli.py b/py/ngff_zarr/cli.py index 16f841e2..79c74b22 100755 --- a/py/ngff_zarr/cli.py +++ b/py/ngff_zarr/cli.py @@ -1260,9 +1260,11 @@ def _upgrade_main(argv: list[str] | None = None) -> None: "--to", "--version", dest="version", - choices=["0.4", "0.5", "0.6"], + choices=["0.4", "0.5", "0.6", "0.9.dev1"], default="0.6", - help="Target OME-Zarr version (default: 0.6).", + help="Target OME-Zarr version (default: 0.6). 0.9.dev1 is the " + "development version that adopts RFC-3; it is opt-in and has no " + "published JSON Schema, so --validate cannot check it.", ) parser.add_argument( "--validate", diff --git a/py/ngff_zarr/upgrade_ome_zarr.py b/py/ngff_zarr/upgrade_ome_zarr.py index 2a14f8bd..618d2246 100644 --- a/py/ngff_zarr/upgrade_ome_zarr.py +++ b/py/ngff_zarr/upgrade_ome_zarr.py @@ -70,6 +70,7 @@ from_ome_zarr, ) from .to_ngff_zarr import ( + _gate_axis_model, _pop_metadata_optionals, _root_ome_attrs, to_ome_zarr, @@ -80,11 +81,13 @@ def _normalize_target_version(version: str | NgffVersion) -> str: - """Return the API version string (``"0.4"``/``"0.5"``/``"0.6"``). + """Return the API version string (``"0.4"``/``"0.5"``/``"0.6"``/``"0.9.dev1"``). Both the API alias ``"0.6"`` and the on-disk pre-release strings of the 0.6 family normalize to ``"0.6"`` so the value can be handed to ``Metadata.to_version`` (which only knows the three released versions). + ``"0.9.dev1"`` needs no such collapse: unlike v0.6 it is itself the + on-disk string. """ nv = NgffVersion(version) if nv in (NgffVersion.V06, NgffVersion.V06dev4, NgffVersion.V06rc0): @@ -197,13 +200,13 @@ def _validate_target_version(target_version: str, requested: str | NgffVersion) ``NgffVersion`` also admits the pre-0.4 drafts (0.1--0.3) that live in ``SUPPORTED_VERSIONS`` for read compatibility, but the metadata - ``to_version`` chains only convert among 0.4/0.5/0.6. Fail early with an - actionable message rather than deep in a conversion. + ``to_version`` chains only convert among 0.4/0.5/0.6 and 0.9.dev1. Fail + early with an actionable message rather than deep in a conversion. """ - if target_version not in ("0.4", "0.5", "0.6"): + if target_version not in ("0.4", "0.5", "0.6", "0.9.dev1"): raise ValueError( f"Unsupported target version {requested!r}. upgrade_ome_zarr() can " - "upgrade to OME-Zarr 0.4, 0.5, or 0.6." + "upgrade to OME-Zarr 0.4, 0.5, 0.6, or 0.9.dev1." ) @@ -252,6 +255,7 @@ def _upgrade_in_place( # deliberately avoided: it resets ``type``/``metadata`` to ``None`` for any # store whose method type is not a recognized ``Methods`` value. new_metadata = multiscales.metadata.to_version(target_version) + _gate_axis_model(new_metadata, target_version) metadata_dict = asdict(new_metadata) metadata_dict = _pop_metadata_optionals(metadata_dict) metadata_dict["@type"] = "ngff:Image" @@ -531,6 +535,9 @@ def upgrade_ome_zarr( # Cross-format upgrade (0.4 -> 0.5/0.6): rewrite array + group # metadata to Zarr v3 while preserving every chunk file on disk. new_metadata = multiscales.metadata.to_version(target_version) + # _rewrite_v2_group_to_v3 deletes the v2 sidecars and creates v3 + # arrays; gate before it so a refusal leaves the store intact. + _gate_axis_model(new_metadata, target_version) _rewrite_v2_group_to_v3(input, new_metadata, target_version) else: # Cross-format downgrade (0.5/0.6 -> 0.4) in place: Zarr v3 default diff --git a/py/test/test_cli_upgrade.py b/py/test/test_cli_upgrade.py index 729d3041..8cf7c93b 100644 --- a/py/test/test_cli_upgrade.py +++ b/py/test/test_cli_upgrade.py @@ -28,7 +28,13 @@ ) # The on-disk ``ome.version`` string each API version is written as. -DISK_VERSION = {"0.4": "0.4", "0.5": "0.5", "0.6": "0.6rc0"} +DISK_VERSION = { + "0.4": "0.4", + "0.5": "0.5", + "0.6": "0.6rc0", + # Unlike 0.6 this is itself the on-disk string. + "0.9.dev1": "0.9.dev1", +} # Metadata sidecars across Zarr v2 and v3, excluded when isolating chunk data. _METADATA_NAMES = {"zarr.json", ".zarray", ".zattrs", ".zgroup", ".zmetadata"} @@ -138,6 +144,26 @@ def test_write_to_new_store_0_4_to_0_5(tmp_path): assert _all_files_digest(src) == source_before +def test_in_place_upgrade_0_6_to_0_9dev1(tmp_path): + """``--to 0.9.dev1`` is a real CLI target, not just a library one. + + ``upgrade_ome_zarr`` accepts 0.9.dev1, so the CLI in front of it must + offer the same set or the RFC-3 target is reachable from Python only. + """ + store = tmp_path / "image.ome.zarr" + _write_source(str(store), "0.6") + chunks_before = _chunk_files(store) + + result = _run_ngff_zarr("upgrade", str(store), "--to", "0.9.dev1") + assert result.returncode == 0, f"stdout: {result.stdout}\nstderr: {result.stderr}" + + root = zarr.open_group(str(store), mode="r", zarr_format=3) + assert root.attrs["ome"]["version"] == DISK_VERSION["0.9.dev1"] + + # Same Zarr format on both sides, so this stays metadata-only. + assert _chunk_files(store) == chunks_before + + def test_in_place_cross_format_downgrade_exits_nonzero(tmp_path): """0.6 -> 0.4 in place is rejected with guidance and a non-zero exit code.""" store = tmp_path / "image.ome.zarr" diff --git a/ts/src/io/upgrade_ome_zarr_common.ts b/ts/src/io/upgrade_ome_zarr_common.ts index efb9b356..fcff84aa 100644 --- a/ts/src/io/upgrade_ome_zarr_common.ts +++ b/ts/src/io/upgrade_ome_zarr_common.ts @@ -40,7 +40,7 @@ export interface UpgradeOmeZarrOptions { */ output?: string | MemoryStore; /** Target OME-Zarr specification version. Defaults to `"0.6"`. */ - version?: "0.4" | "0.5" | "0.6"; + version?: "0.4" | "0.5" | "0.6" | "0.9.dev1"; /** Validate the source metadata against the NGFF schema while reading. */ validate?: boolean; /** @@ -55,12 +55,18 @@ export interface UpgradeOmeZarrOptions { export interface UpgradeOmeZarrDeps { fromOmeZarr: ( store: UpgradeInput, - options?: { validate?: boolean; version?: "0.4" | "0.5" | "0.6" }, + options?: { + validate?: boolean; + version?: "0.4" | "0.5" | "0.6" | "0.9.dev1"; + }, ) => Promise; toOmeZarr: ( store: string | MemoryStore | zarr.FetchStore, multiscales: NgffMultiscales, - options?: { overwrite?: boolean; version?: "0.4" | "0.5" | "0.6" }, + options?: { + overwrite?: boolean; + version?: "0.4" | "0.5" | "0.6" | "0.9.dev1"; + }, ) => Promise; /** * Resolve `input` to a writable store for an in-place rewrite. Must reject a @@ -95,8 +101,17 @@ function onDiskVersion(rootAttrs: Record): string | undefined { return typeof version === "string" ? version : undefined; } -/** The `ome.version` string a store written at `version` carries. */ -function onDiskVersionFor(version: "0.4" | "0.5" | "0.6"): string { +/** + * The `ome.version` string a store written at `version` carries. + * + * Only `0.6` differs from what the caller passes: it is tagged with the + * pre-release the bundled schemas carry. `0.9.dev1` is itself the on-disk + * string and is returned unchanged, matching the Python port's + * `_ondisk_version_for`. + */ +function onDiskVersionFor( + version: "0.4" | "0.5" | "0.6" | "0.9.dev1", +): string { return version === "0.6" ? V06_ONDISK_VERSION : version; } From 3393a2dda5761f4675095c0d89aa4c1a0dcfecfa Mon Sep 17 00:00:00 2001 From: Valentin Boussot Date: Tue, 25 Aug 2026 16:30:50 +0200 Subject: [PATCH 5/7] fix(py,ts): carry the v0.6 transform rules over to 0.9.dev1 The browser build dispatched on isV06Version alone, which matches 0.6 and its pre-release tags but not 0.9.dev1. A store this package's own 0.9 writer produced therefore fell through to the v0.4/v0.5 parser, which expects a flat axes entry and cannot read a coordinateSystems document. The node reader already dispatches on both and restores 0.9.dev1 on the returned metadata, since the v0.6 delegate records 0.6; the browser reader now does the same. The writer's gate on multiscale-level transforms fired only at 0.6, so a 0.9.dev1 store could be written with a transform naming no input or output coordinate system, which the validating reader then refuses. 0.9.dev1 is the 0.6 model with the axis restrictions relaxed, so the same requirement holds and the gate covers it. --- py/ngff_zarr/to_ngff_zarr.py | 7 ++++++- ts/src/io/from_ngff_zarr-browser.ts | 27 ++++++++++++++++++++++----- ts/test/write_gate_test.ts | 26 ++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 6 deletions(-) diff --git a/py/ngff_zarr/to_ngff_zarr.py b/py/ngff_zarr/to_ngff_zarr.py index 8827a29f..820fc1de 100644 --- a/py/ngff_zarr/to_ngff_zarr.py +++ b/py/ngff_zarr/to_ngff_zarr.py @@ -277,8 +277,13 @@ def _gate_top_level_transforms(metadata, version: str) -> None: coordinate systems, and the schema requires both ``input`` and ``output`` to name one. The writer serializes whatever the model holds, so a missing reference would produce a store the validated reader rejects. + + 0.9.dev1 is the 0.6 model with the axis restrictions relaxed, so its + inter-system transforms carry the same requirement and the gate covers it. """ - if version != "0.6" or not metadata.coordinateTransformations: + if version not in ("0.6", NgffVersion.V09dev1.value): + return + if not metadata.coordinateTransformations: return for index, transform in enumerate(metadata.coordinateTransformations): for side in ("input", "output"): diff --git a/ts/src/io/from_ngff_zarr-browser.ts b/ts/src/io/from_ngff_zarr-browser.ts index 2d3cbf66..1c4c30c7 100644 --- a/ts/src/io/from_ngff_zarr-browser.ts +++ b/ts/src/io/from_ngff_zarr-browser.ts @@ -11,7 +11,7 @@ import type { AxisUnit } from "../types/units.ts"; import type { Metadata, Omero } from "../types/zarr_metadata.ts"; import { extractMethodMetadata } from "../utils/parse_metadata.ts"; import { fromZarrAttrsV06 } from "../utils/from_zarr_attrs.ts"; -import { isV06Version } from "../types/supported_versions.ts"; +import { isV06Version, NgffVersion } from "../types/supported_versions.ts"; export type { ChunkCache } from "../utils/worker_pool.ts"; @@ -98,17 +98,28 @@ export async function fromOmeZarr( const omeForVersion = rootAttrsForVersion.ome as | Record | undefined; + const onDiskVersion = + omeForVersion && typeof omeForVersion.version === "string" + ? omeForVersion.version + : undefined; + // 0.9.dev1 carries the same coordinateSystems layout as v0.6, so it reads + // through the same delegate. Without this the store falls through to the + // v0.4/v0.5 parser below, which expects a flat `axes` entry and cannot read + // what this package's own 0.9 writer produced. if ( - omeForVersion && typeof omeForVersion.version === "string" && - isV06Version(omeForVersion.version) + omeForVersion && onDiskVersion !== undefined && + (isV06Version(onDiskVersion) || onDiskVersion === NgffVersion.V09dev1) ) { // Gate the requested-version mismatch behind `validate`, matching the node // reader and the v0.4/v0.5 path below; otherwise behavior diverges by // version and environment. The v0.6 family (`0.6` and its pre-release // tags) is treated as equivalent. - if (validate && version && !isV06Version(version)) { + const versionsMatch = version === undefined || + version === onDiskVersion || + (isV06Version(onDiskVersion) && isV06Version(version)); + if (validate && !versionsMatch) { throw new Error( - `Expected OME-Zarr version ${version}, but found ${omeForVersion.version}`, + `Expected OME-Zarr version ${version}, but found ${onDiskVersion}`, ); } const result = await fromZarrAttrsV06( @@ -116,6 +127,12 @@ export async function fromOmeZarr( resolvedStore, validate, ); + // The v0.6 delegate records `0.6`, which is right for the whole v0.6 + // family but not for a 0.9.dev1 store: that string is the on-disk + // version, and callers read `metadata.version` to tell them apart. + if (onDiskVersion === NgffVersion.V09dev1) { + result.metadata.version = NgffVersion.V09dev1; + } const entry = (omeForVersion.multiscales as unknown[])[0] as Record< string, unknown diff --git a/ts/test/write_gate_test.ts b/ts/test/write_gate_test.ts index 2addd352..30a16936 100644 --- a/ts/test/write_gate_test.ts +++ b/ts/test/write_gate_test.ts @@ -204,6 +204,32 @@ Deno.test("write gate - message bytes match the Python port", () => { assertEquals(sixAxis.message, CANONICAL_GATE_MESSAGE_AXIS_COUNT); }); +Deno.test("the browser reader routes a 0.9.dev1 store through the v0.6 reader", async () => { + // The browser build dispatched on `isV06Version` alone, which does not cover + // 0.9.dev1, so a store this package's own 0.9 writer produced fell through to + // the v0.4/v0.5 parser and failed on its absent flat `axes`. + const { toNgffZarr } = await import("../src/mod.ts"); + const { fromOmeZarr } = await import("../src/io/from_ngff_zarr-browser.ts"); + const { fromNgffZarr } = await import("../src/mod.ts"); + + const testStorePath = new URL( + "../../py/test/data/input/v04/6001240.zarr", + import.meta.url, + ); + const resolvedPath = testStorePath.pathname.replace(/^\/([A-Za-z]:)/, "$1"); + const source = await fromNgffZarr(resolvedPath, { version: "0.4" }); + + const store: Map = new Map(); + await toNgffZarr(store, source, { version: "0.9.dev1" }); + + const read = await fromOmeZarr(store, { version: "0.9.dev1" }); + assertEquals( + read.metadata.axes.map((ax) => ax.name), + source.metadata.axes.map((ax) => ax.name), + ); + assertEquals(read.metadata.version, "0.9.dev1"); +}); + Deno.test("0.9.dev1 round-trip goes through the v0.6 reader", async () => { // `isV06Version` does not cover 0.9.dev1, so a store tagged 0.9.dev1 would // otherwise fall through to the v0.4 reader and fail on its coordinate From ed4aee4d8d0f9893d879686a6ec6a633450164ee Mon Sep 17 00:00:00 2001 From: Valentin Boussot Date: Tue, 25 Aug 2026 16:49:32 +0200 Subject: [PATCH 6/7] fix(py,ts): stop applying the v0.6 mapAxis arity to a 0.9.dev1 store The model held every mapAxis to 2 to 5 indices. That mirrors the minItems and maxItems the 0.4 through 0.6 schemas set, and follows from their five-axis cap, but 0.9.dev1 declares neither: RFC-3 lifts the cap, and the retreat is systematic across its schemas, which also drop maxItems and maxContains on axes and the per-index maximum of 4. A permutation over six axes is therefore a valid 0.9.dev1 document that schema validation accepts and the parser refused, whatever validate was set to. The bound moves from __post_init__, which has no version to consult, to validate, and the declared version is threaded from the reader through _parse_transforms. The permutation and integer rules stay at construction: every version states them identically. --- py/ngff_zarr/v06/zarr_metadata.py | 105 +++++++++++++----- py/test/test_coordinate_transformations.py | 45 +++++++- ts/src/utils/from_zarr_attrs.ts | 2 + ts/src/utils/v06_metadata.ts | 21 +++- .../v06_coordinate_transformations_test.ts | 21 ++++ 5 files changed, 158 insertions(+), 36 deletions(-) diff --git a/py/ngff_zarr/v06/zarr_metadata.py b/py/ngff_zarr/v06/zarr_metadata.py index e405b210..f62cf4b3 100644 --- a/py/ngff_zarr/v06/zarr_metadata.py +++ b/py/ngff_zarr/v06/zarr_metadata.py @@ -121,7 +121,9 @@ def from_dict(cls, data: dict) -> "BaseTransform": return cls(**data) def validate( # noqa: B027 - self, coordinateSystems: list[CoordinateSystem] | None = None + self, + coordinateSystems: list[CoordinateSystem] | None = None, + version: object | None = None, ) -> None: """Check this transform's RFC-5 constraints. @@ -133,6 +135,9 @@ def validate( # noqa: B027 and are skipped otherwise. Raises ``ValueError`` on the first violation. + ``version`` selects the bounds a version-dependent rule applies; a + rule that holds at every version ignores it. + Transform types without constraints beyond their field types, such as ``Scale`` or ``Identity``, inherit this no-op deliberately. """ @@ -198,21 +203,35 @@ def __post_init__(self) -> None: self._check_intrinsic() def _check_intrinsic(self) -> None: + """The permutation rules, which every version states identically.""" indices = self.mapAxis _require_integer_axes(indices, "mapAxis") - if not (2 <= len(indices) <= 5): - raise ValueError( - "mapAxis must hold between 2 and 5 indices, one per axis " - f"of the coordinate systems it permutes; got {indices}" - ) if sorted(indices) != list(range(len(indices))): raise ValueError( "mapAxis must be a permutation holding every zero-based " f"input axis index exactly once; got {indices}" ) - def validate(self, coordinateSystems: list[CoordinateSystem] | None = None) -> None: + def validate( + self, + coordinateSystems: list[CoordinateSystem] | None = None, + version: object | None = None, + ) -> None: + from ..structural_validation import is_rfc3_axis_model_allowed + self._check_intrinsic() + # The 2-to-5 arity mirrors the minItems and maxItems the 0.4 through + # 0.6 schemas set, and follows from their five-axis cap. RFC-3 lifts + # that cap at 0.9.dev1, whose mapAxis definition sets neither bound, so + # a permutation over six axes is valid there and the check stands down. + # It cannot live in __post_init__, which has no version to consult. + if not is_rfc3_axis_model_allowed(version) and not ( + 2 <= len(self.mapAxis) <= 5 + ): + raise ValueError( + "mapAxis must hold between 2 and 5 indices, one per axis " + f"of the coordinate systems it permutes; got {self.mapAxis}" + ) for identifier in (self.input, self.output): count = _resolved_axis_count(identifier, coordinateSystems) if count is not None and count != len(self.mapAxis): @@ -346,10 +365,16 @@ def produced_outputAxes(self) -> set[int]: axes.update(item.outputAxes) return axes - def validate(self, coordinateSystems: list[CoordinateSystem] | None = None) -> None: + def validate( + self, + coordinateSystems: list[CoordinateSystem] | None = None, + version: object | None = None, + ) -> None: for item in self.transformations: item._check_intrinsic() - item.transformation.validate(coordinateSystems) + # A wrapper passes the version down: a mapAxis nested here obeys + # the bounds of the document's version, not of v0.6. + item.transformation.validate(coordinateSystems, version) self._check_intrinsic() input_count = _resolved_axis_count(self.input, coordinateSystems) if input_count is not None: @@ -391,9 +416,14 @@ class Bijection(BaseTransform): inverse: Transform type: str = "bijection" - def validate(self, coordinateSystems: list[CoordinateSystem] | None = None) -> None: - self.forward.validate(coordinateSystems) - self.inverse.validate(coordinateSystems) + def validate( + self, + coordinateSystems: list[CoordinateSystem] | None = None, + version: object | None = None, + ) -> None: + # A wrapper passes the version down, as byDimension does. + self.forward.validate(coordinateSystems, version) + self.inverse.validate(coordinateSystems, version) input_count = _resolved_axis_count(self.input, coordinateSystems) output_count = _resolved_axis_count(self.output, coordinateSystems) if ( @@ -423,14 +453,19 @@ class TransformSequence(BaseTransform): name: str | None = "transformSequence" type: str = "sequence" - def validate(self, coordinateSystems: list[CoordinateSystem] | None = None) -> None: + def validate( + self, + coordinateSystems: list[CoordinateSystem] | None = None, + version: object | None = None, + ) -> None: for transformation in self.transformations: - transformation.validate(coordinateSystems) + transformation.validate(coordinateSystems, version) def validate_transform( transformation: Transform, coordinateSystems: list[CoordinateSystem] | None = None, + version: object | None = None, ) -> None: """Check a transform's RFC-5 constraints; see ``BaseTransform.validate``. @@ -709,16 +744,19 @@ def _from_zarr_attrs( "Invalid OME-Zarr metadata: missing 'ome' or 'multiscales' field." ) + # From 0.6 the version is recorded on the ``ome`` namespace rather than + # on each multiscales entry, as the pre-release string the store was + # written with. The per-entry value is the fallback, as on the v0.4 read + # path. Read unconditionally: the transform rules whose bounds RFC-3 + # lifts need it whether or not the schema pass runs. + declared_version = str( + root_attrs["ome"].get("version") + or root_attrs["ome"]["multiscales"][0].get("version") + or "0.6" + ) + if validate: - # From 0.6 the version is recorded on the ``ome`` namespace rather - # than on each multiscales entry, as the pre-release string the - # store was written with. The per-entry value is the fallback, as - # on the v0.4 read path. - schema_version = str( - root_attrs["ome"].get("version") - or root_attrs["ome"]["multiscales"][0].get("version") - or "0.6" - ) + schema_version = declared_version schema_attrs = root_attrs if schema_version in V06_SUPERSEDED_TAGS: # The bundled 0.6 schemas accept one tag, the pre-release they @@ -794,7 +832,9 @@ def _from_zarr_attrs( ] if "coordinateTransformations" in dataset: coordinateTransformations = cls._parse_transforms( - dataset["coordinateTransformations"], coordinate_systems + dataset["coordinateTransformations"], + coordinate_systems, + declared_version, ) # extract scale and translation for ngff_image convenience @@ -846,7 +886,7 @@ def _from_zarr_attrs( additionalTransformations = root_attrs.get("coordinateTransformations", None) if additionalTransformations is not None: additionalTransformations = cls._parse_transforms( - additionalTransformations, coordinate_systems + additionalTransformations, coordinate_systems, declared_version ) metadata = cls( @@ -861,10 +901,15 @@ def _from_zarr_attrs( @classmethod def _parse_transforms( - cls, transforms: list[dict], coordinateSystems: list[CoordinateSystem] + cls, + transforms: list[dict], + coordinateSystems: list[CoordinateSystem], + version: object | None = None, ) -> list[Transform]: - """ - Parse a list of possibly nested transformation dictionaries into Transform instances. + """Parse transformation dictionaries, nested ones included. + + ``version`` selects the bounds a version-dependent rule applies, such + as the ``mapAxis`` arity RFC-3 lifts at 0.9.dev1. """ parsed_transforms = [] for transform in transforms: @@ -892,7 +937,7 @@ def _parse_transforms( elif transform["type"] == "sequence": # TODO: Undo nested sequences on import? sub_transforms = cls._parse_transforms( - transform["transformations"], coordinateSystems + transform["transformations"], coordinateSystems, version ) transformation = TransformSequence(transformations=sub_transforms) else: @@ -928,7 +973,7 @@ def _parse_transforms( # every type. if transform.get("name") is not None: transformation.name = transform["name"] - validate_transform(transformation, coordinateSystems) + validate_transform(transformation, coordinateSystems, version) parsed_transforms.append(transformation) return parsed_transforms diff --git a/py/test/test_coordinate_transformations.py b/py/test/test_coordinate_transformations.py index bf9d39ed..9c817729 100644 --- a/py/test/test_coordinate_transformations.py +++ b/py/test/test_coordinate_transformations.py @@ -275,11 +275,50 @@ def test_map_axis_must_be_a_permutation(): MapAxis(mapAxis=[0, 1, 3]) -def test_map_axis_arity_is_bounded(): - """OME-Zarr coordinate systems hold 2 to 5 axes; mapAxis matches.""" +def test_map_axis_arity_is_bounded_below_0_9_dev1(): + """Through 0.6 a coordinate system holds 2 to 5 axes; mapAxis matches. + + The bound is checked at validation rather than at construction: it mirrors + the minItems and maxItems those schemas set, and ``__post_init__`` has no + version to consult. + """ for indices in ([0], [], [5, 4, 3, 2, 1, 0]): with pytest.raises(ValueError, match="between 2 and 5"): - MapAxis(mapAxis=indices) + MapAxis(mapAxis=indices).validate() + + +def test_map_axis_arity_is_unbounded_at_0_9_dev1(): + """RFC-3 lifts the five-axis cap, and the 0.9.dev1 mapAxis sets no bound. + + Its schema declares neither minItems nor maxItems, so a permutation over + six axes is a valid 0.9.dev1 document and the model must read it. + """ + MapAxis(mapAxis=[5, 4, 3, 2, 1, 0]).validate(version="0.9.dev1") + MapAxis(mapAxis=[0]).validate(version="0.9.dev1") + + +def test_a_nested_map_axis_follows_the_document_version(): + """A wrapper carries the version to what it holds. + + Without that, a six-axis permutation inside a sequence or a bijection + still met the v0.6 arity even in a 0.9.dev1 document. + """ + six = MapAxis(mapAxis=[5, 4, 3, 2, 1, 0]) + + TransformSequence(transformations=[six]).validate(version="0.9.dev1") + Bijection(forward=six, inverse=six).validate(version="0.9.dev1") + + with pytest.raises(ValueError, match="between 2 and 5"): + TransformSequence(transformations=[six]).validate(version="0.6") + with pytest.raises(ValueError, match="between 2 and 5"): + Bijection(forward=six, inverse=six).validate(version="0.6") + + +def test_map_axis_is_a_permutation_at_every_version(): + """The permutation rule holds regardless of the version.""" + for ngff_version in (None, "0.6", "0.9.dev1"): + with pytest.raises(ValueError, match="permutation"): + MapAxis(mapAxis=[0, 0, 1]).validate(version=ngff_version) def test_axis_indices_must_be_integers(): diff --git a/ts/src/utils/from_zarr_attrs.ts b/ts/src/utils/from_zarr_attrs.ts index 90e00ad8..ed7f4574 100644 --- a/ts/src/utils/from_zarr_attrs.ts +++ b/ts/src/utils/from_zarr_attrs.ts @@ -708,6 +708,7 @@ export async function fromZarrAttrsV06( dataset.coordinateTransformations as Array>, coordinateSystemNames, coordinateSystems, + declaredVersion, ); const extracted = extractScaleTranslation(parsed, dims); scaleValues = extracted.scale; @@ -757,6 +758,7 @@ export async function fromZarrAttrsV06( entry.coordinateTransformations as Array>, coordinateSystemNames, coordinateSystems, + declaredVersion, ); } diff --git a/ts/src/utils/v06_metadata.ts b/ts/src/utils/v06_metadata.ts index f31967e5..57ab0676 100644 --- a/ts/src/utils/v06_metadata.ts +++ b/ts/src/utils/v06_metadata.ts @@ -11,6 +11,7 @@ * convert between the two, mirroring `py/ngff_zarr/v06/zarr_metadata.py`. */ +import { isRfc3AxisModelAllowed } from "../types/supported_versions.ts"; import type { ByDimensionItem, CoordinateSystem, @@ -202,9 +203,10 @@ export function parseV06Transforms( raw: Array>, coordinateSystemNames: string[], coordinateSystems?: CoordinateSystem[], + version?: string, ): V06Transform[] { return raw.map((entry) => - parseV06Transform(entry, coordinateSystemNames, coordinateSystems) + parseV06Transform(entry, coordinateSystemNames, coordinateSystems, version) ); } @@ -212,6 +214,7 @@ function parseV06Transform( entry: Record, coordinateSystemNames: string[], coordinateSystems?: CoordinateSystem[], + version?: string, ): V06Transform { const type = String(entry.type); let transform: V06Transform; @@ -288,6 +291,7 @@ function parseV06Transform( item.transformation as Record, coordinateSystemNames, coordinateSystems, + version, ), // ngff-zarr 0.29.0 wrote these two keys in snake_case; the spec // and the 0.6rc0 schema spell them inputAxes and outputAxes, which @@ -313,11 +317,13 @@ function parseV06Transform( entry.forward as Record, coordinateSystemNames, coordinateSystems, + version, ), inverse: parseV06Transform( entry.inverse as Record, coordinateSystemNames, coordinateSystems, + version, ), }; break; @@ -333,6 +339,7 @@ function parseV06Transform( entry.transformations as Array>, coordinateSystemNames, coordinateSystems, + version, ), }; break; @@ -352,7 +359,7 @@ function parseV06Transform( transform.name = entry.name; } - validateV06Transform(transform, coordinateSystems ?? []); + validateV06Transform(transform, coordinateSystems ?? [], version); return transform; } @@ -393,6 +400,7 @@ function itemDimensions(transformation: V06Transform): number | undefined { export function validateV06Transform( transform: V06Transform, coordinateSystems: CoordinateSystem[], + version?: string, ): void { if (transform.type === "mapAxis") { const indices = transform.mapAxis; @@ -401,7 +409,14 @@ export function validateV06Transform( `mapAxis axis indices must be integers; got [${indices}]`, ); } - if (indices.length < 2 || indices.length > 5) { + // The 2-to-5 arity mirrors the minItems and maxItems the 0.4 through 0.6 + // schemas set, and follows from their five-axis cap. RFC-3 lifts that cap + // at 0.9.dev1, whose mapAxis definition sets neither bound, so a + // permutation over six axes is valid there and the check stands down. + if ( + !isRfc3AxisModelAllowed(version) && + (indices.length < 2 || indices.length > 5) + ) { throw new Error( "mapAxis must hold between 2 and 5 indices, one per axis of the " + `coordinate systems it permutes; got [${indices}]`, diff --git a/ts/test/v06_coordinate_transformations_test.ts b/ts/test/v06_coordinate_transformations_test.ts index a638ff43..6f7e3b4b 100644 --- a/ts/test/v06_coordinate_transformations_test.ts +++ b/ts/test/v06_coordinate_transformations_test.ts @@ -1069,6 +1069,27 @@ Deno.test("validateV06Transform rejects fractional and out-of-range axes", () => // OME-Zarr coordinate systems hold 2 to 5 axes; a mapAxis of another length // is rejected on read as well as by the zod schema. +Deno.test("the mapAxis arity bound stands down at 0.9.dev1", () => { + // RFC-3 lifts the five-axis cap and the 0.9.dev1 mapAxis definition declares + // neither minItems nor maxItems, so a permutation over six axes is a valid + // 0.9.dev1 document. Mirrors test_map_axis_arity_is_unbounded_at_0_9_dev1. + validateV06Transform(createMapAxis([5, 4, 3, 2, 1, 0]), [], "0.9.dev1"); + validateV06Transform(createMapAxis([0]), [], "0.9.dev1"); + + // The permutation rule holds at every version. + assertThrows( + () => validateV06Transform(createMapAxis([0, 0, 1]), [], "0.9.dev1"), + Error, + "permutation", + ); + // And the arity still binds below 0.9.dev1. + assertThrows( + () => validateV06Transform(createMapAxis([5, 4, 3, 2, 1, 0]), [], "0.6"), + Error, + "between 2 and 5", + ); +}); + Deno.test("validateV06Transform bounds the mapAxis arity", () => { for (const indices of [[0], [], [5, 4, 3, 2, 1, 0]]) { assertThrows( From f2eb21a575591c8ec35ba20e9206a9d89e5639e8 Mon Sep 17 00:00:00 2001 From: Valentin Boussot Date: Tue, 25 Aug 2026 20:34:01 +0200 Subject: [PATCH 7/7] docs: name the axis rules the RFC-3 versions leave inert The page said the axis rules are inert for the versions that adopt the RFC-3 axis model. axis-names-unique is not among them: RFC-3 states it and no released schema carries it, so it applies at every version. Only the count, type and order rules stand down. --- docs/validation/api.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/validation/api.md b/docs/validation/api.md index 4733f151..dfe7e50a 100644 --- a/docs/validation/api.md +++ b/docs/validation/api.md @@ -44,9 +44,10 @@ from ngff_zarr import ( `validate_structural(metadata, options=None, version=None)` runs the image/multiscales rules. When `options` is `None` it uses `ValidateOptions()`, i.e. `ValidationLevel.STRICT`. `version` is the OME-Zarr version the metadata -declares; the axis rules are inert for the versions that adopt the RFC-3 axis -model (see [[parity]]), so omitting it holds every store to the v0.4 axis -caps. A `ValidationError` carries `.rule` (a `SpecRule`), +declares; the axis count, type and order rules are inert for the versions that +adopt the RFC-3 axis model (see [[parity]]), so omitting it holds every store to +the v0.4 axis caps. `axis-names-unique` is never inert: RFC-3 states it and no +released schema carries it. A `ValidationError` carries `.rule` (a `SpecRule`), `.message` (str), and `.location` (`str | None`); `str(exc)` is `Spec rule [] violated: `.