From f8bebf64ef6cca65e27acf71b41d67aca4cdd1fa Mon Sep 17 00:00:00 2001 From: Valentin Boussot Date: Thu, 20 Aug 2026 13:51:42 +0200 Subject: [PATCH 1/3] fix(py): resolve cross-file JSON Schema references during validation validate() registered only the requested schema, plus the version schema from 0.5, in the referencing registry. From 0.6 the spec splits axes, coordinate systems and coordinate transformations into separate files that image.schema reaches by absolute $id URL, and nothing dereferences those URLs offline, so validating any 0.6 image raised "Unresolvable: .../0.6.dev4/schemas/coordinate_transformations.schema#/$defs/scale". Build the registry from every bundled schema file for the version, keyed by its own $id. Every non-internal $ref in every bundled schema resolves to a sibling file in the same version directory, so a per-version registry is complete. strict=True works at every version on the same footing: the strict_* schemas wrap their base schema by absolute $id URL, and the pre-0.6 ones omit $schema, so they need both the registry and an explicit default specification. A pre-release version string resolves to the spec tree of the release it leads to, so the "0.6.dev4" string a 0.6 store records on disk maps to spec/0.6. The version is matched against the bundled directory names rather than joined onto the path as given, because it arrives straight from a store's metadata whenever the caller passes an explicit version, which bypasses the NgffVersion check in version detection. An unbundled version now names the bundled ones instead of raising FileNotFoundError on an internal path. The v0.6 reader takes the version from the ome namespace, where 0.6 records it, falling back to the multiscales entry as the v0.4 read path does. The v0.6 read path forwards its validate flag, so from_ngff_zarr(..., validate=True) checks a 0.6 store against the schema. The upstream 0.6rc0 schemas carry the same absolute cross-file $id references, so bundling those instead needs the same registry. Closes #647. --- py/ngff_zarr/from_ngff_zarr.py | 3 +- py/ngff_zarr/v06/zarr_metadata.py | 11 ++- py/ngff_zarr/validate.py | 92 ++++++++++++++++----- py/test/test_ngff_validation.py | 130 +++++++++++++++++++++++++++++- 4 files changed, 206 insertions(+), 30 deletions(-) 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..f05e253a 100644 --- a/py/ngff_zarr/v06/zarr_metadata.py +++ b/py/ngff_zarr/v06/zarr_metadata.py @@ -675,10 +675,15 @@ 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", "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..c33972a3 100644 --- a/py/ngff_zarr/validate.py +++ b/py/ngff_zarr/validate.py @@ -1,51 +1,101 @@ # 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: + name = packaging_version.parse(name).base_version + except packaging_version.InvalidVersion: + name = "" + 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 ): 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..fa786b90 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,125 @@ 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 + + +@requires_zarr_v3 +@pytest.mark.parametrize("ngff_version", ["0.4", "0.5", "0.6"]) +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. + 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") From 3cbb94cd12b8648cb9f3d6224fc0653ba0796715 Mon Sep 17 00:00:00 2001 From: Valentin Boussot Date: Thu, 20 Aug 2026 20:51:24 +0200 Subject: [PATCH 2/3] fix(py): narrow the version fallback and document validate() Only a pre-release leads to a release, so only a pre-release resolves to that release's bundled tree. A post-release or a local version shares a base version with a release without being it, and neither has a bundled tree. The v0.6 read path falls back to "0.6" when both version fields are null, so validate_ngff() receives a version rather than the string "None". validate() carries a return type and a docstring naming its parameters and what it raises. The strict-schema test runs its 0.4 parameter on zarr-python 2: only the 0.5 and 0.6 parameters need a Zarr v3 store. The MCP fallback stub for validate_ngff carries the same return type, which mypy requires of conditional function variants. --- mcp/ngff_zarr_mcp/tools.py | 3 +-- py/ngff_zarr/v06/zarr_metadata.py | 3 ++- py/ngff_zarr/validate.py | 32 +++++++++++++++++++++++++++++-- py/test/test_ngff_validation.py | 10 ++++++++-- 4 files changed, 41 insertions(+), 7 deletions(-) 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/v06/zarr_metadata.py b/py/ngff_zarr/v06/zarr_metadata.py index f05e253a..a67d9311 100644 --- a/py/ngff_zarr/v06/zarr_metadata.py +++ b/py/ngff_zarr/v06/zarr_metadata.py @@ -681,7 +681,8 @@ def _from_zarr_attrs( # 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", "0.6") + or root_attrs["ome"]["multiscales"][0].get("version") + or "0.6" ) validate_ngff(root_attrs, version=schema_version) diff --git a/py/ngff_zarr/validate.py b/py/ngff_zarr/validate.py index c33972a3..94c95157 100644 --- a/py/ngff_zarr/validate.py +++ b/py/ngff_zarr/validate.py @@ -40,9 +40,13 @@ def _schemas_dir(version: str) -> "Traversable": name = str(version) if name not in available: try: - name = packaging_version.parse(name).base_version + 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}. " @@ -89,7 +93,31 @@ def _schema_registry(version: str) -> "Registry": 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 except ImportError: diff --git a/py/test/test_ngff_validation.py b/py/test/test_ngff_validation.py index fa786b90..2a920bd2 100644 --- a/py/test/test_ngff_validation.py +++ b/py/test/test_ngff_validation.py @@ -244,8 +244,14 @@ def test_validate_v06_schema_active_on_read_path(): assert multiscales is not None -@requires_zarr_v3 -@pytest.mark.parametrize("ngff_version", ["0.4", "0.5", "0.6"]) +@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 From df630adc8705b416fb6ade7d66ee1602c93245d0 Mon Sep 17 00:00:00 2001 From: Valentin Boussot Date: Fri, 21 Aug 2026 01:11:21 +0200 Subject: [PATCH 3/3] test(py): skip the forged-version test without jsonschema `validate()` imports jsonschema before it loads the schema, so without the `[validate]` extra the test raised ImportError rather than the ValueError it asserts. Every other test in the file already guards the same way. --- py/test/test_ngff_validation.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/py/test/test_ngff_validation.py b/py/test/test_ngff_validation.py index 2a920bd2..daf44d6e 100644 --- a/py/test/test_ngff_validation.py +++ b/py/test/test_ngff_validation.py @@ -286,6 +286,8 @@ 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()