Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions py/ngff_zarr/parse_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,37 @@ def _parse_hcs_path(store_path: str) -> tuple[str, str | None]:
return store_path, None


def _raw_axes(multiscales_entry: dict) -> list:
"""The raw axis entries of one ``multiscales`` entry, at any version.

v0.4 and v0.5 carry a flat ``axes`` list on the entry. From v0.6 (RFC-5)
the axes live in a coordinate system, so they are read from
``coordinateSystems[0].axes``: the first entry is the intrinsic system, as
written by :func:`~ngff_zarr.to_ngff_zarr.to_ngff_zarr` and as the
TypeScript reader takes it.

Returns the list as found, without filtering: an axis may legally be a
dict, and a malformed entry is left for the caller to classify. Returns an
empty list when the entry carries neither shape.

Every caller that needs the axes of a raw metadata document goes through
here, so a version that moves them again is a one-line change rather than
a silently skipped check (the v0.6 move is what left the RFC-4 checks
unreachable in both the reader and the conformance report).
"""
if not isinstance(multiscales_entry, dict):
return []
axes = multiscales_entry.get("axes")
if isinstance(axes, list):
return axes
systems = multiscales_entry.get("coordinateSystems")
if isinstance(systems, list) and systems and isinstance(systems[0], dict):
intrinsic_axes = systems[0].get("axes")
if isinstance(intrinsic_axes, list):
return intrinsic_axes
Comment thread
vboussot marked this conversation as resolved.
return []


def _is_hcs_plate(root_attrs: dict) -> bool:
"""Check if root attributes indicate an HCS plate structure.

Expand Down
20 changes: 15 additions & 5 deletions py/ngff_zarr/rfc4_conformance.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from pathlib import Path
from typing import Any

from .parse_metadata import _raw_axes
from .rfc4_validation import _ANATOMICAL_AXIS_OF

# Metadata files that may hold the multiscales, most specific first.
Expand All @@ -45,20 +46,29 @@ def __init__(self, code: str, message: str) -> None:


def _axes_from_metadata(meta: Any) -> list[dict[str, Any]]:
"""Pull ``multiscales[0].axes`` out of a v2 ``.zattrs`` or v3 ``zarr.json`` blob."""
"""Pull the axes out of a v2 ``.zattrs`` or v3 ``zarr.json`` blob.

The axes are located by :func:`~ngff_zarr.parse_metadata._raw_axes`, so a
v0.6 document, whose axes live in the intrinsic coordinate system rather
than in a flat ``axes`` key, yields its axes instead of being classified
as not-OME-Zarr.
"""
if not isinstance(meta, dict):
raise _UnreadableInput("input-not-ome-zarr", "metadata is not a JSON object")
attributes = meta.get("attributes", meta)
ome = attributes.get("ome", attributes) if isinstance(attributes, dict) else {}
try:
axes = ome["multiscales"][0]["axes"]
entry = ome["multiscales"][0]
except (KeyError, IndexError, TypeError) as exc:
raise _UnreadableInput(
"input-not-ome-zarr", "metadata has no multiscales[0].axes"
"input-not-ome-zarr", "metadata has no multiscales[0]"
) from exc
if not isinstance(axes, list):
axes = _raw_axes(entry)
if not axes:
raise _UnreadableInput(
"input-not-ome-zarr", "multiscales[0].axes is not a list"
"input-not-ome-zarr",
"multiscales[0] carries no axes, in a flat 'axes' list or in "
"'coordinateSystems'",
)
return axes

Expand Down
23 changes: 11 additions & 12 deletions py/ngff_zarr/v04/zarr_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,7 @@ def _from_zarr_attrs(
import packaging.version

from ..ngff_image import NgffImage
from ..parse_metadata import _parse_omero
from ..parse_metadata import _parse_omero, _raw_axes
from ..rfc4_validation import (
has_rfc4_orientation_metadata,
validate_rfc4_orientation,
Expand Down Expand Up @@ -462,17 +462,16 @@ def _from_zarr_attrs(
else:
validate_ngff(root_attrs, version=schema_version)

# RFC 4 validation for anatomical orientation
if "axes" in root_attrs["multiscales"][0] and isinstance(
root_attrs["multiscales"][0]["axes"], list
):
# Type cast each axis item to dict for validation
axes_dicts = []
for axis in root_attrs["multiscales"][0]["axes"]:
if isinstance(axis, dict):
axes_dicts.append(axis)
if axes_dicts and has_rfc4_orientation_metadata(axes_dicts):
validate_rfc4_orientation(axes_dicts)
# RFC 4 validation for anatomical orientation. The axes are read
# through the shared helper, which knows where each version keeps
# them, and a non-dict axis entry is left to the schema check.
axes_dicts = [
axis
for axis in _raw_axes(root_attrs["multiscales"][0])
if isinstance(axis, dict)
]
if axes_dicts and has_rfc4_orientation_metadata(axes_dicts):
validate_rfc4_orientation(axes_dicts)

omero = _parse_omero(root_attrs.get("omero"))
# OME-Zarr v0.5 hoists the spec ``version`` to the group-level ``ome``
Expand Down
24 changes: 12 additions & 12 deletions py/ngff_zarr/v06/zarr_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -661,7 +661,7 @@ def _from_zarr_attrs(
import dask.array

from ..ngff_image import NgffImage
from ..parse_metadata import _parse_omero
from ..parse_metadata import _parse_omero, _raw_axes
from ..rfc4_validation import (
has_rfc4_orientation_metadata,
validate_rfc4_orientation,
Expand All @@ -680,17 +680,17 @@ def _from_zarr_attrs(
version=root_attrs["ome"]["multiscales"][0].get("version", "0.6"),
)

# RFC 4 validation for anatomical orientation
if "axes" in root_attrs["ome"]["multiscales"][0] and isinstance(
root_attrs["ome"]["multiscales"][0]["axes"], list
):
# Type cast each axis item to dict for validation
axes_dicts = []
for axis in root_attrs["ome"]["multiscales"][0]["axes"]:
if isinstance(axis, dict):
axes_dicts.append(axis)
if axes_dicts and has_rfc4_orientation_metadata(axes_dicts):
validate_rfc4_orientation(axes_dicts)
# RFC 4 validation for anatomical orientation. From v0.6 the axes
# live in the intrinsic coordinate system, so they are read through
# the shared helper rather than from a flat ``axes`` key, which a
# v0.6 entry does not carry.
axes_dicts = [
axis
for axis in _raw_axes(root_attrs["ome"]["multiscales"][0])
if isinstance(axis, dict)
]
if axes_dicts and has_rfc4_orientation_metadata(axes_dicts):
validate_rfc4_orientation(axes_dicts)
Comment thread
vboussot marked this conversation as resolved.
Outdated

omero = _parse_omero(root_attrs.get("ome", {}).get("omero"))
root_attrs = root_attrs["ome"]["multiscales"][0]
Expand Down
59 changes: 59 additions & 0 deletions py/test/test_cli_conformance.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,3 +287,62 @@ def test_conformance_cli_prints_json(tmp_path, monkeypatch, capsys):
report = json.loads(capsys.readouterr().out)
assert report["rfc4_valid"] is True
assert report["format"] == "ome-zarr"


def _write_zarr_v06(tmp_path: Path, axes: list[Axis]) -> str:
"""Write a v0.6-shaped ``zarr.json``: the axes live in the intrinsic system."""
metadata = {
"attributes": {
"ome": {
"version": "0.6",
"multiscales": [
{
"coordinateSystems": [{"name": "intrinsic", "axes": axes}],
"datasets": [{"path": "0"}],
}
],
}
}
}
(tmp_path / "zarr.json").write_text(json.dumps(metadata))
return str(tmp_path)


def test_conformance_report_reads_v06_coordinate_systems(tmp_path):
"""A v0.6 document is classified on its axes, not rejected as unreadable.

Before the axes were read through the shared helper, this shape carried no
flat ``axes`` key, so every v0.6 input -- valid or not -- came back as
``input-not-ome-zarr`` with an empty axis map.
"""
report = conformance_report(_write_zarr_v06(tmp_path, list(_LPS)))
assert report["rfc4_valid"] is True
assert report["violations"] == []
assert report["axes"] == {
"z": "inferior-to-superior",
"y": "anterior-to-posterior",
"x": "right-to-left",
}


def test_conformance_report_classifies_a_v06_violation(tmp_path):
"""A v0.6 violation is reported with its own code, not as unreadable."""
path = _write_zarr_v06(
tmp_path,
[
_space("y", "left-to-right"),
_space("x", "right-to-left"),
],
)
report = conformance_report(path)
assert report["rfc4_valid"] is False
assert report["violations"] == ["duplicate-anatomical-axis"]


def test_conformance_report_without_axes_anywhere(tmp_path):
"""An entry carrying neither shape stays an unreadable input."""
metadata = {"attributes": {"ome": {"multiscales": [{"datasets": []}]}}}
(tmp_path / "zarr.json").write_text(json.dumps(metadata))
report = conformance_report(str(tmp_path))
assert report["rfc4_valid"] is False
assert report["violations"] == ["input-not-ome-zarr"]
46 changes: 46 additions & 0 deletions py/test/test_parse_metadata_axes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC
# SPDX-License-Identifier: MIT
"""Tests for the single place that locates the axes in raw metadata.

``_raw_axes`` is what every reader and the conformance report use to find the
axes of a ``multiscales`` entry, so the version-dependent location is decided
once. The v0.6 case is the one that regressed: the axes moved into the
intrinsic coordinate system, and code that looked only for a flat ``axes`` key
silently found none.
"""

from ngff_zarr.parse_metadata import _raw_axes

_AXES = [{"name": "y", "type": "space"}, {"name": "x", "type": "space"}]


def test_flat_axes_are_returned():
assert _raw_axes({"axes": _AXES}) == _AXES


def test_v06_axes_come_from_the_intrinsic_coordinate_system():
entry = {"coordinateSystems": [{"name": "intrinsic", "axes": _AXES}]}
assert _raw_axes(entry) == _AXES


def test_a_flat_list_wins_over_coordinate_systems():
"""A document carrying both is read as the flat, pre-v0.6 shape."""
entry = {
"axes": _AXES,
"coordinateSystems": [{"name": "other", "axes": [{"name": "t"}]}],
}
assert _raw_axes(entry) == _AXES


def test_entries_without_axes_yield_an_empty_list():
assert _raw_axes({"datasets": [{"path": "0"}]}) == []
assert _raw_axes({"coordinateSystems": []}) == []
assert _raw_axes({"coordinateSystems": [{"name": "intrinsic"}]}) == []
assert _raw_axes({"axes": "not a list"}) == []
assert _raw_axes("not an entry") == []


def test_axis_entries_are_returned_verbatim():
"""Malformed entries are passed through for the caller to classify."""
entry = {"axes": ["not an object", {"name": 42}]}
assert _raw_axes(entry) == ["not an object", {"name": 42}]
Loading