diff --git a/mcp/ngff_zarr_mcp/tools.py b/mcp/ngff_zarr_mcp/tools.py index f84330eb..fce32c7b 100644 --- a/mcp/ngff_zarr_mcp/tools.py +++ b/mcp/ngff_zarr_mcp/tools.py @@ -4,7 +4,6 @@ import tempfile from pathlib import Path -from typing import Any import zarr from ngff_zarr import ( # type: ignore[import-untyped] @@ -27,7 +26,7 @@ def validate_ngff( version: str = "0.4", model: str = "image", strict: bool = False, - ) -> Any: + ) -> None: pass diff --git a/py/ngff_zarr/from_ngff_zarr.py b/py/ngff_zarr/from_ngff_zarr.py index bb57eefb..c97c3359 100644 --- a/py/ngff_zarr/from_ngff_zarr.py +++ b/py/ngff_zarr/from_ngff_zarr.py @@ -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] diff --git a/py/ngff_zarr/v06/zarr_metadata.py b/py/ngff_zarr/v06/zarr_metadata.py index b067a7a3..a67d9311 100644 --- a/py/ngff_zarr/v06/zarr_metadata.py +++ b/py/ngff_zarr/v06/zarr_metadata.py @@ -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" ) + validate_ngff(root_attrs, version=schema_version) # RFC 4 validation for anatomical orientation if "axes" in root_attrs["ome"]["multiscales"][0] and isinstance( diff --git a/py/ngff_zarr/validate.py b/py/ngff_zarr/validate.py index 99b00919..94c95157 100644 --- a/py/ngff_zarr/validate.py +++ b/py/ngff_zarr/validate.py @@ -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//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))}." + ) + 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) diff --git a/py/test/test_ngff_validation.py b/py/test/test_ngff_validation.py index da2cdd26..daf44d6e 100644 --- a/py/test/test_ngff_validation.py +++ b/py/test/test_ngff_validation.py @@ -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", ) @@ -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")