Skip to content
Open
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ Python library for creating and validating [GeoZarr](https://github.com/zarr-dev
- **[proj:](https://github.com/zarr-conventions/proj)** -- Coordinate Reference System (CRS) via EPSG codes, WKT2, or PROJJSON
- **[multiscales](https://github.com/zarr-conventions/multiscales)** -- Pyramid structures and resolution levels

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

## Installation

```bash
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ authors = [
requires-python = ">=3.12"
dependencies = [
"zarr>=3.0.8",
"zarr-cm>=0.4.1",
"pydantic>=2.12",
"pyproj>=3.7.0",
"structlog>=25.5.0",
Expand Down
11 changes: 8 additions & 3 deletions src/geozarr_toolkit/conventions/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from pydantic import BaseModel, model_validator
from pydantic.experimental.missing_sentinel import MISSING
from zarr_cm import validate_convention_metadata_object


class ZarrConventionMetadata(BaseModel):
Expand All @@ -26,9 +27,13 @@ class ZarrConventionMetadata(BaseModel):

@model_validator(mode="after")
def ensure_identifiable(self) -> Self:
"""Ensure at least one identifier is provided."""
if self.uuid is MISSING and self.schema_url is MISSING and self.spec_url is MISSING:
raise ValueError("At least one of uuid, schema_url, or spec_url must be provided.")
"""Ensure at least one identifier is provided.

Delegates the "at least one of uuid/schema_url/spec_url" rule to
`zarr_cm.validate_convention_metadata_object` so the requirement
stays defined in a single place (zarr-cm).
"""
validate_convention_metadata_object(self.model_dump())
return self


Expand Down
34 changes: 22 additions & 12 deletions src/geozarr_toolkit/conventions/multiscales.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,30 +12,28 @@

from typing import Final, Literal

from pydantic import BaseModel, field_validator
from pydantic import BaseModel, field_validator, model_validator
from pydantic.experimental.missing_sentinel import MISSING
from zarr_cm import multiscales as _multiscales_cm

from geozarr_toolkit.conventions.common import ZarrConventionMetadata

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


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

uuid: Literal["d35379db-88df-4056-af3a-620245f8e347"] = MULTISCALES_UUID
uuid: str = MULTISCALES_UUID
name: Literal["multiscales"] = "multiscales"
schema_url: str = MULTISCALES_SCHEMA_URL
spec_url: str = MULTISCALES_SPEC_URL
description: str = "Multiscale layout of zarr datasets"
description: str = MULTISCALES_DESCRIPTION


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

@model_validator(mode="after")
def validate_layout_structure(self) -> Multiscales:
"""Validate the layout against the multiscales spec.

Delegates structural validation -- in particular the rule that any
level with `derived_from` must also carry a `transform` -- to
`zarr_cm.multiscales.validate` so it stays defined in a single place
(zarr-cm).
"""
_multiscales_cm.validate(self.model_dump())
return self


class MultiscalesAttrs(BaseModel):
"""
Expand Down
36 changes: 22 additions & 14 deletions src/geozarr_toolkit/conventions/proj.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,29 @@
from typing import Any, Final, Literal

from pydantic import BaseModel, Field, field_validator, model_validator
from zarr_cm import proj as _proj_cm

from geozarr_toolkit.conventions.common import ZarrConventionMetadata, is_none

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


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

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


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

@model_validator(mode="after")
def validate_at_least_one_crs(self) -> Proj:
"""Validate that at least one CRS field is provided."""
if not any([self.code, self.wkt2, self.projjson]):
raise ValueError(
"At least one of proj:code, proj:wkt2, or proj:projjson must be provided"
)
"""Validate that at least one CRS field is provided.

Delegates the spec-level "at least one of proj:code/proj:wkt2/
proj:projjson" rule to `zarr_cm.proj.validate` so the requirement
stays defined in a single place (zarr-cm). The stricter
`^[A-Z]+:[0-9]+$` code pattern (`validate_code_format`) and the
pyproj resolution check (`validate_code_resolves`) are geozarr-toolkit
additions layered on top.
"""
_proj_cm.validate(self.model_dump(by_alias=True, exclude_none=True))
return self

@model_validator(mode="after")
Expand Down
21 changes: 12 additions & 9 deletions src/geozarr_toolkit/conventions/spatial.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,26 +13,29 @@
from typing import Final, Literal

from pydantic import BaseModel, Field, model_validator
from zarr_cm import spatial as _spatial_cm

from geozarr_toolkit.conventions.common import ZarrConventionMetadata, is_none

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


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

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


class Spatial(BaseModel):
Expand Down
9 changes: 6 additions & 3 deletions src/geozarr_toolkit/helpers/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from typing import TYPE_CHECKING, Any

from pydantic import ValidationError
from zarr_cm import validate_convention_metadata_object

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

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

return len(errors) == 0, errors
Expand Down
7 changes: 6 additions & 1 deletion tests/test_conventions/test_multiscales.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,16 @@ def test_basic_layout(self) -> None:
multiscales = Multiscales(
layout=(
ScaleLevel(asset="0"),
ScaleLevel(asset="1", derived_from="0"),
ScaleLevel(asset="1", derived_from="0", transform=Transform(scale=(2.0, 2.0))),
)
)
assert len(multiscales.layout) == 2

def test_derived_from_requires_transform(self) -> None:
"""Test that a derived level without a transform fails validation."""
with pytest.raises(ValidationError, match="derived_from"):
Multiscales(layout=(ScaleLevel(asset="1", derived_from="0"),))

def test_empty_layout_fails(self) -> None:
"""Test that empty layout fails validation."""
with pytest.raises(ValidationError, match="at least one level"):
Expand Down
Loading