Skip to content

Commit 3a0e8ca

Browse files
d-v-bclaude
andcommitted
refactor(conventions): source convention identity & validation from zarr-cm
Closes #19. zarr-cm is the shared, minimal-dependency Python implementation of the Zarr conventions metadata. This de-duplicates functionality by depending on it as the single source of truth, while keeping geozarr-toolkit's richer Pydantic models and geospatial value-adds. - Add `zarr-cm>=0.4.1` dependency. - Source each convention's identity (UUID, schema_url, spec_url, description) from zarr-cm instead of hardcoding it. The spatial/proj schema/spec URLs now point at zarr-cm's commit-pinned snapshots (the old `refs/tags/v0.1` URLs for those two conventions 404 upstream). - Delegate structural validation rules to zarr-cm where the behavior matches: - `ZarrConventionMetadata`: the "at least one identifier" rule. - `Proj`: the "at least one CRS" rule (the stricter `^[A-Z]+:[0-9]+$` code pattern and the pyproj resolution check remain geozarr-specific additions). - `Multiscales`: full layout validation, which adds enforcement of the spec rule that a `derived_from` level must also carry a `transform`. - `validate_zarr_conventions` helper: the per-CMO identifier rule. Spatial validation is intentionally NOT delegated: zarr-cm's shipped spatial revisions are strict-2D, whereas geozarr-toolkit supports N-D dimensions. A test that constructed a `derived_from` level without a `transform` (invalid per the spec) is fixed, and a test for the newly enforced rule is added. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fb49f63 commit 3a0e8ca

8 files changed

Lines changed: 83 additions & 42 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ Python library for creating and validating [GeoZarr](https://github.com/zarr-dev
66
- **[proj:](https://github.com/zarr-conventions/proj)** -- Coordinate Reference System (CRS) via EPSG codes, WKT2, or PROJJSON
77
- **[multiscales](https://github.com/zarr-conventions/multiscales)** -- Pyramid structures and resolution levels
88

9+
Convention identity (UUIDs, schema/spec URLs) and the core structural validation
10+
rules are sourced from [`zarr-cm`](https://zarr-cm.readthedocs.io/), the shared
11+
Python implementation of the Zarr conventions metadata, so they stay in sync
12+
across the ecosystem. geozarr-toolkit layers richer Pydantic models and
13+
geospatial-specific checks (e.g. resolving `proj:code` via `pyproj`) on top.
14+
915
## Installation
1016

1117
```bash

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ authors = [
1111
requires-python = ">=3.12"
1212
dependencies = [
1313
"zarr>=3.0.8",
14+
"zarr-cm>=0.4.1",
1415
"pydantic>=2.12",
1516
"pyproj>=3.7.0",
1617
"structlog>=25.5.0",

src/geozarr_toolkit/conventions/common.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from pydantic import BaseModel, model_validator
88
from pydantic.experimental.missing_sentinel import MISSING
9+
from zarr_cm import validate_convention_metadata_object
910

1011

1112
class ZarrConventionMetadata(BaseModel):
@@ -26,9 +27,13 @@ class ZarrConventionMetadata(BaseModel):
2627

2728
@model_validator(mode="after")
2829
def ensure_identifiable(self) -> Self:
29-
"""Ensure at least one identifier is provided."""
30-
if self.uuid is MISSING and self.schema_url is MISSING and self.spec_url is MISSING:
31-
raise ValueError("At least one of uuid, schema_url, or spec_url must be provided.")
30+
"""Ensure at least one identifier is provided.
31+
32+
Delegates the "at least one of uuid/schema_url/spec_url" rule to
33+
`zarr_cm.validate_convention_metadata_object` so the requirement
34+
stays defined in a single place (zarr-cm).
35+
"""
36+
validate_convention_metadata_object(self.model_dump())
3237
return self
3338

3439

src/geozarr_toolkit/conventions/multiscales.py

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,30 +12,28 @@
1212

1313
from typing import Final, Literal
1414

15-
from pydantic import BaseModel, field_validator
15+
from pydantic import BaseModel, field_validator, model_validator
1616
from pydantic.experimental.missing_sentinel import MISSING
17+
from zarr_cm import multiscales as _multiscales_cm
1718

1819
from geozarr_toolkit.conventions.common import ZarrConventionMetadata
1920

20-
MULTISCALES_UUID: Final[Literal["d35379db-88df-4056-af3a-620245f8e347"]] = (
21-
"d35379db-88df-4056-af3a-620245f8e347"
22-
)
23-
MULTISCALES_SCHEMA_URL: Final[str] = (
24-
"https://raw.githubusercontent.com/zarr-conventions/multiscales/refs/tags/v0.1/schema.json"
25-
)
26-
MULTISCALES_SPEC_URL: Final[str] = (
27-
"https://github.com/zarr-conventions/multiscales/blob/v0.1/README.md"
28-
)
21+
# Convention identity (UUID, schema/spec URLs, description) is re-exported from
22+
# zarr-cm so there is a single source of truth for it across the ecosystem.
23+
MULTISCALES_UUID: Final[str] = _multiscales_cm.UUID
24+
MULTISCALES_SCHEMA_URL: Final[str] = _multiscales_cm.SCHEMA_URL
25+
MULTISCALES_SPEC_URL: Final[str] = _multiscales_cm.SPEC_URL
26+
MULTISCALES_DESCRIPTION: Final[str] = _multiscales_cm.CMO["description"]
2927

3028

3129
class MultiscalesConventionMetadata(ZarrConventionMetadata):
3230
"""Metadata for the multiscales convention in zarr_conventions array."""
3331

34-
uuid: Literal["d35379db-88df-4056-af3a-620245f8e347"] = MULTISCALES_UUID
32+
uuid: str = MULTISCALES_UUID
3533
name: Literal["multiscales"] = "multiscales"
3634
schema_url: str = MULTISCALES_SCHEMA_URL
3735
spec_url: str = MULTISCALES_SPEC_URL
38-
description: str = "Multiscale layout of zarr datasets"
36+
description: str = MULTISCALES_DESCRIPTION
3937

4038

4139
class Transform(BaseModel):
@@ -117,6 +115,18 @@ def validate_layout_not_empty(cls, value: tuple[ScaleLevel, ...]) -> tuple[Scale
117115
raise ValueError("multiscales layout must have at least one level")
118116
return value
119117

118+
@model_validator(mode="after")
119+
def validate_layout_structure(self) -> Multiscales:
120+
"""Validate the layout against the multiscales spec.
121+
122+
Delegates structural validation -- in particular the rule that any
123+
level with `derived_from` must also carry a `transform` -- to
124+
`zarr_cm.multiscales.validate` so it stays defined in a single place
125+
(zarr-cm).
126+
"""
127+
_multiscales_cm.validate(self.model_dump())
128+
return self
129+
120130

121131
class MultiscalesAttrs(BaseModel):
122132
"""

src/geozarr_toolkit/conventions/proj.py

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,26 +14,29 @@
1414
from typing import Any, Final, Literal
1515

1616
from pydantic import BaseModel, Field, field_validator, model_validator
17+
from zarr_cm import proj as _proj_cm
1718

1819
from geozarr_toolkit.conventions.common import ZarrConventionMetadata, is_none
1920

20-
PROJ_UUID: Final[Literal["f17cb550-5864-4468-aeb7-f3180cfb622f"]] = (
21-
"f17cb550-5864-4468-aeb7-f3180cfb622f"
22-
)
23-
PROJ_SCHEMA_URL: Final[str] = (
24-
"https://raw.githubusercontent.com/zarr-conventions/proj/refs/tags/v0.1/schema.json"
25-
)
26-
PROJ_SPEC_URL: Final[str] = "https://github.com/zarr-conventions/proj/blob/v0.1/README.md"
21+
# Convention identity (UUID, schema/spec URLs, description) is re-exported from
22+
# zarr-cm so there is a single source of truth for it across the ecosystem.
23+
PROJ_UUID: Final[str] = _proj_cm.UUID
24+
PROJ_SCHEMA_URL: Final[str] = _proj_cm.SCHEMA_URL
25+
PROJ_SPEC_URL: Final[str] = _proj_cm.SPEC_URL
26+
PROJ_DESCRIPTION: Final[str] = _proj_cm.CMO["description"]
2727

2828

2929
class ProjConventionMetadata(ZarrConventionMetadata):
3030
"""Metadata for the proj convention in zarr_conventions array."""
3131

32-
uuid: Literal["f17cb550-5864-4468-aeb7-f3180cfb622f"] = PROJ_UUID
32+
uuid: str = PROJ_UUID
33+
# `name` is intentionally not sourced from `zarr_cm.proj.CMO`: that CMO
34+
# carries `"proj:"` (with a trailing colon), whereas the proj spec and
35+
# this toolkit use the bare convention name `"proj"`.
3336
name: Literal["proj"] = "proj"
3437
schema_url: str = PROJ_SCHEMA_URL
3538
spec_url: str = PROJ_SPEC_URL
36-
description: str = "Coordinate reference system information for geospatial data"
39+
description: str = PROJ_DESCRIPTION
3740

3841

3942
_CODE_PATTERN = re.compile(r"^[A-Z]+:[0-9]+$")
@@ -74,11 +77,16 @@ def validate_code_format(cls, v: str | None) -> str | None:
7477

7578
@model_validator(mode="after")
7679
def validate_at_least_one_crs(self) -> Proj:
77-
"""Validate that at least one CRS field is provided."""
78-
if not any([self.code, self.wkt2, self.projjson]):
79-
raise ValueError(
80-
"At least one of proj:code, proj:wkt2, or proj:projjson must be provided"
81-
)
80+
"""Validate that at least one CRS field is provided.
81+
82+
Delegates the spec-level "at least one of proj:code/proj:wkt2/
83+
proj:projjson" rule to `zarr_cm.proj.validate` so the requirement
84+
stays defined in a single place (zarr-cm). The stricter
85+
`^[A-Z]+:[0-9]+$` code pattern (`validate_code_format`) and the
86+
pyproj resolution check (`validate_code_resolves`) are geozarr-toolkit
87+
additions layered on top.
88+
"""
89+
_proj_cm.validate(self.model_dump(by_alias=True, exclude_none=True))
8290
return self
8391

8492
@model_validator(mode="after")

src/geozarr_toolkit/conventions/spatial.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,26 +13,29 @@
1313
from typing import Final, Literal
1414

1515
from pydantic import BaseModel, Field, model_validator
16+
from zarr_cm import spatial as _spatial_cm
1617

1718
from geozarr_toolkit.conventions.common import ZarrConventionMetadata, is_none
1819

19-
SPATIAL_UUID: Final[Literal["689b58e2-cf7b-45e0-9fff-9cfc0883d6b4"]] = (
20-
"689b58e2-cf7b-45e0-9fff-9cfc0883d6b4"
21-
)
22-
SPATIAL_SCHEMA_URL: Final[str] = (
23-
"https://raw.githubusercontent.com/zarr-conventions/spatial/refs/tags/v0.1/schema.json"
24-
)
25-
SPATIAL_SPEC_URL: Final[str] = "https://github.com/zarr-conventions/spatial/blob/v0.1/README.md"
20+
# Convention identity (UUID, schema/spec URLs, description) is re-exported from
21+
# zarr-cm so there is a single source of truth for it across the ecosystem.
22+
SPATIAL_UUID: Final[str] = _spatial_cm.UUID
23+
SPATIAL_SCHEMA_URL: Final[str] = _spatial_cm.SCHEMA_URL
24+
SPATIAL_SPEC_URL: Final[str] = _spatial_cm.SPEC_URL
25+
SPATIAL_DESCRIPTION: Final[str] = _spatial_cm.CMO["description"]
2626

2727

2828
class SpatialConventionMetadata(ZarrConventionMetadata):
2929
"""Metadata for the spatial convention in zarr_conventions array."""
3030

31-
uuid: Literal["689b58e2-cf7b-45e0-9fff-9cfc0883d6b4"] = SPATIAL_UUID
31+
uuid: str = SPATIAL_UUID
32+
# `name` is intentionally not sourced from `zarr_cm.spatial.CMO`: that
33+
# CMO carries `"spatial:"` (with a trailing colon), whereas the spatial
34+
# spec and this toolkit use the bare convention name `"spatial"`.
3235
name: Literal["spatial"] = "spatial"
3336
schema_url: str = SPATIAL_SCHEMA_URL
3437
spec_url: str = SPATIAL_SPEC_URL
35-
description: str = "Spatial coordinate information"
38+
description: str = SPATIAL_DESCRIPTION
3639

3740

3841
class Spatial(BaseModel):

src/geozarr_toolkit/helpers/validation.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from typing import TYPE_CHECKING, Any
1111

1212
from pydantic import ValidationError
13+
from zarr_cm import validate_convention_metadata_object
1314

1415
from geozarr_toolkit.conventions import (
1516
MULTISCALES_UUID,
@@ -128,9 +129,11 @@ def validate_zarr_conventions(attrs: dict[str, Any]) -> tuple[bool, list[str]]:
128129
errors.append(f"Convention {i} must be a dictionary")
129130
continue
130131

131-
# Check that at least one identifier is present
132-
has_id = any(conv.get(key) for key in ("uuid", "schema_url", "spec_url"))
133-
if not has_id:
132+
# Defer the "at least one identifier" rule to zarr-cm so it stays
133+
# defined in a single place.
134+
try:
135+
validate_convention_metadata_object(conv)
136+
except ValueError:
134137
errors.append(f"Convention {i} must have at least one of: uuid, schema_url, spec_url")
135138

136139
return len(errors) == 0, errors

tests/test_conventions/test_multiscales.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,11 +79,16 @@ def test_basic_layout(self) -> None:
7979
multiscales = Multiscales(
8080
layout=(
8181
ScaleLevel(asset="0"),
82-
ScaleLevel(asset="1", derived_from="0"),
82+
ScaleLevel(asset="1", derived_from="0", transform=Transform(scale=(2.0, 2.0))),
8383
)
8484
)
8585
assert len(multiscales.layout) == 2
8686

87+
def test_derived_from_requires_transform(self) -> None:
88+
"""Test that a derived level without a transform fails validation."""
89+
with pytest.raises(ValidationError, match="derived_from"):
90+
Multiscales(layout=(ScaleLevel(asset="1", derived_from="0"),))
91+
8792
def test_empty_layout_fails(self) -> None:
8893
"""Test that empty layout fails validation."""
8994
with pytest.raises(ValidationError, match="at least one level"):

0 commit comments

Comments
 (0)