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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions mcp/ngff_zarr_mcp/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

import tempfile
from pathlib import Path
from typing import Any

import zarr
from ngff_zarr import ( # type: ignore[import-untyped]
Expand All @@ -27,7 +26,7 @@ def validate_ngff(
version: str = "0.4",
model: str = "image",
strict: bool = False,
) -> Any:
) -> None:
pass


Expand Down
3 changes: 1 addition & 2 deletions py/ngff_zarr/from_ngff_zarr.py
Original file line number Diff line number Diff line change
Expand Up @@ -349,9 +349,8 @@ def _open_group_with_helpful_errors(**open_kwargs):
if version.startswith("0.6"):
from .v06.zarr_metadata import Metadata

# TODO: Restore validation for v0.6
metadata_obj, images = Metadata._from_zarr_attrs(
root_attrs, store, validate=False, subpath=subpath
root_attrs, store, validate=validate, subpath=subpath
)
method, method_type, method_metadata = _extract_method_metadata(
root_attrs["ome"]["multiscales"][0]
Expand Down
12 changes: 9 additions & 3 deletions py/ngff_zarr/v06/zarr_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -675,10 +675,16 @@ def _from_zarr_attrs(
)

if validate:
validate_ngff(
root_attrs,
version=root_attrs["ome"]["multiscales"][0].get("version", "0.6"),
# From 0.6 the version is recorded on the ``ome`` namespace rather
# than on each multiscales entry, and it is the pre-release string
# ("0.6.dev4") that the bundled 0.6 schemas are tagged 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"
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
validate_ngff(root_attrs, version=schema_version)

# RFC 4 validation for anatomical orientation
if "axes" in root_attrs["ome"]["multiscales"][0] and isinstance(
Expand Down
122 changes: 100 additions & 22 deletions py/ngff_zarr/validate.py
Original file line number Diff line number Diff line change
@@ -1,51 +1,129 @@
# SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC
# SPDX-License-Identifier: MIT
import json
from pathlib import Path
from functools import cache
from typing import TYPE_CHECKING

from importlib_resources import files as file_resources
from packaging import version as packaging_version

if TYPE_CHECKING:
from importlib_resources.abc import Traversable
from referencing import Registry

NGFF_URI = "https://ngff.openmicroscopy.org"


@cache
def _bundled_versions() -> frozenset:
"""The versions that have a bundled ``spec/<version>/schemas`` tree."""
spec = file_resources("ngff_zarr").joinpath("spec")
return frozenset(
entry.name
for entry in spec.iterdir()
if entry.is_dir() and entry.joinpath("schemas").is_dir()
)


def _schemas_dir(version: str) -> "Traversable":
"""Locate the bundled ``schemas`` directory that holds ``version``.

A pre-release shares the tree of the release it leads to: the bundled 0.6
schemas carry the upstream ``0.6.dev4`` tag, so the ``"0.6.dev4"`` string a
0.6 store records on disk resolves to ``spec/0.6`` just as ``"0.6"`` does.

The version is matched against the bundled directory names rather than
joined onto the path as given, because it reaches here straight from a
store's own metadata.
"""
available = _bundled_versions()
name = str(version)
if name not in available:
try:
parsed = packaging_version.parse(name)
except packaging_version.InvalidVersion:
name = ""
else:
# Only a pre-release leads to a release. A post-release or a local
# version shares a base version with one without being it.
name = parsed.base_version if parsed.is_prerelease else ""
if name not in available:
raise ValueError(
f"No JSON Schema is bundled for OME-Zarr version {version!r}. "
f"Bundled versions: {', '.join(sorted(available))}."
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return file_resources("ngff_zarr").joinpath("spec").joinpath(name, "schemas")


def load_schema(
version: str = "0.4", model: str = "image", strict: bool = False
) -> dict:
strict_str = ""
if strict:
strict_str = "strict_"
schema = (
file_resources("ngff_zarr")
.joinpath(
Path("spec")
/ Path(version)
/ Path("schemas")
/ f"{strict_str}{model}.schema"
)
.read_text()
)
schema = _schemas_dir(version).joinpath(f"{strict_str}{model}.schema").read_text()
return json.loads(schema)


@cache
def _schema_registry(version: str) -> "Registry":
"""Register every bundled schema for ``version`` under its own ``$id``.

From 0.6 the spec splits axes, coordinate systems and coordinate
transformations into their own files, which ``image.schema`` reaches by
absolute ``$id`` URL. Validation is offline, so nothing dereferences those
URLs: each sibling file has to be in the registry for the references to
resolve. The pre-0.6 schemas keep the same layout, where the only
cross-file references are ``strict_*`` wrappers around their base schema.
"""
from referencing import Registry, Resource
from referencing.jsonschema import DRAFT202012

resources = []
for entry in _schemas_dir(version).iterdir():
if not entry.name.endswith(".schema"):
continue
contents = json.loads(entry.read_text())
# Some bundled schemas omit ``$schema``; they all predate 2020-12 draft
# divergences, so the draft the validator runs is the right default.
resource = Resource.from_contents(contents, default_specification=DRAFT202012)
resources.append((contents.get("$id", NGFF_URI), resource))
return Registry().with_resources(resources)


def validate(
ngff_dict: dict, version: str = "0.4", model: str = "image", strict: bool = False
):
) -> None:
"""Validate OME-Zarr metadata against its bundled JSON Schema.

Parameters
----------
ngff_dict:
The parsed group attributes to check.
version:
The OME-Zarr version whose bundled schemas to validate against. A
pre-release resolves to the tree of the release it leads to.
model:
The schema to validate against: ``image``, ``label``, ``plate`` or
``well``.
strict:
Validate against the ``strict_*`` variant of ``model``.

Raises
------
ImportError
When the optional ``[validate]`` extra is not installed.
ValueError
When no schema tree is bundled for ``version``.
jsonschema.ValidationError
When ``ngff_dict`` does not satisfy the schema.
"""
try:
from jsonschema import Draft202012Validator
from referencing import Registry, Resource
except ImportError:
raise ImportError(
"jsonschema is required to validate NGFF metadata - install the ngff-zarr[validate] extra"
)
schema = load_schema(version=version, model=model, strict=strict)
registry = Registry().with_resource(
NGFF_URI, resource=Resource.from_contents(schema)
)
if packaging_version.parse(version) >= packaging_version.parse("0.5"):
version_schema = load_schema(version=version, model="_version")
registry = registry.with_resource(
NGFF_URI, resource=Resource.from_contents(version_schema)
)
validator = Draft202012Validator(schema, registry=registry)
validator = Draft202012Validator(schema, registry=_schema_registry(version))
validator.validate(ngff_dict)
138 changes: 134 additions & 4 deletions py/test/test_ngff_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,12 @@
zarr_version = version.parse(zarr.__version__)
zarr_version_major = zarr_version.major

# OME-Zarr v0.5 requires a Zarr v3 store, which zarr-python only writes at
# >= 3.0.0b1. The CI matrix exercises legs pinned to zarr 2.x (v0.4 only), where
# ``to_ngff_zarr(..., version="0.5")`` raises; skip the v0.5-writing tests there.
# OME-Zarr v0.5 and v0.6 require a Zarr v3 store, which zarr-python only writes
# at >= 3.0.0b1. The CI matrix exercises legs pinned to zarr 2.x (v0.4 only),
# where ``to_ngff_zarr(..., version="0.5")`` raises; skip those tests there.
requires_zarr_v3 = pytest.mark.skipif(
zarr_version < version.parse("3.0.0b1"),
reason="OME-Zarr v0.5 requires zarr-python >= 3.0.0b1",
reason="OME-Zarr v0.5 and v0.6 require zarr-python >= 3.0.0b1",
)


Expand Down Expand Up @@ -167,3 +167,133 @@ def _schema_call(store):
assert "multiscales" in v04_instance
assert "ome" not in v04_instance
assert v04_version == "0.4"


def _write_valid_3d_store_v06() -> zarr.storage.MemoryStore:
"""Write a valid 3D ``(z, y, x)`` two-level v0.6 multiscales to a store."""
store = zarr.storage.MemoryStore()
array = np.random.random((4, 8, 8)).astype("float32")
to_ngff_zarr(store, to_multiscales(array, [2]), version="0.6")
return store


@requires_zarr_v3
def test_validate_v06_resolves_split_schema_refs():
# From v0.6 the image schema reaches coordinate systems and coordinate
# transformations through the absolute ``$id`` URLs of sibling schema
# files. Validation is offline, so every bundled file has to be in the
# reference registry; without them this raised an unresolvable-reference
# error on the writer's own output rather than validating it.
pytest.importorskip("jsonschema")

root_attrs = zarr.open_group(_write_valid_3d_store_v06(), mode="r").attrs.asdict()

validate(root_attrs, version="0.6", model="image")


@requires_zarr_v3
def test_validate_v06_accepts_the_on_disk_version_string():
# A v0.6 store records the upstream pre-release tag the bundled schemas
# carry. That string has no ``spec`` tree of its own, so it has to resolve
# to the tree of the release it leads to.
pytest.importorskip("jsonschema")

root_attrs = zarr.open_group(_write_valid_3d_store_v06(), mode="r").attrs.asdict()
on_disk_version = root_attrs["ome"]["version"]
assert on_disk_version.startswith("0.6")
assert on_disk_version != "0.6"

validate(root_attrs, version=on_disk_version, model="image")


@requires_zarr_v3
def test_validate_v06_rejects_invalid_metadata():
# Resolving the references must not turn validation into a no-op: dropping
# a required property still fails.
jsonschema = pytest.importorskip("jsonschema")

root_attrs = zarr.open_group(_write_valid_3d_store_v06(), mode="r").attrs.asdict()
del root_attrs["ome"]["multiscales"][0]["coordinateSystems"]

with pytest.raises(jsonschema.ValidationError):
validate(root_attrs, version="0.6", model="image")


@requires_zarr_v3
def test_validate_v06_schema_active_on_read_path():
# The v0.6 read path validated nothing while the split-schema references
# were unresolvable. As for v0.5, a duplicate multiscale entry violates the
# schema's ``uniqueItems`` constraint -- a pure schema concern the
# structural rules (which inspect only ``multiscales[0]``) do not check --
# so it is rejected under ``validate=True`` and read silently otherwise.
jsonschema = pytest.importorskip("jsonschema")

store = _write_valid_3d_store_v06()
assert from_ngff_zarr(store, validate=True) is not None

root = zarr.open_group(store, mode="r+")
attrs = root.attrs.asdict()
ome = attrs["ome"]
ome["multiscales"] = [ome["multiscales"][0], ome["multiscales"][0]]
root.attrs["ome"] = ome

with pytest.raises(jsonschema.ValidationError):
from_ngff_zarr(store, validate=True)

multiscales = from_ngff_zarr(store, validate=False)
assert multiscales is not None


@pytest.mark.parametrize(
"ngff_version",
[
"0.4",
pytest.param("0.5", marks=requires_zarr_v3),
pytest.param("0.6", marks=requires_zarr_v3),
],
)
def test_validate_strict_image_schema(ngff_version):
# Every ``strict_image`` schema wraps its base schema by absolute ``$id``
# URL, and the pre-0.6 ones omit ``$schema`` entirely. Both have to be
# handled for the strict models to run at all.
pytest.importorskip("jsonschema")

store = zarr.storage.MemoryStore()
array = np.random.random((4, 8, 8)).astype("float32")
to_ngff_zarr(store, to_multiscales(array, [2]), version=ngff_version)
root_attrs = zarr.open_group(store, mode="r").attrs.asdict()

validate(root_attrs, version=ngff_version, model="image", strict=True)


def test_load_schema_rejects_unbundled_version():
# The version reaches the loader straight from a store's own metadata, so
# it is matched against the bundled directory names rather than joined onto
# the path as given. An unbundled version names the ones that are bundled
# instead of surfacing a filesystem path.
from ngff_zarr.validate import load_schema

for unbundled in ("0.7", "latest", "", "../../../../etc", "/etc"):
with pytest.raises(ValueError) as excinfo:
load_schema(version=unbundled)
message = str(excinfo.value)
assert "0.4" in message
assert "spec/" not in message


@requires_zarr_v3
def test_read_path_rejects_a_forged_version_string():
# ``from_ngff_zarr(store, version="0.6")`` bypasses version detection, so a
# store's own ``ome.version`` is what selects the schema. A forged value
# must be rejected by name, not resolved as a path.
pytest.importorskip("jsonschema")

store = _write_valid_3d_store_v06()
root = zarr.open_group(store, mode="r+")
attrs = root.attrs.asdict()
ome = attrs["ome"]
ome["version"] = "../../../../../../etc"
root.attrs["ome"] = ome

with pytest.raises(ValueError, match="No JSON Schema is bundled"):
from_ngff_zarr(store, validate=True, version="0.6")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading