Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions py/ngff_zarr/parse_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,68 @@ 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, and the intrinsic one is whichever
system the datasets map into, named by their transformation ``output``.
Nothing requires it to be listed first, so it is resolved by name; a
document whose datasets name no output falls back to the first system,
which is what both ports write.

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 not isinstance(systems, list) or not systems:
return []
intrinsic = _intrinsic_system(multiscales_entry, systems)
if isinstance(intrinsic, dict):
intrinsic_axes = intrinsic.get("axes")
if isinstance(intrinsic_axes, list):
return intrinsic_axes
Comment thread
vboussot marked this conversation as resolved.
return []


def _intrinsic_system(multiscales_entry: dict, systems: list):
"""The coordinate system the datasets map into, else the first listed.

Mirrors :attr:`ngff_zarr.v06.zarr_metadata.Metadata.intrinsic_coordinate_system`
at the dict level, where the parsed dataclasses are not available yet.
"""
for dataset in multiscales_entry.get("datasets") or []:
if not isinstance(dataset, dict):
continue
transforms = dataset.get("coordinateTransformations")
if not isinstance(transforms, list) or not transforms:
continue
if not isinstance(transforms[0], dict):
continue
output = transforms[0].get("output")
if not isinstance(output, dict):
continue
name = output.get("name")
if name is None:
continue
for system in systems:
if isinstance(system, dict) and system.get("name") == name:
return system
break
return systems[0]


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
35 changes: 35 additions & 0 deletions py/ngff_zarr/rfc4_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,41 @@ def validate_rfc4_orientation(axes: list[dict[str, Any]]) -> None:
validator.validate(axes_structure)


def has_any_rfc4_orientation(axes: list[dict[str, Any]]) -> bool:
"""Whether any axis carries a non-empty ``orientation``.

:func:`has_rfc4_orientation_metadata` answers the narrower question of
whether a *spatial* axis is oriented, which is what callers deciding
whether to record orientation want. Callers deciding whether to *validate*
want this one: an orientation on a non-spatial axis is itself an RFC 4
violation, so skipping validation because no spatial axis is oriented is
precisely how that violation goes unreported.

Only ``None`` and ``{}`` are undefined under RFC 4, which is the pair
:func:`validate_rfc4_orientation` skips. Any other value, a falsey ``[]``,
``""``, ``0`` or ``False`` included, is malformed rather than absent and so
must reach the validator.

Parameters
----------
axes : List[Dict[str, Any]]
List of axis metadata dictionaries

Returns
-------
bool
True if any axis carries an orientation that is neither absent nor empty
"""
for axis in axes:
if not isinstance(axis, dict) or "orientation" not in axis:
continue
orientation = axis["orientation"]
if orientation is None or orientation == {}:
continue
return True
return False


def has_rfc4_orientation_metadata(axes: list[dict[str, Any]]) -> bool:
"""
Check if the axes contain RFC 4 anatomical orientation metadata.
Expand Down
24 changes: 8 additions & 16 deletions py/ngff_zarr/structural_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -525,14 +525,14 @@ def validate_axis_orientation(metadata: Metadata) -> None:
"""Validate RFC 4 anatomical-orientation metadata on the spatial axes.

This rule does not reimplement RFC 4; it wraps the package's existing logic
-- :func:`~ngff_zarr.rfc4_validation.has_rfc4_orientation_metadata` and
-- :func:`~ngff_zarr.rfc4_validation.has_any_rfc4_orientation` and
:func:`~ngff_zarr.rfc4_validation.validate_rfc4_orientation` -- and surfaces
its failures through the unified :class:`ValidationError` channel. The parsed
:class:`~ngff_zarr.v04.zarr_metadata.Axis` objects are first rendered back to
the axis-dict form those helpers expect (see :func:`_axis_to_validation_dict`).

Orientation is optional in RFC 4, so when no spatial axis carries it the rule
is a no-op and the comparatively heavy ``jsonschema`` import inside
Orientation is optional in RFC 4, so when no axis carries one the rule is a
no-op and the comparatively heavy ``jsonschema`` import inside
:func:`validate_rfc4_orientation` is never triggered.

Raises
Expand All @@ -549,23 +549,15 @@ def validate_axis_orientation(metadata: Metadata) -> None:
vocabulary -- a schema-level concern with no dedicated structural rule.
"""
from .rfc4_validation import (
has_rfc4_orientation_metadata,
has_any_rfc4_orientation,
validate_rfc4_orientation,
)

axes_dicts = [_axis_to_validation_dict(axis) for axis in metadata.axes]
# A stray orientation on a non-spatial axis carries no spatial-axis
# orientation, so has_rfc4_orientation_metadata alone would skip it; check for
# any real (non-empty) orientation so the non-space rule is reachable. A null
# or empty orientation is undefined under RFC 4 and so is not a violation.
non_space_orientation = any(
isinstance(axis_dict, dict)
and axis_dict.get("type") != "space"
and isinstance(axis_dict.get("orientation"), dict)
and axis_dict.get("orientation")
for axis_dict in axes_dicts
)
if not has_rfc4_orientation_metadata(axes_dicts) and not non_space_orientation:
# Gated on *any* orientation, not just a spatial one: an orientation on a
# non-spatial axis is itself the violation, so a spatial-only check would
# skip exactly the document that needs reporting.
if not has_any_rfc4_orientation(axes_dicts):
return
try:
validate_rfc4_orientation(axes_dicts)
Expand Down
25 changes: 12 additions & 13 deletions py/ngff_zarr/v04/zarr_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -425,9 +425,9 @@ 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,
has_any_rfc4_orientation,
validate_rfc4_orientation,
)
from ..validate import validate as validate_ngff
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_any_rfc4_orientation(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
26 changes: 13 additions & 13 deletions py/ngff_zarr/v06/zarr_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -661,9 +661,9 @@ 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,
has_any_rfc4_orientation,
validate_rfc4_orientation,
)
from ..validate import validate as validate_ngff
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_any_rfc4_orientation(axes_dicts):
validate_rfc4_orientation(axes_dicts)

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"]
Loading
Loading