diff --git a/core/pystac/__init__.py b/core/pystac/__init__.py index 0f1b808b9..a77500518 100644 --- a/core/pystac/__init__.py +++ b/core/pystac/__init__.py @@ -93,9 +93,11 @@ import pystac.extensions.hooks import pystac.extensions.classification import pystac.extensions.datacube +import pystac.extensions.earthquake import pystac.extensions.eo import pystac.extensions.file import pystac.extensions.grid +import pystac.extensions.insar import pystac.extensions.item_assets with warnings.catch_warnings(): @@ -103,12 +105,18 @@ import pystac.extensions.label import pystac.extensions.mgrs import pystac.extensions.mlm +import pystac.extensions.order import pystac.extensions.pointcloud +import pystac.extensions.processing +import pystac.extensions.product import pystac.extensions.projection import pystac.extensions.raster import pystac.extensions.sar import pystac.extensions.sat import pystac.extensions.scientific +import pystac.extensions.sentinel1 +import pystac.extensions.sentinel2 +import pystac.extensions.sentinel3 import pystac.extensions.storage import pystac.extensions.table import pystac.extensions.timestamps @@ -120,19 +128,27 @@ [ pystac.extensions.classification.CLASSIFICATION_EXTENSION_HOOKS, pystac.extensions.datacube.DATACUBE_EXTENSION_HOOKS, + pystac.extensions.earthquake.EARTHQUAKE_EXTENSION_HOOKS, pystac.extensions.eo.EO_EXTENSION_HOOKS, pystac.extensions.file.FILE_EXTENSION_HOOKS, pystac.extensions.grid.GRID_EXTENSION_HOOKS, + pystac.extensions.insar.INSAR_EXTENSION_HOOKS, pystac.extensions.item_assets.ITEM_ASSETS_EXTENSION_HOOKS, pystac.extensions.label.LABEL_EXTENSION_HOOKS, pystac.extensions.mgrs.MGRS_EXTENSION_HOOKS, pystac.extensions.mlm.MLM_EXTENSION_HOOKS, + pystac.extensions.order.ORDER_EXTENSION_HOOKS, pystac.extensions.pointcloud.POINTCLOUD_EXTENSION_HOOKS, + pystac.extensions.processing.PROCESSING_EXTENSION_HOOKS, + pystac.extensions.product.PRODUCT_EXTENSION_HOOKS, pystac.extensions.projection.PROJECTION_EXTENSION_HOOKS, pystac.extensions.raster.RASTER_EXTENSION_HOOKS, pystac.extensions.sar.SAR_EXTENSION_HOOKS, pystac.extensions.sat.SAT_EXTENSION_HOOKS, pystac.extensions.scientific.SCIENTIFIC_EXTENSION_HOOKS, + pystac.extensions.sentinel1.SENTINEL1_EXTENSION_HOOKS, + pystac.extensions.sentinel2.SENTINEL2_EXTENSION_HOOKS, + pystac.extensions.sentinel3.SENTINEL3_EXTENSION_HOOKS, pystac.extensions.storage.STORAGE_EXTENSION_HOOKS, pystac.extensions.table.TABLE_EXTENSION_HOOKS, pystac.extensions.timestamps.TIMESTAMPS_EXTENSION_HOOKS, diff --git a/core/pystac/extensions/ext.py b/core/pystac/extensions/ext.py index 0b72e9feb..6a82b6abc 100644 --- a/core/pystac/extensions/ext.py +++ b/core/pystac/extensions/ext.py @@ -14,9 +14,11 @@ ) from pystac.extensions.classification import ClassificationExtension from pystac.extensions.datacube import DatacubeExtension +from pystac.extensions.earthquake import EarthquakeExtension from pystac.extensions.eo import EOExtension from pystac.extensions.file import FileExtension from pystac.extensions.grid import GridExtension +from pystac.extensions.insar import InsarExtension from pystac.extensions.item_assets import ItemAssetsExtension from pystac.extensions.mgrs import MgrsExtension from pystac.extensions.mlm import ( @@ -24,13 +26,19 @@ AssetGeneralMLMExtension, MLMExtension, ) +from pystac.extensions.order import OrderExtension from pystac.extensions.pointcloud import PointcloudExtension +from pystac.extensions.processing import ProcessingExtension +from pystac.extensions.product import ProductExtension from pystac.extensions.projection import ProjectionExtension from pystac.extensions.raster import RasterExtension from pystac.extensions.render import Render, RenderExtension from pystac.extensions.sar import SarExtension from pystac.extensions.sat import SatExtension from pystac.extensions.scientific import ScientificExtension +from pystac.extensions.sentinel1 import Sentinel1Extension +from pystac.extensions.sentinel2 import Sentinel2Extension +from pystac.extensions.sentinel3 import Sentinel3Extension from pystac.extensions.storage import StorageExtension from pystac.extensions.table import TableExtension from pystac.extensions.timestamps import TimestampsExtension @@ -48,16 +56,24 @@ EXTENSION_NAMES = Literal[ "classification", "cube", + "eq", "eo", "file", "grid", + "insar", "item_assets", "mgrs", "mlm", + "order", "pc", + "processing", + "product", "proj", "raster", "render", + "s1", + "s2", + "s3", "sar", "sat", "sci", @@ -72,16 +88,24 @@ EXTENSION_NAME_MAPPING: dict[EXTENSION_NAMES, Any] = { ClassificationExtension.name: ClassificationExtension, DatacubeExtension.name: DatacubeExtension, + EarthquakeExtension.name: EarthquakeExtension, EOExtension.name: EOExtension, FileExtension.name: FileExtension, GridExtension.name: GridExtension, + InsarExtension.name: InsarExtension, ItemAssetsExtension.name: ItemAssetsExtension, MgrsExtension.name: MgrsExtension, MLMExtension.name: MLMExtension, + OrderExtension.name: OrderExtension, PointcloudExtension.name: PointcloudExtension, + ProcessingExtension.name: ProcessingExtension, + ProductExtension.name: ProductExtension, ProjectionExtension.name: ProjectionExtension, RasterExtension.name: RasterExtension, RenderExtension.name: RenderExtension, + Sentinel1Extension.name: Sentinel1Extension, + Sentinel2Extension.name: Sentinel2Extension, + Sentinel3Extension.name: Sentinel3Extension, SarExtension.name: SarExtension, SatExtension.name: SatExtension, ScientificExtension.name: ScientificExtension, @@ -156,6 +180,14 @@ class CollectionExt(CatalogExt): def cube(self) -> DatacubeExtension[Collection]: return DatacubeExtension.ext(self.stac_object) + @property + def order(self) -> OrderExtension[Collection]: + return OrderExtension.ext(self.stac_object) + + @property + def product(self) -> ProductExtension[Collection]: + return ProductExtension.ext(self.stac_object) + @property def item_assets(self) -> dict[str, ItemAssetDefinition]: return ItemAssetsExtension.ext(self.stac_object).item_assets @@ -228,6 +260,10 @@ def classification(self) -> ClassificationExtension[Item]: def cube(self) -> DatacubeExtension[Item]: return DatacubeExtension.ext(self.stac_object) + @property + def eq(self) -> EarthquakeExtension[Item]: + return EarthquakeExtension.ext(self.stac_object) + @property def eo(self) -> EOExtension[Item]: return EOExtension.ext(self.stac_object) @@ -236,6 +272,10 @@ def eo(self) -> EOExtension[Item]: def grid(self) -> GridExtension: return GridExtension.ext(self.stac_object) + @property + def insar(self) -> InsarExtension[Item]: + return InsarExtension.ext(self.stac_object) + @property def mgrs(self) -> MgrsExtension: return MgrsExtension.ext(self.stac_object) @@ -244,10 +284,22 @@ def mgrs(self) -> MgrsExtension: def mlm(self) -> MLMExtension[Item]: return MLMExtension.ext(self.stac_object) + @property + def order(self) -> OrderExtension[Item]: + return OrderExtension.ext(self.stac_object) + @property def pc(self) -> PointcloudExtension[Item]: return PointcloudExtension.ext(self.stac_object) + @property + def processing(self) -> ProcessingExtension[Item]: + return ProcessingExtension.ext(self.stac_object) + + @property + def product(self) -> ProductExtension[Item]: + return ProductExtension.ext(self.stac_object) + @property def proj(self) -> ProjectionExtension[Item]: return ProjectionExtension.ext(self.stac_object) @@ -256,6 +308,18 @@ def proj(self) -> ProjectionExtension[Item]: def render(self) -> RenderExtension[Item]: return RenderExtension.ext(self.stac_object) + @property + def s1(self) -> Sentinel1Extension[Item]: + return Sentinel1Extension.ext(self.stac_object) + + @property + def s2(self) -> Sentinel2Extension[Item]: + return Sentinel2Extension.ext(self.stac_object) + + @property + def s3(self) -> Sentinel3Extension[Item]: + return Sentinel3Extension.ext(self.stac_object) + @property def sar(self) -> SarExtension[Item]: return SarExtension.ext(self.stac_object) @@ -355,14 +419,34 @@ def classification(self) -> ClassificationExtension[U]: def cube(self) -> DatacubeExtension[U]: return DatacubeExtension.ext(self.stac_object) + @property + def eq(self) -> EarthquakeExtension[U]: + return EarthquakeExtension.ext(self.stac_object) + @property def eo(self) -> EOExtension[U]: return EOExtension.ext(self.stac_object) + @property + def insar(self) -> InsarExtension[U]: + return InsarExtension.ext(self.stac_object) + @property def pc(self) -> PointcloudExtension[U]: return PointcloudExtension.ext(self.stac_object) + @property + def order(self) -> OrderExtension[U]: + return OrderExtension.ext(self.stac_object) + + @property + def processing(self) -> ProcessingExtension[U]: + return ProcessingExtension.ext(self.stac_object) + + @property + def product(self) -> ProductExtension[U]: + return ProductExtension.ext(self.stac_object) + @property def proj(self) -> ProjectionExtension[U]: return ProjectionExtension.ext(self.stac_object) @@ -371,6 +455,10 @@ def proj(self) -> ProjectionExtension[U]: def raster(self) -> RasterExtension[U]: return RasterExtension.ext(self.stac_object) + @property + def s3(self) -> Sentinel3Extension[U]: + return Sentinel3Extension.ext(self.stac_object) + @property def sar(self) -> SarExtension[U]: return SarExtension.ext(self.stac_object) @@ -436,6 +524,22 @@ class ItemAssetExt(_AssetExt[ItemAssetDefinition]): def mlm(self) -> MLMExtension[ItemAssetDefinition]: return MLMExtension.ext(self.stac_object) + @property + def order(self) -> OrderExtension[ItemAssetDefinition]: + return OrderExtension.ext(self.stac_object) + + @property + def processing(self) -> ProcessingExtension[ItemAssetDefinition]: + return ProcessingExtension.ext(self.stac_object) + + @property + def product(self) -> ProductExtension[ItemAssetDefinition]: + return ProductExtension.ext(self.stac_object) + + @property + def s3(self) -> Sentinel3Extension[ItemAssetDefinition]: + return Sentinel3Extension.ext(self.stac_object) + @property def storage(self) -> StorageExtension[ItemAssetDefinition]: return StorageExtension.ext(self.stac_object) diff --git a/docs/api/extensions.rst b/docs/api/extensions.rst index ce37d3215..2484d5b6d 100644 --- a/docs/api/extensions.rst +++ b/docs/api/extensions.rst @@ -15,21 +15,29 @@ pystac.extensions classification.ClassificationExtension datacube.DatacubeExtension + earthquake.EarthquakeExtension eo.EOExtension file.FileExtension grid.GridExtension + insar.InsarExtension item_assets.ItemAssetsExtension mgrs.MgrsExtension mlm.MLMExtension mlm.AssetGeneralMLMExtension mlm.AssetDetailedMLMExtension + order.OrderExtension pointcloud.PointcloudExtension + processing.ProcessingExtension + product.ProductExtension projection.ProjectionExtension raster.RasterExtension render.RenderExtension sar.SarExtension sat.SatExtension scientific.ScientificExtension + sentinel1.Sentinel1Extension + sentinel2.Sentinel2Extension + sentinel3.Sentinel3Extension storage.StorageExtension table.TableExtension timestamps.TimestampsExtension diff --git a/docs/api/extensions/earthquake.rst b/docs/api/extensions/earthquake.rst new file mode 100644 index 000000000..4530d3840 --- /dev/null +++ b/docs/api/extensions/earthquake.rst @@ -0,0 +1,6 @@ +pystac.extensions.earthquake +============================ + +.. automodule:: pystac.extensions.earthquake + :members: + :undoc-members: diff --git a/docs/api/extensions/insar.rst b/docs/api/extensions/insar.rst new file mode 100644 index 000000000..bb58f6b60 --- /dev/null +++ b/docs/api/extensions/insar.rst @@ -0,0 +1,6 @@ +pystac.extensions.insar +======================= + +.. automodule:: pystac.extensions.insar + :members: + :undoc-members: diff --git a/docs/api/extensions/order.rst b/docs/api/extensions/order.rst new file mode 100644 index 000000000..ea03430cb --- /dev/null +++ b/docs/api/extensions/order.rst @@ -0,0 +1,6 @@ +pystac.extensions.order +======================= + +.. automodule:: pystac.extensions.order + :members: + :undoc-members: diff --git a/docs/api/extensions/processing.rst b/docs/api/extensions/processing.rst new file mode 100644 index 000000000..8ee0084eb --- /dev/null +++ b/docs/api/extensions/processing.rst @@ -0,0 +1,6 @@ +pystac.extensions.processing +============================ + +.. automodule:: pystac.extensions.processing + :members: + :undoc-members: diff --git a/docs/api/extensions/product.rst b/docs/api/extensions/product.rst new file mode 100644 index 000000000..4d1b4daac --- /dev/null +++ b/docs/api/extensions/product.rst @@ -0,0 +1,6 @@ +pystac.extensions.product +========================= + +.. automodule:: pystac.extensions.product + :members: + :undoc-members: diff --git a/docs/api/extensions/sentinel1.rst b/docs/api/extensions/sentinel1.rst new file mode 100644 index 000000000..539ddee9e --- /dev/null +++ b/docs/api/extensions/sentinel1.rst @@ -0,0 +1,6 @@ +pystac.extensions.sentinel1 +=========================== + +.. automodule:: pystac.extensions.sentinel1 + :members: + :undoc-members: diff --git a/docs/api/extensions/sentinel2.rst b/docs/api/extensions/sentinel2.rst new file mode 100644 index 000000000..0182aa470 --- /dev/null +++ b/docs/api/extensions/sentinel2.rst @@ -0,0 +1,6 @@ +pystac.extensions.sentinel2 +=========================== + +.. automodule:: pystac.extensions.sentinel2 + :members: + :undoc-members: diff --git a/docs/api/extensions/sentinel3.rst b/docs/api/extensions/sentinel3.rst new file mode 100644 index 000000000..abf897a97 --- /dev/null +++ b/docs/api/extensions/sentinel3.rst @@ -0,0 +1,19 @@ +pystac.extensions.sentinel3 +=========================== + +.. currentmodule:: pystac.extensions.sentinel3 + +.. py:class:: SRALGSD + + Type alias for Sentinel-3 SRAL GSD mappings with keys ``"along-track"`` + and ``"across-track"``. + +.. py:class:: SLSTRGSD + + Type alias for Sentinel-3 SLSTR GSD mappings with keys ``"S1-S6"`` and + ``"S7-S9 and F1-F2"``. + +.. automodule:: pystac.extensions.sentinel3 + :members: + :undoc-members: + :exclude-members: SRALGSD, SLSTRGSD diff --git a/docs/conf.py b/docs/conf.py index 9af5c5707..22baeb7f5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -256,3 +256,7 @@ ("py:class", "HREF"), # this one partially works ("py:class", "jsonschema.validators.Draft7Validator"), ] + +nitpick_ignore_regex = [ + ("py:class", r"pystac\.extensions\.[^.]+\.T"), +] diff --git a/extensions/earthquake/README.md b/extensions/earthquake/README.md new file mode 100644 index 000000000..c851eb19f --- /dev/null +++ b/extensions/earthquake/README.md @@ -0,0 +1,13 @@ +# pystac-ext-earthquake + +[PySTAC](https://pypi.org/project/pystac/) extension package for the [Earthquake Extension](https://github.com/stac-extensions/earthquake). +This extension provides fields for describing earthquake event metadata, including magnitude, status, sources, felt reports, tsunami flag, and depth. + +## Supported versions + +- [v1.0.0](https://stac-extensions.github.io/earthquake/v1.0.0/schema.json) + +## Versioning + +This package's version corresponds to the version of the extension specification it targets. +When we release updates to the package code without changing the target extension version, we use [post releases](https://packaging.python.org/en/latest/discussions/versioning/#post-releases), e.g. `1.0.0.post1`. diff --git a/extensions/earthquake/pyproject.toml b/extensions/earthquake/pyproject.toml new file mode 100644 index 000000000..5c5f241c7 --- /dev/null +++ b/extensions/earthquake/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "pystac-ext-earthquake" +description = "Earthquake extension for PySTAC" +readme = "README.md" +version = "1.0.0" +authors = [] +maintainers = [] +keywords = ["pystac", "imagery", "raster", "catalog", "STAC", "earthquake"] +license = { text = "Apache-2.0" } +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Natural Language :: English", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +requires-python = ">=3.10" +dependencies = ["pystac-core"] + +[project.urls] +Documentation = "https://pystac.readthedocs.io" +Repository = "https://github.com/stac-utils/pystac" +Issues = "https://github.com/stac-utils/pystac/issues" +Changelog = "https://github.com/stac-utils/pystac/blob/main/CHANGELOG.md" +Discussions = "https://github.com/radiantearth/stac-spec/discussions/categories/stac-software" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["pystac"] diff --git a/extensions/earthquake/pystac/extensions/earthquake.py b/extensions/earthquake/pystac/extensions/earthquake.py new file mode 100644 index 000000000..fcea0d450 --- /dev/null +++ b/extensions/earthquake/pystac/extensions/earthquake.py @@ -0,0 +1,278 @@ +"""Implementation of the STAC :stac-ext:`Earthquake Extension `.""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any, Generic, Literal, TypedDict, TypeVar, cast + +import pystac +from pystac.errors import ExtensionTypeError +from pystac.extensions.base import ExtensionManagementMixin, PropertiesExtension +from pystac.extensions.hooks import ExtensionHooks + +SCHEMA_URI: str = "https://stac-extensions.github.io/earthquake/v1.0.0/schema.json" + +MAGNITUDE_PROP = "eq:magnitude" +MAGNITUDE_TYPE_PROP = "eq:magnitude_type" +FELT_PROP = "eq:felt" +STATUS_PROP = "eq:status" +TSUNAMI_PROP = "eq:tsunami" +SOURCES_PROP = "eq:sources" +DEPTH_PROP = "eq:depth" + +MagnitudeType = Literal[ + "mww", + "mwc", + "mwb", + "ms", + "mb", + "mfa", + "ml", + "mlg", + "md", + "mwp", + "me", + "mh", +] +StatusType = Literal["automatic", "reviewed", "deleted"] + + +class EarthquakeSource(TypedDict, total=False): + """A single source entry stored in the ``eq:sources`` field.""" + + name: str + code: str + catalog: str + + +def _validate_magnitude(v: float | None) -> float | None: + if v is None: + return None + if v < 0 or v > 20: + raise ValueError(f"{MAGNITUDE_PROP} must be in [0, 20]. Got: {v}") + return float(v) + + +def _validate_felt(v: int | None) -> int | None: + if v is None: + return None + if v < 0: + raise ValueError(f"{FELT_PROP} must be >= 0. Got: {v}") + return int(v) + + +def _validate_sources( + v: list[EarthquakeSource] | None, +) -> list[EarthquakeSource] | None: + if v is None: + return None + if len(v) < 1: + raise ValueError(f"{SOURCES_PROP} must have at least 1 source.") + + for i, source in enumerate(v): + if "name" not in source or "code" not in source: + raise ValueError( + f"{SOURCES_PROP}[{i}] must include required keys 'name' and 'code'. " + f"Got: {source}" + ) + if not isinstance(source["name"], str) or not source["name"]: + raise ValueError(f"{SOURCES_PROP}[{i}].name must be a non-empty string.") + if not isinstance(source["code"], str) or not source["code"]: + raise ValueError(f"{SOURCES_PROP}[{i}].code must be a non-empty string.") + if "catalog" in source and ( + not isinstance(source["catalog"], str) or not source["catalog"] + ): + raise ValueError( + f"{SOURCES_PROP}[{i}].catalog must be a non-empty string if set." + ) + + return v + + +T = TypeVar("T", pystac.Item, pystac.Asset, pystac.ItemAssetDefinition) + + +class EarthquakeExtension( + Generic[T], + PropertiesExtension, + ExtensionManagementMixin[pystac.Item | pystac.Collection], +): + """ + Implements the STAC Earthquake Extension for Items and also supports reading/writing + extension fields on Assets and Collection Item Asset Definitions. + + Schema: https://stac-extensions.github.io/earthquake/v1.0.0/schema.json + """ + + name: Literal["eq"] = "eq" + + @classmethod + def get_schema_uri(cls) -> str: + """Return the published schema URI for this extension.""" + return SCHEMA_URI + + @classmethod + def ext(cls, obj: T, add_if_missing: bool = False) -> EarthquakeExtension[T]: + """ + Extend an Item, Asset, or ItemAssetDefinition with earthquake fields. + + Args: + obj: The PySTAC object to wrap. + add_if_missing: If ``True``, add the earthquake schema URI to the owning + Item or Collection before returning the extension wrapper. + """ + if isinstance(obj, pystac.Item): + cls.ensure_has_extension(obj, add_if_missing) + return cast(EarthquakeExtension[T], ItemEarthquakeExtension(obj)) + if isinstance(obj, pystac.Asset): + cls.ensure_owner_has_extension(obj, add_if_missing) + return cast(EarthquakeExtension[T], AssetEarthquakeExtension(obj)) + if isinstance(obj, pystac.ItemAssetDefinition): + cls.ensure_owner_has_extension(obj, add_if_missing) + return cast(EarthquakeExtension[T], ItemAssetsEarthquakeExtension(obj)) + raise ExtensionTypeError(cls._ext_error_message(obj)) + + def apply( + self, + *, + magnitude: float, + sources: list[EarthquakeSource], + magnitude_type: MagnitudeType | None = None, + felt: int | None = None, + status: StatusType | None = None, + tsunami: bool | None = None, + depth: float | None = None, + ) -> None: + """ + Apply earthquake fields to the wrapped object. + + Note: schema marks `eq:magnitude` and `eq:sources` as required. + """ + self.magnitude = magnitude + self.sources = sources + self.magnitude_type = magnitude_type + self.felt = felt + self.status = status + self.tsunami = tsunami + self.depth = depth + + @property + def magnitude(self) -> float | None: + """Magnitude of the earthquake event.""" + return self._get_property(MAGNITUDE_PROP, float) + + @magnitude.setter + def magnitude(self, v: float | None) -> None: + self._set_property(MAGNITUDE_PROP, _validate_magnitude(v), pop_if_none=True) + + @property + def magnitude_type(self) -> MagnitudeType | None: + """Magnitude scale used to compute :attr:`magnitude`.""" + return cast(MagnitudeType | None, self._get_property(MAGNITUDE_TYPE_PROP, str)) + + @magnitude_type.setter + def magnitude_type(self, v: MagnitudeType | None) -> None: + self._set_property(MAGNITUDE_TYPE_PROP, v, pop_if_none=True) + + @property + def felt(self) -> int | None: + """Reported number of people who felt the event.""" + return self._get_property(FELT_PROP, int) + + @felt.setter + def felt(self, v: int | None) -> None: + self._set_property(FELT_PROP, _validate_felt(v), pop_if_none=True) + + @property + def status(self) -> StatusType | None: + """Review status of the event metadata.""" + return cast(StatusType | None, self._get_property(STATUS_PROP, str)) + + @status.setter + def status(self, v: StatusType | None) -> None: + self._set_property(STATUS_PROP, v, pop_if_none=True) + + @property + def tsunami(self) -> bool | None: + """Whether the event was associated with a tsunami.""" + return self._get_property(TSUNAMI_PROP, bool) + + @tsunami.setter + def tsunami(self, v: bool | None) -> None: + self._set_property(TSUNAMI_PROP, v, pop_if_none=True) + + @property + def depth(self) -> float | None: + """Depth of the event in kilometers.""" + return self._get_property(DEPTH_PROP, float) + + @depth.setter + def depth(self, v: float | None) -> None: + self._set_property( + DEPTH_PROP, None if v is None else float(v), pop_if_none=True + ) + + @property + def sources(self) -> list[EarthquakeSource] | None: + """Provider-specific source records associated with this event.""" + return cast( + list[EarthquakeSource] | None, + self._get_property(SOURCES_PROP, list), + ) + + @sources.setter + def sources(self, v: list[EarthquakeSource] | None) -> None: + self._set_property(SOURCES_PROP, _validate_sources(v), pop_if_none=True) + + +class ItemEarthquakeExtension(EarthquakeExtension[pystac.Item]): + """Concrete earthquake implementation for :class:`~pystac.Item` objects.""" + + item: pystac.Item + properties: dict[str, Any] + + def __init__(self, item: pystac.Item): + self.item = item + self.properties = item.properties + + def __repr__(self) -> str: + return f"" + + +class AssetEarthquakeExtension(EarthquakeExtension[pystac.Asset]): + """Concrete earthquake implementation for :class:`~pystac.Asset` objects.""" + + asset_href: str + properties: dict[str, Any] + additional_read_properties: Iterable[dict[str, Any]] | None = None + + def __init__(self, asset: pystac.Asset): + self.asset_href = asset.href + self.properties = asset.extra_fields + if asset.owner and isinstance(asset.owner, pystac.Item): + self.additional_read_properties = [asset.owner.properties] + + def __repr__(self) -> str: + return f"" + + +class ItemAssetsEarthquakeExtension(EarthquakeExtension[pystac.ItemAssetDefinition]): + """Concrete earthquake implementation for item asset definitions.""" + + asset_defn: pystac.ItemAssetDefinition + properties: dict[str, Any] + + def __init__(self, item_asset: pystac.ItemAssetDefinition): + self.asset_defn = item_asset + self.properties = item_asset.properties + + +class EarthquakeExtensionHooks(ExtensionHooks): + """Hook registration used when reading or migrating STAC objects.""" + + schema_uri: str = SCHEMA_URI + prev_extension_ids = {"earthquake", "eq"} + stac_object_types = {pystac.STACObjectType.COLLECTION, pystac.STACObjectType.ITEM} + + +EARTHQUAKE_EXTENSION_HOOKS: ExtensionHooks = EarthquakeExtensionHooks() diff --git a/extensions/earthquake/pystac/extensions/py.typed b/extensions/earthquake/pystac/extensions/py.typed new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/extensions/earthquake/pystac/extensions/py.typed @@ -0,0 +1 @@ + diff --git a/extensions/earthquake/tests/test_earthquake.py b/extensions/earthquake/tests/test_earthquake.py new file mode 100644 index 000000000..6bda05e5f --- /dev/null +++ b/extensions/earthquake/tests/test_earthquake.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, cast + +import pytest + +import pystac +from pystac.extensions.earthquake import ( + DEPTH_PROP, + EARTHQUAKE_EXTENSION_HOOKS, + FELT_PROP, + MAGNITUDE_PROP, + MAGNITUDE_TYPE_PROP, + SOURCES_PROP, + STATUS_PROP, + TSUNAMI_PROP, + EarthquakeExtension, + EarthquakeSource, +) + + +def _dt(s: str) -> datetime: + return datetime.fromisoformat(s.replace("Z", "+00:00")) + + +def make_item() -> pystac.Item: + return pystac.Item( + id="eq-item", + geometry=None, + bbox=None, + datetime=_dt("2020-01-01T00:00:00Z"), + properties={}, + start_datetime=None, + end_datetime=None, + ) + + +def test_item_apply_roundtrip() -> None: + item = make_item() + ext = EarthquakeExtension.ext(item, add_if_missing=True) + + sources: list[EarthquakeSource] = [{"name": "usgs", "code": "ak021"}] + ext.apply( + magnitude=6.1, + sources=sources, + magnitude_type="mww", + felt=12, + status="reviewed", + tsunami=False, + depth=8.4, + ) + + assert item.properties[MAGNITUDE_PROP] == 6.1 + assert item.properties[SOURCES_PROP] == sources + assert item.properties[MAGNITUDE_TYPE_PROP] == "mww" + assert item.properties[FELT_PROP] == 12 + assert item.properties[STATUS_PROP] == "reviewed" + assert item.properties[TSUNAMI_PROP] is False + assert item.properties[DEPTH_PROP] == 8.4 + + assert ext.magnitude == 6.1 + assert ext.sources == sources + assert ext.magnitude_type == "mww" + assert ext.status == "reviewed" + + +def test_validation_errors() -> None: + item = make_item() + ext = EarthquakeExtension.ext(item, add_if_missing=True) + + with pytest.raises(ValueError): + ext.magnitude = 25.0 + + with pytest.raises(ValueError): + ext.felt = -1 + + with pytest.raises(ValueError): + ext.sources = [] + + with pytest.raises(ValueError): + ext.sources = cast(Any, [{"name": "usgs"}]) + + +def test_asset_reads_from_owner_and_writes_to_asset() -> None: + item = make_item() + iext = EarthquakeExtension.ext(item, add_if_missing=True) + iext.magnitude = 5.0 + + asset = pystac.Asset(href="s3://bucket/quake.json") + item.add_asset("quake", asset) + + aext = EarthquakeExtension.ext(asset) + assert aext.magnitude == 5.0 + + aext.depth = 7.5 + assert asset.extra_fields[DEPTH_PROP] == 7.5 + + +def test_extension_hooks_are_declared() -> None: + assert EARTHQUAKE_EXTENSION_HOOKS.schema_uri == EarthquakeExtension.get_schema_uri() + assert "eq" in EARTHQUAKE_EXTENSION_HOOKS.prev_extension_ids + assert pystac.STACObjectType.ITEM in EARTHQUAKE_EXTENSION_HOOKS.stac_object_types diff --git a/extensions/insar/README.md b/extensions/insar/README.md new file mode 100644 index 000000000..7e1b16354 --- /dev/null +++ b/extensions/insar/README.md @@ -0,0 +1,13 @@ +# pystac-ext-insar + +[PySTAC](https://pypi.org/project/pystac/) extension package for the [InSAR Extension](https://github.com/stac-extensions/insar). +This extension provides fields for describing interferometric SAR metadata, including baselines, acquisition datetimes, and DEM references. + +## Supported versions + +- [v1.0.0](https://stac-extensions.github.io/insar/v1.0.0/schema.json) + +## Versioning + +This package's version corresponds to the version of the extension specification it targets. +When we release updates to the package code without changing the target extension version, we use [post releases](https://packaging.python.org/en/latest/discussions/versioning/#post-releases), e.g. `1.0.0.post1`. diff --git a/extensions/insar/pyproject.toml b/extensions/insar/pyproject.toml new file mode 100644 index 000000000..46b3d17a2 --- /dev/null +++ b/extensions/insar/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "pystac-ext-insar" +description = "InSAR extension for PySTAC" +readme = "README.md" +version = "1.0.0" +authors = [] +maintainers = [] +keywords = ["pystac", "imagery", "raster", "catalog", "STAC", "insar"] +license = { text = "Apache-2.0" } +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Natural Language :: English", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +requires-python = ">=3.10" +dependencies = ["pystac-core"] + +[project.urls] +Documentation = "https://pystac.readthedocs.io" +Repository = "https://github.com/stac-utils/pystac" +Issues = "https://github.com/stac-utils/pystac/issues" +Changelog = "https://github.com/stac-utils/pystac/blob/main/CHANGELOG.md" +Discussions = "https://github.com/radiantearth/stac-spec/discussions/categories/stac-software" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["pystac"] diff --git a/extensions/insar/pystac/extensions/insar.py b/extensions/insar/pystac/extensions/insar.py new file mode 100644 index 000000000..4d49bf4b2 --- /dev/null +++ b/extensions/insar/pystac/extensions/insar.py @@ -0,0 +1,387 @@ +"""Implementation of the STAC :stac-ext:`InSAR Extension `.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Generic, Literal, TypeVar, cast + +import pystac +from pystac.extensions.base import ( + ExtensionManagementMixin, + PropertiesExtension, + SummariesExtension, +) +from pystac.extensions.hooks import ExtensionHooks +from pystac.utils import datetime_to_str, map_opt, str_to_datetime + +SCHEMA_URI: str = "https://stac-extensions.github.io/insar/v1.0.0/schema.json" + +PREFIX: str = "insar:" +PERP_BASELINE_PROP: str = PREFIX + "perpendicular_baseline" +TEMP_BASELINE_PROP: str = PREFIX + "temporal_baseline" +HOA_PROP: str = PREFIX + "height_of_ambiguity" +REF_DT_PROP: str = PREFIX + "reference_datetime" +SEC_DT_PROP: str = PREFIX + "secondary_datetime" +PROC_DEM_PROP: str = PREFIX + "processing_dem" +GEOC_DEM_PROP: str = PREFIX + "geocoding_dem" + +T = TypeVar("T", pystac.Item, pystac.Asset, pystac.ItemAssetDefinition) + + +def _validated_number(v: float | int | None, field: str) -> float | None: + """Validate and normalize a numeric InSAR property value.""" + if v is None: + return None + if isinstance(v, bool) or not isinstance(v, (int, float)): + raise ValueError(f"Invalid {field}: expected a number, got {type(v).__name__}") + return float(v) + + +def _validated_str(v: str | None, field: str) -> str | None: + """Validate a string-valued InSAR property.""" + if v is None: + return None + if not isinstance(v, str): + raise ValueError(f"Invalid {field}: expected a string, got {type(v).__name__}") + return v + + +class InsarExtension( + Generic[T], + PropertiesExtension, + ExtensionManagementMixin[pystac.Item | pystac.Collection], +): + """ + Item/Asset/ItemAssetDefinition InSAR properties. + For Collection-level support, use InsarExtension.summaries(collection, ...). + """ + + name: Literal["insar"] = "insar" + + @classmethod + def get_schema_uri(cls) -> str: + """Return the published schema URI for this extension.""" + return SCHEMA_URI + + @classmethod + def ext(cls, obj: T, add_if_missing: bool = False) -> InsarExtension[T]: + """ + Extend an Item, Asset, or ItemAssetDefinition with InSAR properties. + + Applies to: + - Item (properties) + - Asset (extra_fields), including Item and Collection assets + - ItemAssetDefinition (collection.item_assets[*].properties) + """ + if isinstance(obj, pystac.Item): + cls.ensure_has_extension(obj, add_if_missing) + return cast(InsarExtension[T], ItemInsarExtension(obj)) + if isinstance(obj, pystac.Asset): + cls.ensure_owner_has_extension(obj, add_if_missing) + return cast(InsarExtension[T], AssetInsarExtension(obj)) + if isinstance(obj, pystac.ItemAssetDefinition): + cls.ensure_owner_has_extension(obj, add_if_missing) + return cast(InsarExtension[T], ItemAssetsInsarExtension(obj)) + if isinstance(obj, pystac.Collection): + raise pystac.ExtensionTypeError( + "InSAR extension does not apply to type 'Collection'. " + "Hint: Did you mean to use `InsarExtension.summaries` instead?" + ) + raise pystac.ExtensionTypeError(cls._ext_error_message(obj)) + + @classmethod + def summaries( + cls, obj: pystac.Collection, add_if_missing: bool = False + ) -> SummariesInsarExtension: + """ + Return the InSAR summaries helper for a collection. + """ + cls.ensure_has_extension(obj, add_if_missing) + return SummariesInsarExtension(obj) + + def apply( + self, + *, + perpendicular_baseline: float | int | None = None, + temporal_baseline: float | int | None = None, + height_of_ambiguity: float | int | None = None, + reference_datetime: datetime | None = None, + secondary_datetime: datetime | None = None, + processing_dem: str | None = None, + geocoding_dem: str | None = None, + ) -> None: + """ + Sets the properties for the InSAR extension. + + Args: + perpendicular_baseline: The perpendicular baseline in meters. + temporal_baseline: The temporal baseline in days. + height_of_ambiguity: The height of ambiguity in meters. + reference_datetime: The reference acquisition datetime. + secondary_datetime: The secondary acquisition datetime. + processing_dem: The DEM used during interferogram processing. + geocoding_dem: The DEM used during geocoding. + """ + self.perpendicular_baseline = perpendicular_baseline + self.temporal_baseline = temporal_baseline + self.height_of_ambiguity = height_of_ambiguity + self.reference_datetime = reference_datetime + self.secondary_datetime = secondary_datetime + self.processing_dem = processing_dem + self.geocoding_dem = geocoding_dem + + @property + def perpendicular_baseline(self) -> float | None: + """ + Gets or sets the perpendicular baseline in meters. + + Returns: + The perpendicular baseline in meters, or None if not set. + """ + return self._get_property(PERP_BASELINE_PROP, float) + + @perpendicular_baseline.setter + def perpendicular_baseline(self, v: float | int | None) -> None: + """ + Sets the perpendicular baseline in meters. + + Args: + v: The perpendicular baseline in meters, or None to remove the property. + """ + self._set_property( + PERP_BASELINE_PROP, + _validated_number(v, PERP_BASELINE_PROP), + pop_if_none=True, + ) + + @property + def temporal_baseline(self) -> float | None: + """ + Gets or sets the temporal baseline in days. + """ + return self._get_property(TEMP_BASELINE_PROP, float) + + @temporal_baseline.setter + def temporal_baseline(self, v: float | int | None) -> None: + """ + Sets the temporal baseline in days. + + Args: + v: The temporal baseline in days, or None to remove the property. + """ + self._set_property( + TEMP_BASELINE_PROP, + _validated_number(v, TEMP_BASELINE_PROP), + pop_if_none=True, + ) + + @property + def height_of_ambiguity(self) -> float | None: + """ + Gets or sets the height of ambiguity in meters. + """ + return self._get_property(HOA_PROP, float) + + @height_of_ambiguity.setter + def height_of_ambiguity(self, v: float | int | None) -> None: + """ + Sets the height of ambiguity in meters. + + Args: + v: The height of ambiguity in meters, or None to remove the property. + """ + self._set_property( + HOA_PROP, + _validated_number(v, HOA_PROP), + pop_if_none=True, + ) + + @property + def reference_datetime(self) -> datetime | None: + """ + Gets or sets the reference acquisition datetime. + """ + return map_opt(str_to_datetime, self._get_property(REF_DT_PROP, str)) + + @reference_datetime.setter + def reference_datetime(self, v: datetime | None) -> None: + """ + Sets the reference acquisition datetime. + """ + self._set_property(REF_DT_PROP, map_opt(datetime_to_str, v), pop_if_none=True) + + @property + def secondary_datetime(self) -> datetime | None: + """ + Gets or sets the secondary acquisition datetime. + """ + return map_opt(str_to_datetime, self._get_property(SEC_DT_PROP, str)) + + @secondary_datetime.setter + def secondary_datetime(self, v: datetime | None) -> None: + """ + Sets the secondary acquisition datetime. + """ + self._set_property(SEC_DT_PROP, map_opt(datetime_to_str, v), pop_if_none=True) + + @property + def processing_dem(self) -> str | None: + """ + Gets or sets the processing DEM. + """ + return self._get_property(PROC_DEM_PROP, str) + + @processing_dem.setter + def processing_dem(self, v: str | None) -> None: + """ + Sets the processing DEM. + + Args: + v: The processing DEM, or None to remove the property. + """ + self._set_property( + PROC_DEM_PROP, + _validated_str(v, PROC_DEM_PROP), + pop_if_none=True, + ) + + @property + def geocoding_dem(self) -> str | None: + """ + Gets or sets the geocoding DEM. + """ + return self._get_property(GEOC_DEM_PROP, str) + + @geocoding_dem.setter + def geocoding_dem(self, v: str | None) -> None: + """ + Sets the geocoding DEM. + + Args: + v: The geocoding DEM, or None to remove the property. + """ + self._set_property( + GEOC_DEM_PROP, + _validated_str(v, GEOC_DEM_PROP), + pop_if_none=True, + ) + + +class ItemInsarExtension(InsarExtension[pystac.Item]): + item: pystac.Item + properties: dict[str, Any] + + def __init__(self, item: pystac.Item): + self.item = item + self.properties = item.properties + + def __repr__(self) -> str: + return f"" + + +class AssetInsarExtension(InsarExtension[pystac.Asset]): + asset_href: str + properties: dict[str, Any] + + def __init__(self, asset: pystac.Asset): + self.asset_href = asset.href + self.properties = asset.extra_fields + + def __repr__(self) -> str: + return f"" + + +class ItemAssetsInsarExtension(InsarExtension[pystac.ItemAssetDefinition]): + asset_defn: pystac.ItemAssetDefinition + properties: dict[str, Any] + + def __init__(self, item_asset: pystac.ItemAssetDefinition): + self.asset_defn = item_asset + self.properties = item_asset.properties + + def __repr__(self) -> str: + return "" + + +class SummariesInsarExtension(SummariesExtension): + def _get_singleton_list_value(self, key: str) -> Any | None: + lst = self.summaries.get_list(key) + if lst is None: + return None + if len(lst) == 0: + return None + return lst[0] + + def _set_singleton_list_value(self, key: str, value: Any | None) -> None: + if value is None: + self.summaries.remove(key) + return + self.summaries.add(key, [value]) + + def apply( + self, + *, + reference_datetime: datetime | None = None, + processing_dem: str | None = None, + geocoding_dem: str | None = None, + ) -> None: + self.reference_datetime = reference_datetime + self.processing_dem = processing_dem + self.geocoding_dem = geocoding_dem + + @property + def reference_datetime(self) -> datetime | None: + raw = self._get_singleton_list_value(REF_DT_PROP) + if raw is None: + return None + if isinstance(raw, str): + return str_to_datetime(raw) + raise ValueError( + f"Invalid {REF_DT_PROP} summary: expected RFC3339 string in list, " + f"got {type(raw).__name__}" + ) + + @reference_datetime.setter + def reference_datetime(self, v: datetime | None) -> None: + self._set_singleton_list_value(REF_DT_PROP, map_opt(datetime_to_str, v)) + + @property + def processing_dem(self) -> str | None: + raw = self._get_singleton_list_value(PROC_DEM_PROP) + if raw is None: + return None + if isinstance(raw, str): + return raw + raise ValueError( + f"Invalid {PROC_DEM_PROP} summary: expected string in list, " + f"got {type(raw).__name__}" + ) + + @processing_dem.setter + def processing_dem(self, v: str | None) -> None: + self._set_singleton_list_value(PROC_DEM_PROP, _validated_str(v, PROC_DEM_PROP)) + + @property + def geocoding_dem(self) -> str | None: + raw = self._get_singleton_list_value(GEOC_DEM_PROP) + if raw is None: + return None + if isinstance(raw, str): + return raw + raise ValueError( + f"Invalid {GEOC_DEM_PROP} summary: expected string in list, " + f"got {type(raw).__name__}" + ) + + @geocoding_dem.setter + def geocoding_dem(self, v: str | None) -> None: + self._set_singleton_list_value(GEOC_DEM_PROP, _validated_str(v, GEOC_DEM_PROP)) + + +class InsarExtensionHooks(ExtensionHooks): + schema_uri: str = SCHEMA_URI + prev_extension_ids = {"insar"} + stac_object_types = {pystac.STACObjectType.COLLECTION, pystac.STACObjectType.ITEM} + + +INSAR_EXTENSION_HOOKS: ExtensionHooks = InsarExtensionHooks() diff --git a/extensions/insar/pystac/extensions/py.typed b/extensions/insar/pystac/extensions/py.typed new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/extensions/insar/pystac/extensions/py.typed @@ -0,0 +1 @@ + diff --git a/extensions/insar/tests/test_insar.py b/extensions/insar/tests/test_insar.py new file mode 100644 index 000000000..17cd09a99 --- /dev/null +++ b/extensions/insar/tests/test_insar.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, TypeAlias, cast + +import pytest + +import pystac +from pystac.extensions.insar import ( + GEOC_DEM_PROP, + HOA_PROP, + PERP_BASELINE_PROP, + PROC_DEM_PROP, + REF_DT_PROP, + SEC_DT_PROP, + TEMP_BASELINE_PROP, + InsarExtension, +) + +TemporalIntervals: TypeAlias = list[list[datetime | None]] + + +def _dt(s: str) -> datetime: + # Accept RFC3339 "Z" and return tz-aware datetime + return datetime.fromisoformat(s.replace("Z", "+00:00")) + + +def make_item() -> pystac.Item: + return pystac.Item( + id="test", + geometry=None, + bbox=None, + datetime=_dt("2020-01-01T00:00:00Z"), + properties={}, + start_datetime=None, + end_datetime=None, + ) + + +def make_collection() -> pystac.Collection: + temporal_intervals: TemporalIntervals = [[None, None]] + return pystac.Collection( + id="c", + description="d", + extent=pystac.Extent( + pystac.SpatialExtent([[-180.0, -90.0, 180.0, 90.0]]), + pystac.TemporalExtent(temporal_intervals), + ), + license="proprietary", + ) + + +def test_item_ext_adds_extension_when_requested() -> None: + item = make_item() + assert InsarExtension.get_schema_uri() not in item.stac_extensions + + ext = InsarExtension.ext(item, add_if_missing=True) + assert InsarExtension.get_schema_uri() in item.stac_extensions + assert ext.perpendicular_baseline is None + + +def test_item_apply_roundtrip_and_serialization() -> None: + item = make_item() + ext = InsarExtension.ext(item, add_if_missing=True) + + ref = _dt("2023-02-01T00:00:00Z") + sec = _dt("2023-02-28T23:59:59Z") + + ext.apply( + perpendicular_baseline=123.4, + temporal_baseline=12, + height_of_ambiguity=42, + reference_datetime=ref, + secondary_datetime=sec, + processing_dem="s3://bucket/dem1.tif", + geocoding_dem="s3://bucket/dem2.tif", + ) + + assert item.properties[PERP_BASELINE_PROP] == 123.4 + assert item.properties[TEMP_BASELINE_PROP] == 12.0 + assert item.properties[HOA_PROP] == 42.0 + assert item.properties[REF_DT_PROP].endswith("Z") + assert item.properties[SEC_DT_PROP].endswith("Z") + assert item.properties[PROC_DEM_PROP] == "s3://bucket/dem1.tif" + assert item.properties[GEOC_DEM_PROP] == "s3://bucket/dem2.tif" + + # getters + assert ext.perpendicular_baseline == 123.4 + assert ext.temporal_baseline == 12.0 + assert ext.height_of_ambiguity == 42.0 + assert ext.reference_datetime == ref + assert ext.secondary_datetime == sec + assert ext.processing_dem == "s3://bucket/dem1.tif" + assert ext.geocoding_dem == "s3://bucket/dem2.tif" + + # pop_if_none behavior + ext.processing_dem = None + assert PROC_DEM_PROP not in item.properties + + +def test_validations_raise_value_error() -> None: + item = make_item() + ext = InsarExtension.ext(item, add_if_missing=True) + + with pytest.raises(ValueError): + ext.perpendicular_baseline = True # bool is not a number + + with pytest.raises(ValueError): + ext.processing_dem = 123 # type: ignore[assignment] + + +def test_asset_ext_adds_extension_to_owner() -> None: + item = make_item() + asset = pystac.Asset(href="s3://bucket/a.tif") + item.add_asset("data", asset) + + assert InsarExtension.get_schema_uri() not in item.stac_extensions + aext = InsarExtension.ext(asset, add_if_missing=True) + assert InsarExtension.get_schema_uri() in item.stac_extensions + + aext.temporal_baseline = 3 + assert asset.extra_fields[TEMP_BASELINE_PROP] == 3.0 + + +def test_collection_ext_is_not_supported() -> None: + col = make_collection() + with pytest.raises(pystac.ExtensionTypeError): + InsarExtension.ext(cast(Any, col)) + + +def test_collection_summaries_singleton_storage_and_removal() -> None: + col = make_collection() + sext = InsarExtension.summaries(col, add_if_missing=True) + + ref = _dt("2023-02-01T00:00:00Z") + sext.apply(reference_datetime=ref, processing_dem="demA", geocoding_dem="demB") + + # Stored as singleton lists + assert col.summaries.lists[REF_DT_PROP] == ["2023-02-01T00:00:00Z"] + assert col.summaries.lists[PROC_DEM_PROP] == ["demA"] + assert col.summaries.lists[GEOC_DEM_PROP] == ["demB"] + + assert sext.reference_datetime == ref + assert sext.processing_dem == "demA" + assert sext.geocoding_dem == "demB" + + # Removal + sext.processing_dem = None + assert PROC_DEM_PROP not in col.summaries.lists diff --git a/extensions/order/README.md b/extensions/order/README.md new file mode 100644 index 000000000..2452db832 --- /dev/null +++ b/extensions/order/README.md @@ -0,0 +1,13 @@ +# pystac-ext-order + +[PySTAC](https://pypi.org/project/pystac/) extension package for the [Order Extension](https://github.com/stac-extensions/order). +This extension provides fields for describing order status and order metadata on STAC objects. + +## Supported versions + +- [v1.1.0](https://stac-extensions.github.io/order/v1.1.0/schema.json) + +## Versioning + +This package's version corresponds to the version of the extension specification it targets. +When we release updates to the package code without changing the target extension version, we use [post releases](https://packaging.python.org/en/latest/discussions/versioning/#post-releases), e.g. `1.1.0.post1`. diff --git a/extensions/order/pyproject.toml b/extensions/order/pyproject.toml new file mode 100644 index 000000000..4dd692552 --- /dev/null +++ b/extensions/order/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "pystac-ext-order" +description = "Order extension for PySTAC" +readme = "README.md" +version = "1.1.0" +authors = [] +maintainers = [] +keywords = ["pystac", "imagery", "raster", "catalog", "STAC", "order"] +license = { text = "Apache-2.0" } +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Natural Language :: English", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +requires-python = ">=3.10" +dependencies = ["pystac-core"] + +[project.urls] +Documentation = "https://pystac.readthedocs.io" +Repository = "https://github.com/stac-utils/pystac" +Issues = "https://github.com/stac-utils/pystac/issues" +Changelog = "https://github.com/stac-utils/pystac/blob/main/CHANGELOG.md" +Discussions = "https://github.com/radiantearth/stac-spec/discussions/categories/stac-software" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["pystac"] diff --git a/extensions/order/pystac/extensions/order.py b/extensions/order/pystac/extensions/order.py new file mode 100644 index 000000000..8e177a0dc --- /dev/null +++ b/extensions/order/pystac/extensions/order.py @@ -0,0 +1,245 @@ +"""Implements the :stac-ext:`Order Extension `.""" + +from __future__ import annotations + +import warnings +from collections.abc import Iterable +from datetime import datetime +from typing import Any, Generic, Literal, TypeVar, cast + +import pystac +from pystac.extensions.base import ( + ExtensionManagementMixin, + PropertiesExtension, + SummariesExtension, +) +from pystac.extensions.hooks import ExtensionHooks +from pystac.utils import ( + StringEnum, + datetime_to_str, + get_required, + map_opt, + str_to_datetime, +) + +T = TypeVar( + "T", + pystac.Item, + pystac.Collection, + pystac.Asset, + pystac.ItemAssetDefinition, +) + +SCHEMA_URI: str = "https://stac-extensions.github.io/order/v1.1.0/schema.json" + +PREFIX: str = "order:" +STATUS_PROP: str = PREFIX + "status" +ID_PROP: str = PREFIX + "id" +DATE_PROP: str = PREFIX + "date" +EXPIRATION_DATE_PROP: str = PREFIX + "expiration_date" + + +class OrderStatus(StringEnum): + ORDERABLE = "orderable" + ORDERED = "ordered" + PENDING = "pending" + SHIPPING = "shipping" + SUCCEEDED = "succeeded" + FAILED = "failed" + CANCELED = "canceled" + + +class OrderExtension( + Generic[T], + PropertiesExtension, + ExtensionManagementMixin[pystac.Item | pystac.Collection], +): + """Extension API for the STAC Order Extension.""" + + name: Literal["order"] = "order" + + def apply( + self, + status: OrderStatus, + order_id: str | None = None, + date: datetime | None = None, + expiration_date: datetime | None = None, + ) -> None: + self.status = status + self.order_id = order_id + self.date = date + if expiration_date is not None: + self.expiration_date = expiration_date + + @classmethod + def get_schema_uri(cls) -> str: + return SCHEMA_URI + + @property + def status(self) -> OrderStatus: + return get_required( + map_opt(lambda x: OrderStatus(x), self._get_property(STATUS_PROP, str)), + self, + STATUS_PROP, + ) + + @status.setter + def status(self, v: OrderStatus) -> None: + self._set_property(STATUS_PROP, v.value, pop_if_none=False) + + @property + def order_id(self) -> str | None: + return self._get_property(ID_PROP, str) + + @order_id.setter + def order_id(self, v: str | None) -> None: + self._set_property(ID_PROP, v, pop_if_none=True) + + @property + def date(self) -> datetime | None: + return map_opt(lambda s: str_to_datetime(s), self._get_property(DATE_PROP, str)) + + @date.setter + def date(self, v: datetime | None) -> None: + self._set_property(DATE_PROP, map_opt(datetime_to_str, v), pop_if_none=True) + + @property + def expiration_date(self) -> datetime | None: + warnings.warn( + f"'{EXPIRATION_DATE_PROP}' is deprecated in the Order Extension schema.", + DeprecationWarning, + stacklevel=2, + ) + return map_opt( + lambda s: str_to_datetime(s), + self._get_property(EXPIRATION_DATE_PROP, str), + ) + + @expiration_date.setter + def expiration_date(self, v: datetime | None) -> None: + warnings.warn( + f"'{EXPIRATION_DATE_PROP}' is deprecated in the Order Extension schema.", + DeprecationWarning, + stacklevel=2, + ) + self._set_property( + EXPIRATION_DATE_PROP, map_opt(datetime_to_str, v), pop_if_none=True + ) + + @classmethod + def ext(cls, obj: T, add_if_missing: bool = False) -> OrderExtension[T]: + if isinstance(obj, pystac.Item): + cls.ensure_has_extension(obj, add_if_missing) + return cast(OrderExtension[T], ItemOrderExtension(obj)) + if isinstance(obj, pystac.Collection): + cls.ensure_has_extension(obj, add_if_missing) + return cast(OrderExtension[T], CollectionOrderExtension(obj)) + if isinstance(obj, pystac.Asset): + if obj.owner is not None and not isinstance( + obj.owner, (pystac.Item, pystac.Collection) + ): + raise pystac.ExtensionTypeError( + "Order extension only applies to Assets owned by Item or " + "Collection." + ) + cls.ensure_owner_has_extension(obj, add_if_missing) + return cast(OrderExtension[T], AssetOrderExtension(obj)) + if isinstance(obj, pystac.ItemAssetDefinition): + cls.ensure_owner_has_extension(obj, add_if_missing) + return cast(OrderExtension[T], ItemAssetsOrderExtension(obj)) + raise pystac.ExtensionTypeError(cls._ext_error_message(obj)) + + @classmethod + def summaries( + cls, obj: pystac.Collection, add_if_missing: bool = False + ) -> SummariesOrderExtension: + cls.ensure_has_extension(obj, add_if_missing) + return SummariesOrderExtension(obj) + + +class ItemOrderExtension(OrderExtension[pystac.Item]): + item: pystac.Item + properties: dict[str, Any] + + def __init__(self, item: pystac.Item): + self.item = item + self.properties = item.properties + + def __repr__(self) -> str: + return f"" + + +class CollectionOrderExtension(OrderExtension[pystac.Collection]): + collection: pystac.Collection + properties: dict[str, Any] + + def __init__(self, collection: pystac.Collection): + self.collection = collection + self.properties = collection.extra_fields + + def __repr__(self) -> str: + return f"" + + +class AssetOrderExtension(OrderExtension[pystac.Asset]): + asset_href: str + properties: dict[str, Any] + additional_read_properties: Iterable[dict[str, Any]] | None = None + + def __init__(self, asset: pystac.Asset): + self.asset_href = asset.href + self.properties = asset.extra_fields + if asset.owner and isinstance(asset.owner, pystac.Item): + self.additional_read_properties = [asset.owner.properties] + elif asset.owner and isinstance(asset.owner, pystac.Collection): + self.additional_read_properties = [asset.owner.extra_fields] + + def __repr__(self) -> str: + return f"" + + +class ItemAssetsOrderExtension(OrderExtension[pystac.ItemAssetDefinition]): + asset_defn: pystac.ItemAssetDefinition + properties: dict[str, Any] + + def __init__(self, item_asset: pystac.ItemAssetDefinition): + self.asset_defn = item_asset + self.properties = item_asset.properties + + def __repr__(self) -> str: + return "" + + +class SummariesOrderExtension(SummariesExtension): + @property + def status(self) -> list[OrderStatus] | None: + return self.summaries.get_list(STATUS_PROP) + + @status.setter + def status(self, v: list[OrderStatus] | None) -> None: + self._set_summary(STATUS_PROP, v) + + @property + def order_id(self) -> list[str] | None: + return self.summaries.get_list(ID_PROP) + + @order_id.setter + def order_id(self, v: list[str] | None) -> None: + self._set_summary(ID_PROP, v) + + @property + def date(self) -> list[str] | None: + return self.summaries.get_list(DATE_PROP) + + @date.setter + def date(self, v: list[str] | None) -> None: + self._set_summary(DATE_PROP, v) + + +class OrderExtensionHooks(ExtensionHooks): + schema_uri: str = SCHEMA_URI + prev_extension_ids = {"order"} + stac_object_types = {pystac.STACObjectType.COLLECTION, pystac.STACObjectType.ITEM} + + +ORDER_EXTENSION_HOOKS: ExtensionHooks = OrderExtensionHooks() diff --git a/extensions/order/pystac/extensions/py.typed b/extensions/order/pystac/extensions/py.typed new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/extensions/order/pystac/extensions/py.typed @@ -0,0 +1 @@ + diff --git a/extensions/order/tests/test_order.py b/extensions/order/tests/test_order.py new file mode 100644 index 000000000..7cc428c78 --- /dev/null +++ b/extensions/order/tests/test_order.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, TypeAlias, cast + +import pytest + +import pystac +from pystac.errors import RequiredPropertyMissing +from pystac.extensions.order import ( + DATE_PROP, + EXPIRATION_DATE_PROP, + ID_PROP, + STATUS_PROP, + AssetOrderExtension, + OrderExtension, + OrderStatus, +) + +TemporalIntervals: TypeAlias = list[list[datetime | None]] + + +def _dt(s: str) -> datetime: + return datetime.fromisoformat(s.replace("Z", "+00:00")) + + +def make_item() -> pystac.Item: + return pystac.Item( + id="i", + geometry=None, + bbox=None, + datetime=_dt("2020-01-01T00:00:00Z"), + properties={}, + start_datetime=None, + end_datetime=None, + ) + + +def make_collection() -> pystac.Collection: + temporal_intervals: TemporalIntervals = [[None, None]] + return pystac.Collection( + id="c", + description="d", + extent=pystac.Extent( + pystac.SpatialExtent([[-180.0, -90.0, 180.0, 90.0]]), + pystac.TemporalExtent(temporal_intervals), + ), + license="proprietary", + ) + + +def test_item_status_is_required() -> None: + item = make_item() + OrderExtension.ext(item, add_if_missing=True) + + # status missing -> get_required should raise + ext = OrderExtension.ext(item) + with pytest.raises(RequiredPropertyMissing): + _ = ext.status + + +def test_item_apply_roundtrip() -> None: + item = make_item() + ext = OrderExtension.ext(item, add_if_missing=True) + + d = _dt("2024-01-01T10:00:00Z") + ext.apply(status=OrderStatus.ORDERED, order_id="123", date=d) + + assert item.properties[STATUS_PROP] == "ordered" + assert item.properties[ID_PROP] == "123" + assert item.properties[DATE_PROP].endswith("Z") + assert ext.status == OrderStatus.ORDERED + assert ext.order_id == "123" + assert ext.date == d + + # pop_if_none on optional fields + ext.order_id = None + assert ID_PROP not in item.properties + + +def test_collection_top_level_fields() -> None: + col = make_collection() + ext = OrderExtension.ext(col, add_if_missing=True) + + ext.status = OrderStatus.PENDING + ext.order_id = "abc" + + assert col.extra_fields[STATUS_PROP] == "pending" + assert col.extra_fields[ID_PROP] == "abc" + + +def test_asset_owner_type_validation() -> None: + # Asset with no owner is allowed when add_if_missing is False. + asset = pystac.Asset(href="s3://bucket/a.tif") + ext = OrderExtension.ext(asset, add_if_missing=False) + assert isinstance(ext, AssetOrderExtension) + assert ext.asset_href == "s3://bucket/a.tif" + + # Make it owned by something invalid (not Item/Collection) + cat = pystac.Catalog(id="cat", description="d") + bad_asset = pystac.Asset(href="s3://bucket/b.tif") + bad_asset.owner = cast(Any, cat) + + with pytest.raises(pystac.ExtensionTypeError): + OrderExtension.ext(bad_asset, add_if_missing=True) + + +def test_expiration_date_is_deprecated_and_roundtrips() -> None: + item = make_item() + ext = OrderExtension.ext(item, add_if_missing=True) + + exp = _dt("2024-02-01T00:00:00Z") + with pytest.warns(DeprecationWarning): + ext.expiration_date = exp + + assert item.properties[EXPIRATION_DATE_PROP].endswith("Z") + + with pytest.warns(DeprecationWarning): + got = ext.expiration_date + assert got == exp + + +def test_summaries_wrapper_sets_lists() -> None: + col = make_collection() + sext = OrderExtension.summaries(col, add_if_missing=True) + + sext.status = [OrderStatus.ORDERABLE, OrderStatus.ORDERED] + + assert col.summaries.lists[STATUS_PROP] == [ + OrderStatus.ORDERABLE, + OrderStatus.ORDERED, + ] diff --git a/extensions/processing/README.md b/extensions/processing/README.md new file mode 100644 index 000000000..73665c4e9 --- /dev/null +++ b/extensions/processing/README.md @@ -0,0 +1,13 @@ +# pystac-ext-processing + +[PySTAC](https://pypi.org/project/pystac/) extension package for the [Processing Extension](https://github.com/stac-extensions/processing). +This extension provides fields for describing processing expression, lineage, level, facility, software, version, and processing datetime. + +## Supported versions + +- [v1.2.0](https://stac-extensions.github.io/processing/v1.2.0/schema.json) + +## Versioning + +This package's version corresponds to the version of the extension specification it targets. +When we release updates to the package code without changing the target extension version, we use [post releases](https://packaging.python.org/en/latest/discussions/versioning/#post-releases), e.g. `1.2.0.post1`. diff --git a/extensions/processing/pyproject.toml b/extensions/processing/pyproject.toml new file mode 100644 index 000000000..153f96594 --- /dev/null +++ b/extensions/processing/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "pystac-ext-processing" +description = "Processing extension for PySTAC" +readme = "README.md" +version = "1.2.0" +authors = [] +maintainers = [] +keywords = ["pystac", "imagery", "raster", "catalog", "STAC", "processing"] +license = { text = "Apache-2.0" } +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Natural Language :: English", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +requires-python = ">=3.10" +dependencies = ["pystac-core"] + +[project.urls] +Documentation = "https://pystac.readthedocs.io" +Repository = "https://github.com/stac-utils/pystac" +Issues = "https://github.com/stac-utils/pystac/issues" +Changelog = "https://github.com/stac-utils/pystac/blob/main/CHANGELOG.md" +Discussions = "https://github.com/radiantearth/stac-spec/discussions/categories/stac-software" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["pystac"] diff --git a/extensions/processing/pystac/extensions/processing.py b/extensions/processing/pystac/extensions/processing.py new file mode 100644 index 000000000..8c81fcaac --- /dev/null +++ b/extensions/processing/pystac/extensions/processing.py @@ -0,0 +1,382 @@ +"""Implements the :stac-ext:`Processing Extension `.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Generic, Literal, TypeVar, cast + +import pystac +from pystac.extensions.base import ( + ExtensionManagementMixin, + PropertiesExtension, + SummariesExtension, +) +from pystac.extensions.hooks import ExtensionHooks +from pystac.utils import datetime_to_str, get_required, map_opt, str_to_datetime + +SCHEMA_URI: str = "https://stac-extensions.github.io/processing/v1.2.0/schema.json" + +PREFIX: str = "processing:" +EXPRESSION_PROP: str = PREFIX + "expression" +LINEAGE_PROP: str = PREFIX + "lineage" +LEVEL_PROP: str = PREFIX + "level" +FACILITY_PROP: str = PREFIX + "facility" +SOFTWARE_PROP: str = PREFIX + "software" +VERSION_PROP: str = PREFIX + "version" +DATETIME_PROP: str = PREFIX + "datetime" + +T = TypeVar("T", pystac.Item, pystac.Asset, pystac.ItemAssetDefinition) + + +class ProcessingExpression: + properties: dict[str, Any] + + def __init__(self, properties: dict[str, Any]) -> None: + self.properties = properties + + @property + def format(self) -> str: + return cast(str, get_required(self.properties.get("format"), self, "format")) + + @format.setter + def format(self, v: str) -> None: + self.properties["format"] = v + + @property + def expression(self) -> Any: + return get_required(self.properties.get("expression"), self, "expression") + + @expression.setter + def expression(self, v: Any) -> None: + self.properties["expression"] = v + + def apply(self, format: str, expression: Any) -> None: + self.format = format + self.expression = expression + + @classmethod + def create(cls, format: str, expression: Any) -> ProcessingExpression: + pe = cls({}) + pe.apply(format=format, expression=expression) + return pe + + def to_dict(self) -> dict[str, Any]: + return self.properties + + def __repr__(self) -> str: + return f"" + + +class ProcessingExtension( + Generic[T], + PropertiesExtension, + ExtensionManagementMixin[pystac.Item | pystac.Collection], +): + name: Literal["processing"] = "processing" + + def apply( + self, + expression: ProcessingExpression | dict[str, Any] | None = None, + lineage: str | None = None, + level: str | None = None, + facility: str | None = None, + software: dict[str, str] | None = None, + version: str | None = None, + processing_datetime: datetime | None = None, + ) -> None: + self.expression = expression + self.lineage = lineage + self.level = level + self.facility = facility + self.software = software + self.version = version + self.processing_datetime = processing_datetime + + @property + def expression(self) -> ProcessingExpression | None: + return map_opt( + lambda d: ProcessingExpression(cast(dict[str, Any], d)), + self._get_property(EXPRESSION_PROP, dict), + ) + + @expression.setter + def expression(self, v: ProcessingExpression | dict[str, Any] | None) -> None: + if isinstance(v, ProcessingExpression): + self._set_property(EXPRESSION_PROP, v.to_dict()) + return + self._set_property(EXPRESSION_PROP, v) + + @property + def lineage(self) -> str | None: + return self._get_property(LINEAGE_PROP, str) + + @lineage.setter + def lineage(self, v: str | None) -> None: + self._set_property(LINEAGE_PROP, v) + + @property + def level(self) -> str | None: + return self._get_property(LEVEL_PROP, str) + + @level.setter + def level(self, v: str | None) -> None: + self._set_property(LEVEL_PROP, v) + + @property + def facility(self) -> str | None: + return self._get_property(FACILITY_PROP, str) + + @facility.setter + def facility(self, v: str | None) -> None: + self._set_property(FACILITY_PROP, v) + + @property + def software(self) -> dict[str, str] | None: + return cast(dict[str, str] | None, self._get_property(SOFTWARE_PROP, dict)) + + @software.setter + def software(self, v: dict[str, str] | None) -> None: + self._set_property(SOFTWARE_PROP, v) + + @property + def version(self) -> str | None: + return self._get_property(VERSION_PROP, str) + + @version.setter + def version(self, v: str | None) -> None: + self._set_property(VERSION_PROP, v) + + @property + def processing_datetime(self) -> datetime | None: + return map_opt(str_to_datetime, self._get_property(DATETIME_PROP, str)) + + @processing_datetime.setter + def processing_datetime(self, v: datetime | None) -> None: + self._set_property(DATETIME_PROP, map_opt(datetime_to_str, v)) + + @classmethod + def get_schema_uri(cls) -> str: + return SCHEMA_URI + + @classmethod + def ext(cls, obj: T, add_if_missing: bool = False) -> ProcessingExtension[T]: + if isinstance(obj, pystac.Item): + cls.ensure_has_extension(obj, add_if_missing) + return cast(ProcessingExtension[T], ItemProcessingExtension(obj)) + if isinstance(obj, pystac.Asset): + cls.ensure_owner_has_extension(obj, add_if_missing) + return cast(ProcessingExtension[T], AssetProcessingExtension(obj)) + if isinstance(obj, pystac.ItemAssetDefinition): + cls.ensure_owner_has_extension(obj, add_if_missing) + return cast(ProcessingExtension[T], ItemAssetsProcessingExtension(obj)) + raise pystac.ExtensionTypeError(cls._ext_error_message(obj)) + + @classmethod + def summaries( + cls, obj: pystac.Collection, add_if_missing: bool = False + ) -> SummariesProcessingExtension: + cls.ensure_has_extension(obj, add_if_missing) + return SummariesProcessingExtension(obj) + + @classmethod + def provider(cls, provider: pystac.Provider) -> ProviderProcessingExtension: + return ProviderProcessingExtension(provider) + + +class ItemProcessingExtension(ProcessingExtension[pystac.Item]): + item: pystac.Item + properties: dict[str, Any] + + def __init__(self, item: pystac.Item): + self.item = item + self.properties = item.properties + + def __repr__(self) -> str: + return f"" + + +class AssetProcessingExtension(ProcessingExtension[pystac.Asset]): + asset_href: str + properties: dict[str, Any] + additional_read_properties: list[dict[str, Any]] | None = None + + def __init__(self, asset: pystac.Asset): + self.asset = asset + self.asset_href = asset.href + self.properties = asset.extra_fields + if asset.owner and isinstance(asset.owner, pystac.Item): + self.additional_read_properties = [asset.owner.properties] + + def __repr__(self) -> str: + return f"" + + +class ItemAssetsProcessingExtension(ProcessingExtension[pystac.ItemAssetDefinition]): + asset_defn: pystac.ItemAssetDefinition + properties: dict[str, Any] + + def __init__(self, item_asset: pystac.ItemAssetDefinition): + self.asset_defn = item_asset + self.properties = item_asset.properties + + def __repr__(self) -> str: + return "" + + +class SummariesProcessingExtension(SummariesExtension): + @property + def expression(self) -> dict[str, Any] | None: + return self.summaries.get_schema(EXPRESSION_PROP) + + @expression.setter + def expression(self, v: dict[str, Any] | None) -> None: + self._set_summary(EXPRESSION_PROP, v) + + @property + def lineage(self) -> list[Any] | None: + return self.summaries.get_list(LINEAGE_PROP) + + @lineage.setter + def lineage(self, v: list[Any] | None) -> None: + self._set_summary(LINEAGE_PROP, v) + + @property + def level(self) -> list[Any] | None: + return self.summaries.get_list(LEVEL_PROP) + + @level.setter + def level(self, v: list[Any] | None) -> None: + self._set_summary(LEVEL_PROP, v) + + @property + def facility(self) -> list[Any] | None: + return self.summaries.get_list(FACILITY_PROP) + + @facility.setter + def facility(self, v: list[Any] | None) -> None: + self._set_summary(FACILITY_PROP, v) + + @property + def software(self) -> dict[str, Any] | None: + return self.summaries.get_schema(SOFTWARE_PROP) + + @software.setter + def software(self, v: dict[str, Any] | None) -> None: + self._set_summary(SOFTWARE_PROP, v) + + @property + def version(self) -> list[Any] | None: + return self.summaries.get_list(VERSION_PROP) + + @version.setter + def version(self, v: list[Any] | None) -> None: + self._set_summary(VERSION_PROP, v) + + @property + def processing_datetime(self) -> list[Any] | None: + return self.summaries.get_list(DATETIME_PROP) + + @processing_datetime.setter + def processing_datetime(self, v: list[Any] | None) -> None: + self._set_summary(DATETIME_PROP, v) + + +class ProviderProcessingExtension(PropertiesExtension): + provider: pystac.Provider + properties: dict[str, Any] + + def __init__(self, provider: pystac.Provider): + self.provider = provider + self.properties = provider.extra_fields + + def apply( + self, + expression: ProcessingExpression | dict[str, Any] | None = None, + lineage: str | None = None, + level: str | None = None, + facility: str | None = None, + software: dict[str, str] | None = None, + version: str | None = None, + processing_datetime: datetime | None = None, + ) -> None: + self.expression = expression + self.lineage = lineage + self.level = level + self.facility = facility + self.software = software + self.version = version + self.processing_datetime = processing_datetime + + @property + def expression(self) -> ProcessingExpression | None: + return map_opt( + lambda d: ProcessingExpression(cast(dict[str, Any], d)), + self._get_property(EXPRESSION_PROP, dict), + ) + + @expression.setter + def expression(self, v: ProcessingExpression | dict[str, Any] | None) -> None: + if isinstance(v, ProcessingExpression): + self._set_property(EXPRESSION_PROP, v.to_dict()) + return + self._set_property(EXPRESSION_PROP, v) + + @property + def lineage(self) -> str | None: + return self._get_property(LINEAGE_PROP, str) + + @lineage.setter + def lineage(self, v: str | None) -> None: + self._set_property(LINEAGE_PROP, v) + + @property + def level(self) -> str | None: + return self._get_property(LEVEL_PROP, str) + + @level.setter + def level(self, v: str | None) -> None: + self._set_property(LEVEL_PROP, v) + + @property + def facility(self) -> str | None: + return self._get_property(FACILITY_PROP, str) + + @facility.setter + def facility(self, v: str | None) -> None: + self._set_property(FACILITY_PROP, v) + + @property + def software(self) -> dict[str, str] | None: + return cast(dict[str, str] | None, self._get_property(SOFTWARE_PROP, dict)) + + @software.setter + def software(self, v: dict[str, str] | None) -> None: + self._set_property(SOFTWARE_PROP, v) + + @property + def version(self) -> str | None: + return self._get_property(VERSION_PROP, str) + + @version.setter + def version(self, v: str | None) -> None: + self._set_property(VERSION_PROP, v) + + @property + def processing_datetime(self) -> datetime | None: + return map_opt(str_to_datetime, self._get_property(DATETIME_PROP, str)) + + @processing_datetime.setter + def processing_datetime(self, v: datetime | None) -> None: + self._set_property(DATETIME_PROP, map_opt(datetime_to_str, v)) + + def __repr__(self) -> str: + return f"" + + +class ProcessingExtensionHooks(ExtensionHooks): + schema_uri: str = SCHEMA_URI + prev_extension_ids = {"processing"} + stac_object_types = {pystac.STACObjectType.COLLECTION, pystac.STACObjectType.ITEM} + + +PROCESSING_EXTENSION_HOOKS: ExtensionHooks = ProcessingExtensionHooks() diff --git a/extensions/processing/pystac/extensions/py.typed b/extensions/processing/pystac/extensions/py.typed new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/extensions/processing/pystac/extensions/py.typed @@ -0,0 +1 @@ + diff --git a/extensions/processing/tests/test_processing.py b/extensions/processing/tests/test_processing.py new file mode 100644 index 000000000..1cee291fd --- /dev/null +++ b/extensions/processing/tests/test_processing.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from datetime import datetime +from typing import TypeAlias + +import pystac +from pystac.extensions.processing import ( + DATETIME_PROP, + EXPRESSION_PROP, + FACILITY_PROP, + LEVEL_PROP, + LINEAGE_PROP, + SOFTWARE_PROP, + VERSION_PROP, + ProcessingExpression, + ProcessingExtension, +) + +TemporalIntervals: TypeAlias = list[list[datetime | None]] + + +def _dt(s: str) -> datetime: + return datetime.fromisoformat(s.replace("Z", "+00:00")) + + +def make_item() -> pystac.Item: + return pystac.Item( + id="i", + geometry=None, + bbox=None, + datetime=_dt("2020-01-01T00:00:00Z"), + properties={}, + start_datetime=None, + end_datetime=None, + ) + + +def make_collection() -> pystac.Collection: + temporal_intervals: TemporalIntervals = [[None, None]] + return pystac.Collection( + id="c", + description="d", + extent=pystac.Extent( + pystac.SpatialExtent([[-180.0, -90.0, 180.0, 90.0]]), + pystac.TemporalExtent(temporal_intervals), + ), + license="proprietary", + ) + + +def test_processing_expression_create_and_to_dict() -> None: + pe = ProcessingExpression.create(format="cwl", expression={"a": 1}) + assert pe.format == "cwl" + assert pe.expression == {"a": 1} + assert pe.to_dict() == {"format": "cwl", "expression": {"a": 1}} + assert "ProcessingExpression" in repr(pe) + + +def test_item_apply_roundtrip() -> None: + item = make_item() + ext = ProcessingExtension.ext(item, add_if_missing=True) + + pe = ProcessingExpression.create(format="cwl", expression={"steps": []}) + dt = _dt("2024-01-01T12:00:00Z") + ext.apply( + expression=pe, + lineage="L2 from L1", + level="L2A", + facility="ESA", + software={"snap": "9.0"}, + version="1.0", + processing_datetime=dt, + ) + + assert item.properties[EXPRESSION_PROP] == { + "format": "cwl", + "expression": {"steps": []}, + } + assert item.properties[LINEAGE_PROP] == "L2 from L1" + assert item.properties[LEVEL_PROP] == "L2A" + assert item.properties[FACILITY_PROP] == "ESA" + assert item.properties[SOFTWARE_PROP] == {"snap": "9.0"} + assert item.properties[VERSION_PROP] == "1.0" + assert item.properties[DATETIME_PROP].endswith("Z") + + assert ext.expression is not None + assert ext.expression.format == "cwl" + assert ext.processing_datetime == dt + + +def test_asset_read_falls_back_to_owner_item_properties() -> None: + item = make_item() + iext = ProcessingExtension.ext(item, add_if_missing=True) + iext.level = "L2B" + + asset = pystac.Asset(href="s3://bucket/a.tif") + item.add_asset("data", asset) + + aext = ProcessingExtension.ext(asset, add_if_missing=True) + # Not set on asset, but should be readable via additional_read_properties fallback + assert aext.level == "L2B" + + aext.level = "L2C" + assert asset.extra_fields[LEVEL_PROP] == "L2C" + assert aext.level == "L2C" + + +def test_summaries_wrapper_sets_lists_and_schema() -> None: + col = make_collection() + sext = ProcessingExtension.summaries(col, add_if_missing=True) + + sext.level = ["L1", "L2"] + sext.software = {"snap": {"type": "string"}} + + assert col.summaries.lists[LEVEL_PROP] == ["L1", "L2"] + assert col.summaries.schemas[SOFTWARE_PROP] == {"snap": {"type": "string"}} + + +def test_provider_wrapper_sets_extra_fields() -> None: + provider = pystac.Provider( + name="ACME", + roles=[pystac.ProviderRole.PROCESSOR], + url="https://example.com", + ) + pext = ProcessingExtension.provider(provider) + + pext.level = "L3" + assert provider.extra_fields[LEVEL_PROP] == "L3" diff --git a/extensions/product/README.md b/extensions/product/README.md new file mode 100644 index 000000000..2cc1efece --- /dev/null +++ b/extensions/product/README.md @@ -0,0 +1,13 @@ +# pystac-ext-product + +[PySTAC](https://pypi.org/project/pystac/) extension package for the [Product Extension](https://github.com/stac-extensions/product). +This extension provides fields for describing product type, timeliness, timeliness category, and acquisition type. + +## Supported versions + +- [v1.0.0](https://stac-extensions.github.io/product/v1.0.0/schema.json) + +## Versioning + +This package's version corresponds to the version of the extension specification it targets. +When we release updates to the package code without changing the target extension version, we use [post releases](https://packaging.python.org/en/latest/discussions/versioning/#post-releases), e.g. `1.0.0.post1`. diff --git a/extensions/product/pyproject.toml b/extensions/product/pyproject.toml new file mode 100644 index 000000000..79b56f341 --- /dev/null +++ b/extensions/product/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "pystac-ext-product" +description = "Product extension for PySTAC" +readme = "README.md" +version = "1.0.0" +authors = [] +maintainers = [] +keywords = ["pystac", "imagery", "raster", "catalog", "STAC", "product"] +license = { text = "Apache-2.0" } +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Natural Language :: English", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +requires-python = ">=3.10" +dependencies = ["pystac-core"] + +[project.urls] +Documentation = "https://pystac.readthedocs.io" +Repository = "https://github.com/stac-utils/pystac" +Issues = "https://github.com/stac-utils/pystac/issues" +Changelog = "https://github.com/stac-utils/pystac/blob/main/CHANGELOG.md" +Discussions = "https://github.com/radiantearth/stac-spec/discussions/categories/stac-software" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["pystac"] diff --git a/extensions/product/pystac/extensions/product.py b/extensions/product/pystac/extensions/product.py new file mode 100644 index 000000000..280a90ed6 --- /dev/null +++ b/extensions/product/pystac/extensions/product.py @@ -0,0 +1,238 @@ +"""Implements the :stac-ext:`Product Extension `.""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any, Generic, Literal, TypeVar, cast + +import pystac +from pystac.extensions.base import ( + ExtensionManagementMixin, + PropertiesExtension, + SummariesExtension, +) +from pystac.extensions.hooks import ExtensionHooks +from pystac.utils import StringEnum + +T = TypeVar( + "T", + pystac.Collection, + pystac.Item, + pystac.Asset, + pystac.ItemAssetDefinition, +) + +SCHEMA_URI: str = "https://stac-extensions.github.io/product/v1.0.0/schema.json" + +PREFIX: str = "product:" +TYPE_PROP: str = PREFIX + "type" +TIMELINESS_PROP: str = PREFIX + "timeliness" +TIMELINESS_CATEGORY_PROP: str = PREFIX + "timeliness_category" +ACQUISITION_TYPE_PROP: str = PREFIX + "acquisition_type" + + +class AcquisitionType(StringEnum): + """Allowed values for product:acquisition_type.""" + + NOMINAL = "nominal" + CALIBRATION = "calibration" + OTHER = "other" + + +class ProductExtension( + Generic[T], + PropertiesExtension, + ExtensionManagementMixin[pystac.Collection | pystac.Item], +): + name: Literal["product"] = "product" + + def apply( + self, + *, + product_type: str | None = None, + timeliness: str | None = None, + timeliness_category: str | None = None, + acquisition_type: AcquisitionType | str | None = None, + ) -> None: + if ( + timeliness_category is not None + and timeliness is None + and self.timeliness is None + ): + raise ValueError( + f"'{TIMELINESS_CATEGORY_PROP}' requires '{TIMELINESS_PROP}' to be set." + ) + + self.product_type = product_type + if timeliness is not None: + self.timeliness = timeliness + if timeliness_category is not None: + self.timeliness_category = timeliness_category + self.acquisition_type = acquisition_type + + @property + def product_type(self) -> str | None: + return self._get_property(TYPE_PROP, str) + + @product_type.setter + def product_type(self, v: str | None) -> None: + self._set_property(TYPE_PROP, v) + + @property + def timeliness(self) -> str | None: + return self._get_property(TIMELINESS_PROP, str) + + @timeliness.setter + def timeliness(self, v: str | None) -> None: + self._set_property(TIMELINESS_PROP, v) + + @property + def timeliness_category(self) -> str | None: + return self._get_property(TIMELINESS_CATEGORY_PROP, str) + + @timeliness_category.setter + def timeliness_category(self, v: str | None) -> None: + if v is not None and self.timeliness is None: + raise ValueError( + f"'{TIMELINESS_CATEGORY_PROP}' requires '{TIMELINESS_PROP}' to be set." + ) + self._set_property(TIMELINESS_CATEGORY_PROP, v) + + @property + def acquisition_type(self) -> AcquisitionType | str | None: + raw = self._get_property(ACQUISITION_TYPE_PROP, str) + if raw is None: + return None + try: + return AcquisitionType(raw) + except Exception: + return raw + + @acquisition_type.setter + def acquisition_type(self, v: AcquisitionType | str | None) -> None: + self._set_property(ACQUISITION_TYPE_PROP, None if v is None else str(v)) + + @classmethod + def get_schema_uri(cls) -> str: + return SCHEMA_URI + + @classmethod + def ext(cls, obj: T, add_if_missing: bool = False) -> ProductExtension[T]: + if isinstance(obj, pystac.Collection): + cls.ensure_has_extension(obj, add_if_missing) + return cast(ProductExtension[T], CollectionProductExtension(obj)) + if isinstance(obj, pystac.Item): + cls.ensure_has_extension(obj, add_if_missing) + return cast(ProductExtension[T], ItemProductExtension(obj)) + if isinstance(obj, pystac.Asset): + cls.ensure_owner_has_extension(obj, add_if_missing) + return cast(ProductExtension[T], AssetProductExtension(obj)) + if isinstance(obj, pystac.ItemAssetDefinition): + cls.ensure_owner_has_extension(obj, add_if_missing) + return cast(ProductExtension[T], ItemAssetsProductExtension(obj)) + raise pystac.ExtensionTypeError(cls._ext_error_message(obj)) + + @classmethod + def summaries( + cls, obj: pystac.Collection, add_if_missing: bool = False + ) -> SummariesProductExtension: + cls.ensure_has_extension(obj, add_if_missing) + return SummariesProductExtension(obj) + + +class CollectionProductExtension(ProductExtension[pystac.Collection]): + collection: pystac.Collection + properties: dict[str, Any] + + def __init__(self, collection: pystac.Collection): + self.collection = collection + self.properties = collection.extra_fields + + def __repr__(self) -> str: + return f"" + + +class ItemProductExtension(ProductExtension[pystac.Item]): + item: pystac.Item + properties: dict[str, Any] + + def __init__(self, item: pystac.Item): + self.item = item + self.properties = item.properties + + def __repr__(self) -> str: + return f"" + + +class AssetProductExtension(ProductExtension[pystac.Asset]): + asset: pystac.Asset + asset_href: str + properties: dict[str, Any] + additional_read_properties: Iterable[dict[str, Any]] | None + + def __init__(self, asset: pystac.Asset): + self.asset = asset + self.asset_href = asset.href + self.properties = asset.extra_fields + if asset.owner and isinstance(asset.owner, pystac.Item): + self.additional_read_properties = [asset.owner.properties] + else: + self.additional_read_properties = None + + def __repr__(self) -> str: + return f"" + + +class ItemAssetsProductExtension(ProductExtension[pystac.ItemAssetDefinition]): + asset_defn: pystac.ItemAssetDefinition + properties: dict[str, Any] + + def __init__(self, item_asset: pystac.ItemAssetDefinition): + self.asset_defn = item_asset + self.properties = item_asset.properties + + def __repr__(self) -> str: + return "" + + +class SummariesProductExtension(SummariesExtension): + @property + def product_type(self) -> list[str] | None: + return self.summaries.get_list(TYPE_PROP) + + @product_type.setter + def product_type(self, v: list[str] | None) -> None: + self._set_summary(TYPE_PROP, v) + + @property + def timeliness(self) -> list[str] | None: + return self.summaries.get_list(TIMELINESS_PROP) + + @timeliness.setter + def timeliness(self, v: list[str] | None) -> None: + self._set_summary(TIMELINESS_PROP, v) + + @property + def timeliness_category(self) -> list[str] | None: + return self.summaries.get_list(TIMELINESS_CATEGORY_PROP) + + @timeliness_category.setter + def timeliness_category(self, v: list[str] | None) -> None: + self._set_summary(TIMELINESS_CATEGORY_PROP, v) + + @property + def acquisition_type(self) -> list[str] | None: + return self.summaries.get_list(ACQUISITION_TYPE_PROP) + + @acquisition_type.setter + def acquisition_type(self, v: list[str] | None) -> None: + self._set_summary(ACQUISITION_TYPE_PROP, v) + + +class ProductExtensionHooks(ExtensionHooks): + schema_uri: str = SCHEMA_URI + prev_extension_ids = {"product"} + stac_object_types = {pystac.STACObjectType.COLLECTION, pystac.STACObjectType.ITEM} + + +PRODUCT_EXTENSION_HOOKS: ExtensionHooks = ProductExtensionHooks() diff --git a/extensions/product/pystac/extensions/py.typed b/extensions/product/pystac/extensions/py.typed new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/extensions/product/pystac/extensions/py.typed @@ -0,0 +1 @@ + diff --git a/extensions/product/tests/test_product.py b/extensions/product/tests/test_product.py new file mode 100644 index 000000000..d9b4e62ed --- /dev/null +++ b/extensions/product/tests/test_product.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from datetime import datetime +from typing import TypeAlias + +import pytest + +import pystac +from pystac.extensions.product import ( + ACQUISITION_TYPE_PROP, + PRODUCT_EXTENSION_HOOKS, + TIMELINESS_CATEGORY_PROP, + TIMELINESS_PROP, + TYPE_PROP, + AcquisitionType, + ProductExtension, +) + +TemporalIntervals: TypeAlias = list[list[datetime | None]] + + +def _dt(s: str) -> datetime: + return datetime.fromisoformat(s.replace("Z", "+00:00")) + + +def make_item() -> pystac.Item: + return pystac.Item( + id="i", + geometry=None, + bbox=None, + datetime=_dt("2020-01-01T00:00:00Z"), + properties={}, + start_datetime=None, + end_datetime=None, + ) + + +def make_collection() -> pystac.Collection: + temporal_intervals: TemporalIntervals = [[None, None]] + return pystac.Collection( + id="c", + description="d", + extent=pystac.Extent( + pystac.SpatialExtent([[-180.0, -90.0, 180.0, 90.0]]), + pystac.TemporalExtent(temporal_intervals), + ), + license="proprietary", + ) + + +def test_apply_requires_timeliness_when_setting_category() -> None: + item = make_item() + ext = ProductExtension.ext(item, add_if_missing=True) + + with pytest.raises(ValueError): + ext.apply(timeliness_category="NRT") # timeliness missing and not already set + + # If timeliness is already set, setting category is allowed + ext.timeliness = "PT3H" + ext.apply(timeliness_category="NRT") + assert item.properties[TIMELINESS_CATEGORY_PROP] == "NRT" + + +def test_item_apply_roundtrip_and_acquisition_type_enum() -> None: + item = make_item() + ext = ProductExtension.ext(item, add_if_missing=True) + + ext.apply( + product_type="SLC", + timeliness="PT3H", + timeliness_category="NRT", + acquisition_type=AcquisitionType.NOMINAL, + ) + + assert item.properties[TYPE_PROP] == "SLC" + assert item.properties[TIMELINESS_PROP] == "PT3H" + assert item.properties[TIMELINESS_CATEGORY_PROP] == "NRT" + assert item.properties[ACQUISITION_TYPE_PROP] == "nominal" + assert ext.acquisition_type == AcquisitionType.NOMINAL + + # Unknown strings should roundtrip as raw strings + ext.acquisition_type = "nonstandard" + assert item.properties[ACQUISITION_TYPE_PROP] == "nonstandard" + assert ext.acquisition_type == "nonstandard" + + +def test_collection_top_level_fields() -> None: + col = make_collection() + ext = ProductExtension.ext(col, add_if_missing=True) + + ext.product_type = "L1C" + assert col.extra_fields[TYPE_PROP] == "L1C" + + +def test_summaries_wrapper_sets_lists() -> None: + col = make_collection() + sext = ProductExtension.summaries(col, add_if_missing=True) + + sext.product_type = ["L1C", "L2A"] + assert col.summaries.lists[TYPE_PROP] == ["L1C", "L2A"] + + +def test_extension_hooks_are_declared() -> None: + assert PRODUCT_EXTENSION_HOOKS.schema_uri == ProductExtension.get_schema_uri() + assert "product" in PRODUCT_EXTENSION_HOOKS.prev_extension_ids + assert pystac.STACObjectType.COLLECTION in PRODUCT_EXTENSION_HOOKS.stac_object_types diff --git a/extensions/sentinel1/README.md b/extensions/sentinel1/README.md new file mode 100644 index 000000000..20c68f205 --- /dev/null +++ b/extensions/sentinel1/README.md @@ -0,0 +1,13 @@ +# pystac-ext-sentinel-1 + +[PySTAC](https://pypi.org/project/pystac/) extension package for the [Sentinel-1 Extension](https://github.com/stac-extensions/sentinel-1). +This extension provides fields for Sentinel-1-specific product, processing, datatake, and shape metadata. + +## Supported versions + +- [v0.2.0](https://stac-extensions.github.io/sentinel-1/v0.2.0/schema.json) + +## Versioning + +This package's version corresponds to the version of the extension specification it targets. +When we release updates to the package code without changing the target extension version, we use [post releases](https://packaging.python.org/en/latest/discussions/versioning/#post-releases), e.g. `0.2.0.post1`. diff --git a/extensions/sentinel1/pyproject.toml b/extensions/sentinel1/pyproject.toml new file mode 100644 index 000000000..fe054f8bd --- /dev/null +++ b/extensions/sentinel1/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "pystac-ext-sentinel-1" +description = "Sentinel-1 extension for PySTAC" +readme = "README.md" +version = "0.2.0" +authors = [] +maintainers = [] +keywords = ["pystac", "imagery", "raster", "catalog", "STAC", "sentinel-1"] +license = { text = "Apache-2.0" } +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Natural Language :: English", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +requires-python = ">=3.10" +dependencies = ["pystac-core"] + +[project.urls] +Documentation = "https://pystac.readthedocs.io" +Repository = "https://github.com/stac-utils/pystac" +Issues = "https://github.com/stac-utils/pystac/issues" +Changelog = "https://github.com/stac-utils/pystac/blob/main/CHANGELOG.md" +Discussions = "https://github.com/radiantearth/stac-spec/discussions/categories/stac-software" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["pystac"] diff --git a/extensions/sentinel1/pystac/extensions/py.typed b/extensions/sentinel1/pystac/extensions/py.typed new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/extensions/sentinel1/pystac/extensions/py.typed @@ -0,0 +1 @@ + diff --git a/extensions/sentinel1/pystac/extensions/sentinel1.py b/extensions/sentinel1/pystac/extensions/sentinel1.py new file mode 100644 index 000000000..58d9dd929 --- /dev/null +++ b/extensions/sentinel1/pystac/extensions/sentinel1.py @@ -0,0 +1,313 @@ +"""Implements the :stac-ext:`Sentinel-1 Extension `.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Generic, Literal, TypeVar, cast + +import pystac +from pystac.extensions.base import ( + ExtensionManagementMixin, + PropertiesExtension, + SummariesExtension, +) +from pystac.extensions.hooks import ExtensionHooks +from pystac.summaries import RangeSummary +from pystac.utils import datetime_to_str, map_opt, str_to_datetime + +T = TypeVar("T", bound=pystac.Item) + +SCHEMA_URI = "https://stac-extensions.github.io/sentinel-1/v0.2.0/schema.json" +PREFIX = "s1:" + +DATATAKE_ID_PROP = PREFIX + "datatake_id" +INSTRUMENT_CONFIGURATION_ID_PROP = PREFIX + "instrument_configuration_ID" +ORBIT_SOURCE_PROP = PREFIX + "orbit_source" +PROCESSING_DATETIME_PROP = PREFIX + "processing_datetime" +PRODUCT_IDENTIFIER_PROP = PREFIX + "product_identifier" +PRODUCT_TIMELINESS_PROP = PREFIX + "product_timeliness" +RESOLUTION_PROP = PREFIX + "resolution" +SLICE_NUMBER_PROP = PREFIX + "slice_number" +TOTAL_SLICES_PROP = PREFIX + "total_slices" +PROCESSING_LEVEL_PROP = PREFIX + "processing_level" +SHAPE_PROP = PREFIX + "shape" + + +def _validate_shape(v: list[int] | None) -> list[int] | None: + if v is None: + return None + if len(v) < 2: + raise ValueError(f"{SHAPE_PROP} must contain at least two integers.") + if not all(isinstance(x, int) for x in v): + raise ValueError(f"{SHAPE_PROP} must contain only integers.") + return v + + +class Sentinel1Extension( + Generic[T], + PropertiesExtension, + ExtensionManagementMixin[pystac.Item | pystac.Collection], +): + name: Literal["s1"] = "s1" + + def apply( + self, + datatake_id: str | None = None, + instrument_configuration_id: str | None = None, + orbit_source: str | None = None, + processing_datetime: datetime | None = None, + product_identifier: str | None = None, + product_timeliness: str | None = None, + resolution: str | None = None, + slice_number: str | None = None, + total_slices: str | None = None, + processing_level: str | None = None, + shape: list[int] | None = None, + ) -> None: + self.datatake_id = datatake_id + self.instrument_configuration_id = instrument_configuration_id + self.orbit_source = orbit_source + self.processing_datetime = processing_datetime + self.product_identifier = product_identifier + self.product_timeliness = product_timeliness + self.resolution = resolution + self.slice_number = slice_number + self.total_slices = total_slices + self.processing_level = processing_level + self.shape = shape + + @property + def datatake_id(self) -> str | None: + return self._get_property(DATATAKE_ID_PROP, str) + + @datatake_id.setter + def datatake_id(self, v: str | None) -> None: + self._set_property(DATATAKE_ID_PROP, v) + + @property + def instrument_configuration_id(self) -> str | None: + return self._get_property(INSTRUMENT_CONFIGURATION_ID_PROP, str) + + @instrument_configuration_id.setter + def instrument_configuration_id(self, v: str | None) -> None: + self._set_property(INSTRUMENT_CONFIGURATION_ID_PROP, v) + + @property + def orbit_source(self) -> str | None: + return self._get_property(ORBIT_SOURCE_PROP, str) + + @orbit_source.setter + def orbit_source(self, v: str | None) -> None: + self._set_property(ORBIT_SOURCE_PROP, v) + + @property + def processing_datetime(self) -> datetime | None: + return map_opt( + str_to_datetime, + self._get_property(PROCESSING_DATETIME_PROP, str), + ) + + @processing_datetime.setter + def processing_datetime(self, v: datetime | None) -> None: + self._set_property(PROCESSING_DATETIME_PROP, map_opt(datetime_to_str, v)) + + @property + def product_identifier(self) -> str | None: + return self._get_property(PRODUCT_IDENTIFIER_PROP, str) + + @product_identifier.setter + def product_identifier(self, v: str | None) -> None: + self._set_property(PRODUCT_IDENTIFIER_PROP, v) + + @property + def product_timeliness(self) -> str | None: + return self._get_property(PRODUCT_TIMELINESS_PROP, str) + + @product_timeliness.setter + def product_timeliness(self, v: str | None) -> None: + self._set_property(PRODUCT_TIMELINESS_PROP, v) + + @property + def resolution(self) -> str | None: + return self._get_property(RESOLUTION_PROP, str) + + @resolution.setter + def resolution(self, v: str | None) -> None: + self._set_property(RESOLUTION_PROP, v) + + @property + def slice_number(self) -> str | None: + return self._get_property(SLICE_NUMBER_PROP, str) + + @slice_number.setter + def slice_number(self, v: str | None) -> None: + self._set_property(SLICE_NUMBER_PROP, v) + + @property + def total_slices(self) -> str | None: + return self._get_property(TOTAL_SLICES_PROP, str) + + @total_slices.setter + def total_slices(self, v: str | None) -> None: + self._set_property(TOTAL_SLICES_PROP, v) + + @property + def processing_level(self) -> str | None: + return self._get_property(PROCESSING_LEVEL_PROP, str) + + @processing_level.setter + def processing_level(self, v: str | None) -> None: + self._set_property(PROCESSING_LEVEL_PROP, v) + + @property + def shape(self) -> list[int] | None: + return cast(list[int] | None, self._get_property(SHAPE_PROP, list)) + + @shape.setter + def shape(self, v: list[int] | None) -> None: + self._set_property(SHAPE_PROP, _validate_shape(v)) + + @classmethod + def get_schema_uri(cls) -> str: + return SCHEMA_URI + + @classmethod + def ext(cls, obj: T, add_if_missing: bool = False) -> Sentinel1Extension[T]: + if isinstance(obj, pystac.Item): + cls.ensure_has_extension(obj, add_if_missing) + return cast(Sentinel1Extension[T], ItemSentinel1Extension(obj)) + raise pystac.ExtensionTypeError(cls._ext_error_message(obj)) + + @classmethod + def summaries( + cls, obj: pystac.Collection, add_if_missing: bool = False + ) -> SummariesSentinel1Extension: + cls.ensure_has_extension(obj, add_if_missing) + return SummariesSentinel1Extension(obj) + + +class ItemSentinel1Extension(Sentinel1Extension[pystac.Item]): + item: pystac.Item + properties: dict[str, Any] + + def __init__(self, item: pystac.Item): + self.item = item + self.properties = item.properties + + def __repr__(self) -> str: + return f"" + + +class SummariesSentinel1Extension(SummariesExtension): + @property + def datatake_id(self) -> list[str] | None: + return cast(list[str] | None, self.summaries.get_list(DATATAKE_ID_PROP)) + + @datatake_id.setter + def datatake_id(self, v: list[str] | None) -> None: + self._set_summary(DATATAKE_ID_PROP, v) + + @property + def instrument_configuration_id(self) -> list[str] | None: + return cast( + list[str] | None, + self.summaries.get_list(INSTRUMENT_CONFIGURATION_ID_PROP), + ) + + @instrument_configuration_id.setter + def instrument_configuration_id(self, v: list[str] | None) -> None: + self._set_summary(INSTRUMENT_CONFIGURATION_ID_PROP, v) + + @property + def orbit_source(self) -> list[str] | None: + return cast(list[str] | None, self.summaries.get_list(ORBIT_SOURCE_PROP)) + + @orbit_source.setter + def orbit_source(self, v: list[str] | None) -> None: + self._set_summary(ORBIT_SOURCE_PROP, v) + + @property + def processing_datetime(self) -> RangeSummary[datetime] | None: + return map_opt( + lambda s: RangeSummary( + str_to_datetime(s.minimum), str_to_datetime(s.maximum) + ), + self.summaries.get_range(PROCESSING_DATETIME_PROP), + ) + + @processing_datetime.setter + def processing_datetime(self, v: RangeSummary[datetime] | None) -> None: + self._set_summary( + PROCESSING_DATETIME_PROP, + map_opt( + lambda s: RangeSummary( + datetime_to_str(s.minimum), datetime_to_str(s.maximum) + ), + v, + ), + ) + + @property + def product_identifier(self) -> list[str] | None: + return cast(list[str] | None, self.summaries.get_list(PRODUCT_IDENTIFIER_PROP)) + + @product_identifier.setter + def product_identifier(self, v: list[str] | None) -> None: + self._set_summary(PRODUCT_IDENTIFIER_PROP, v) + + @property + def product_timeliness(self) -> list[str] | None: + return cast(list[str] | None, self.summaries.get_list(PRODUCT_TIMELINESS_PROP)) + + @product_timeliness.setter + def product_timeliness(self, v: list[str] | None) -> None: + self._set_summary(PRODUCT_TIMELINESS_PROP, v) + + @property + def resolution(self) -> list[str] | None: + return cast(list[str] | None, self.summaries.get_list(RESOLUTION_PROP)) + + @resolution.setter + def resolution(self, v: list[str] | None) -> None: + self._set_summary(RESOLUTION_PROP, v) + + @property + def slice_number(self) -> list[str] | None: + return cast(list[str] | None, self.summaries.get_list(SLICE_NUMBER_PROP)) + + @slice_number.setter + def slice_number(self, v: list[str] | None) -> None: + self._set_summary(SLICE_NUMBER_PROP, v) + + @property + def total_slices(self) -> list[str] | None: + return cast(list[str] | None, self.summaries.get_list(TOTAL_SLICES_PROP)) + + @total_slices.setter + def total_slices(self, v: list[str] | None) -> None: + self._set_summary(TOTAL_SLICES_PROP, v) + + @property + def processing_level(self) -> list[str] | None: + return cast(list[str] | None, self.summaries.get_list(PROCESSING_LEVEL_PROP)) + + @processing_level.setter + def processing_level(self, v: list[str] | None) -> None: + self._set_summary(PROCESSING_LEVEL_PROP, v) + + @property + def shape(self) -> list[list[int]] | None: + return cast(list[list[int]] | None, self.summaries.get_list(SHAPE_PROP)) + + @shape.setter + def shape(self, v: list[list[int]] | None) -> None: + self._set_summary(SHAPE_PROP, v) + + +class Sentinel1ExtensionHooks(ExtensionHooks): + schema_uri: str = SCHEMA_URI + prev_extension_ids = {"sentinel-1"} + stac_object_types = {pystac.STACObjectType.COLLECTION, pystac.STACObjectType.ITEM} + + +SENTINEL1_EXTENSION_HOOKS: ExtensionHooks = Sentinel1ExtensionHooks() diff --git a/extensions/sentinel1/tests/cassettes/test_sentinel1/test_apply_and_validate.yaml b/extensions/sentinel1/tests/cassettes/test_sentinel1/test_apply_and_validate.yaml new file mode 100644 index 000000000..d25770e8d --- /dev/null +++ b/extensions/sentinel1/tests/cassettes/test_sentinel1/test_apply_and_validate.yaml @@ -0,0 +1,55 @@ +interactions: +- request: + body: null + headers: {} + method: GET + uri: https://stac-extensions.github.io/sentinel-1/v0.2.0/schema.json + response: + body: + string: "{\n \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n \"$id\": + \"https://stac-extensions.github.io/sentinel-1/v0.2.0/schema.json#\",\n \"title\": + \"Sentinel-1 Extension\",\n \"description\": \"STAC Sentinel-1 Extension + for STAC Items and STAC Collection Summaries.\",\n \"type\": \"object\",\n + \ \"required\": [\n \"stac_extensions\"\n ],\n \"properties\": {\n \"stac_extensions\": + {\n \"type\": \"array\",\n \"contains\": {\n \"const\": \"https://stac-extensions.github.io/sentinel-1/v0.2.0/schema.json\"\n + \ }\n }\n },\n \"oneOf\": [\n {\n \"$comment\": \"This is + the schema for STAC Items.\",\n \"type\": \"object\",\n \"required\": + [\n \"type\",\n \"properties\"\n ],\n \"properties\": + {\n \"type\": {\n \"const\": \"Feature\"\n },\n \"properties\": + {\n \"allOf\": [\n {\n \"$ref\": \"#/definitions/require_any\"\n + \ },\n {\n \"$ref\": \"#/definitions/fields\"\n + \ }\n ]\n }\n }\n },\n {\n \"$comment\": + \"This is the schema for STAC Collections, or more specifically only Collection + Summaries in this case. By default, only checks the existence of the properties, + but not the schema of the summaries.\",\n \"type\": \"object\",\n \"required\": + [\n \"type\",\n \"summaries\"\n ],\n \"properties\": + {\n \"type\": {\n \"const\": \"Collection\"\n },\n + \ \"summaries\": {\n \"$ref\": \"#/definitions/require_any\"\n + \ }\n }\n }\n ],\n \"definitions\": {\n \"require_any\": + {\n \"$comment\": \"Please list all fields here so that we can force + the existence of one of them in other parts of the schemas.\",\n \"anyOf\": + [\n {\"required\": [\"s1:datatake_id\"]},\n {\"required\": [\"s1:instrument_configuration_ID\"]},\n + \ {\"required\": [\"s1:orbit_source\"]},\n {\"required\": [\"s1:slice_number\"]},\n + \ {\"required\": [\"s1:total_slices\"]}\n ]\n },\n \"fields\": + {\n \"$comment\": \" Don't require fields here, do that above in the + corresponding schema.\",\n \"type\": \"object\",\n \"properties\": + {\n \"s1:datatake_id\": {\n \"type\": \"string\"\n },\n + \ \"s1:instrument_configuration_ID\": {\n \"type\": \"string\"\n + \ },\n \"s1:orbit_source\": {\n \"type\": \"string\"\n + \ },\n \"s1:processing_datetime\": {\n \"type\": \"string\",\n + \ \"format\": \"date-time\"\n },\n \"s1:product_identifier\": + {\n \"type\": \"string\"\n },\n \"s1:product_timeliness\": + {\n \"type\": \"string\"\n },\n \"s1:resolution\": + {\n \"type\": \"string\"\n },\n \"s1:slice_number\": + {\n \"type\": \"string\"\n },\n \"s1:total_slices\": + {\n \"type\": \"string\"\n },\n \"s1:processing_level\": + {\n \"type\": \"string\"\n },\n \"s1:shape\": {\n \"type\": + \"array\",\n \"minItems\": 2,\n \"items\": {\n \"type\": + \"integer\"\n }\n }\n },\n \"patternProperties\": + {\n \"^(?!s1:)\": {}\n },\n \"additionalProperties\": false\n + \ }\n }\n}\n" + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/extensions/sentinel1/tests/cassettes/test_sentinel1/test_no_args_fails.yaml b/extensions/sentinel1/tests/cassettes/test_sentinel1/test_no_args_fails.yaml new file mode 100644 index 000000000..d25770e8d --- /dev/null +++ b/extensions/sentinel1/tests/cassettes/test_sentinel1/test_no_args_fails.yaml @@ -0,0 +1,55 @@ +interactions: +- request: + body: null + headers: {} + method: GET + uri: https://stac-extensions.github.io/sentinel-1/v0.2.0/schema.json + response: + body: + string: "{\n \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n \"$id\": + \"https://stac-extensions.github.io/sentinel-1/v0.2.0/schema.json#\",\n \"title\": + \"Sentinel-1 Extension\",\n \"description\": \"STAC Sentinel-1 Extension + for STAC Items and STAC Collection Summaries.\",\n \"type\": \"object\",\n + \ \"required\": [\n \"stac_extensions\"\n ],\n \"properties\": {\n \"stac_extensions\": + {\n \"type\": \"array\",\n \"contains\": {\n \"const\": \"https://stac-extensions.github.io/sentinel-1/v0.2.0/schema.json\"\n + \ }\n }\n },\n \"oneOf\": [\n {\n \"$comment\": \"This is + the schema for STAC Items.\",\n \"type\": \"object\",\n \"required\": + [\n \"type\",\n \"properties\"\n ],\n \"properties\": + {\n \"type\": {\n \"const\": \"Feature\"\n },\n \"properties\": + {\n \"allOf\": [\n {\n \"$ref\": \"#/definitions/require_any\"\n + \ },\n {\n \"$ref\": \"#/definitions/fields\"\n + \ }\n ]\n }\n }\n },\n {\n \"$comment\": + \"This is the schema for STAC Collections, or more specifically only Collection + Summaries in this case. By default, only checks the existence of the properties, + but not the schema of the summaries.\",\n \"type\": \"object\",\n \"required\": + [\n \"type\",\n \"summaries\"\n ],\n \"properties\": + {\n \"type\": {\n \"const\": \"Collection\"\n },\n + \ \"summaries\": {\n \"$ref\": \"#/definitions/require_any\"\n + \ }\n }\n }\n ],\n \"definitions\": {\n \"require_any\": + {\n \"$comment\": \"Please list all fields here so that we can force + the existence of one of them in other parts of the schemas.\",\n \"anyOf\": + [\n {\"required\": [\"s1:datatake_id\"]},\n {\"required\": [\"s1:instrument_configuration_ID\"]},\n + \ {\"required\": [\"s1:orbit_source\"]},\n {\"required\": [\"s1:slice_number\"]},\n + \ {\"required\": [\"s1:total_slices\"]}\n ]\n },\n \"fields\": + {\n \"$comment\": \" Don't require fields here, do that above in the + corresponding schema.\",\n \"type\": \"object\",\n \"properties\": + {\n \"s1:datatake_id\": {\n \"type\": \"string\"\n },\n + \ \"s1:instrument_configuration_ID\": {\n \"type\": \"string\"\n + \ },\n \"s1:orbit_source\": {\n \"type\": \"string\"\n + \ },\n \"s1:processing_datetime\": {\n \"type\": \"string\",\n + \ \"format\": \"date-time\"\n },\n \"s1:product_identifier\": + {\n \"type\": \"string\"\n },\n \"s1:product_timeliness\": + {\n \"type\": \"string\"\n },\n \"s1:resolution\": + {\n \"type\": \"string\"\n },\n \"s1:slice_number\": + {\n \"type\": \"string\"\n },\n \"s1:total_slices\": + {\n \"type\": \"string\"\n },\n \"s1:processing_level\": + {\n \"type\": \"string\"\n },\n \"s1:shape\": {\n \"type\": + \"array\",\n \"minItems\": 2,\n \"items\": {\n \"type\": + \"integer\"\n }\n }\n },\n \"patternProperties\": + {\n \"^(?!s1:)\": {}\n },\n \"additionalProperties\": false\n + \ }\n }\n}\n" + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/extensions/sentinel1/tests/test_sentinel1.py b/extensions/sentinel1/tests/test_sentinel1.py new file mode 100644 index 000000000..b8cbf4767 --- /dev/null +++ b/extensions/sentinel1/tests/test_sentinel1.py @@ -0,0 +1,203 @@ +"""Tests for pystac.extensions.sentinel1.""" + +from datetime import datetime +from typing import Any, cast + +import pytest + +import pystac +from pystac import Collection, ExtensionTypeError, Item +from pystac.extensions import sentinel1 +from pystac.extensions.sentinel1 import ( + SCHEMA_URI, + Sentinel1Extension, + SummariesSentinel1Extension, +) +from pystac.summaries import RangeSummary +from pystac.utils import str_to_datetime +from tests.utils import TestCases + + +@pytest.fixture +def item() -> Item: + item = pystac.Item( + id="sentinel1-item", + geometry=None, + bbox=None, + datetime=datetime(2020, 1, 1), + properties={}, + ) + Sentinel1Extension.add_to(item) + return item + + +@pytest.fixture +def collection() -> Collection: + return Collection.from_file( + TestCases.get_path("data-files/collections/multi-extent.json") + ) + + +def test_stac_extensions(item: Item) -> None: + assert Sentinel1Extension.has_extension(item) + + +def test_item_repr(item: Item) -> None: + assert ( + Sentinel1Extension.ext(item).__repr__() + == f"" + ) + + +@pytest.mark.vcr() +def test_no_args_fails(item: Item) -> None: + Sentinel1Extension.ext(item).apply() + with pytest.raises(pystac.STACValidationError): + item.validate() + + +@pytest.mark.vcr() +def test_apply_and_validate(item: Item) -> None: + processing_datetime = str_to_datetime("2020-01-02T03:04:05Z") + + Sentinel1Extension.ext(item).apply( + datatake_id="123456", + instrument_configuration_id="9", + orbit_source="RESORB", + processing_datetime=processing_datetime, + product_identifier="S1A_IW_GRDH_1SDV_20200101T000000", + product_timeliness="Fast-24h", + resolution="H", + slice_number="2", + total_slices="9", + processing_level="LEVEL1", + shape=[10980, 10980], + ) + + ext = Sentinel1Extension.ext(item) + assert ext.datatake_id == "123456" + assert ext.instrument_configuration_id == "9" + assert ext.orbit_source == "RESORB" + assert ext.processing_datetime == processing_datetime + assert ext.product_identifier == "S1A_IW_GRDH_1SDV_20200101T000000" + assert ext.product_timeliness == "Fast-24h" + assert ext.resolution == "H" + assert ext.slice_number == "2" + assert ext.total_slices == "9" + assert ext.processing_level == "LEVEL1" + assert ext.shape == [10980, 10980] + + item.validate() + + +def test_shape_must_have_two_values(item: Item) -> None: + with pytest.raises(ValueError, match=r"must contain at least two integers"): + Sentinel1Extension.ext(item).shape = [10980] + + +def test_from_dict() -> None: + d: dict[str, Any] = { + "type": "Feature", + "stac_version": "1.0.0", + "id": "sentinel1-item", + "properties": { + "datetime": "2020-01-01T00:00:00Z", + "s1:datatake_id": "123456", + "s1:shape": [10980, 10980], + }, + "geometry": None, + "links": [], + "assets": {}, + "stac_extensions": [SCHEMA_URI], + } + item = pystac.Item.from_dict(d) + + ext = Sentinel1Extension.ext(item) + assert ext.datatake_id == "123456" + assert ext.shape == [10980, 10980] + + +def test_to_from_dict(item: Item) -> None: + processing_datetime = str_to_datetime("2020-01-02T03:04:05Z") + Sentinel1Extension.ext(item).apply( + datatake_id="123456", + processing_datetime=processing_datetime, + shape=[100, 200], + ) + + d = item.to_dict() + assert d["properties"][sentinel1.DATATAKE_ID_PROP] == "123456" + assert d["properties"][sentinel1.PROCESSING_DATETIME_PROP] == "2020-01-02T03:04:05Z" + assert d["properties"][sentinel1.SHAPE_PROP] == [100, 200] + + item = pystac.Item.from_dict(d) + ext = Sentinel1Extension.ext(item) + assert ext.datatake_id == "123456" + assert ext.processing_datetime == processing_datetime + assert ext.shape == [100, 200] + + +def test_extension_not_implemented(item: Item) -> None: + item.stac_extensions.remove(Sentinel1Extension.get_schema_uri()) + + with pytest.raises(pystac.ExtensionNotImplemented): + _ = Sentinel1Extension.ext(item) + + +def test_item_ext_add_to(item: Item) -> None: + item.stac_extensions.remove(Sentinel1Extension.get_schema_uri()) + assert Sentinel1Extension.get_schema_uri() not in item.stac_extensions + + _ = Sentinel1Extension.ext(item, add_if_missing=True) + + assert Sentinel1Extension.get_schema_uri() in item.stac_extensions + + +def test_should_raise_exception_when_passing_invalid_extension_object() -> None: + with pytest.raises( + ExtensionTypeError, + match=r"^Sentinel1Extension does not apply to type 'object'$", + ): + Sentinel1Extension.ext(object()) # type: ignore + + +def test_summaries(collection: Collection) -> None: + summaries_ext = Sentinel1Extension.summaries(collection, True) + processing_datetime = RangeSummary( + str_to_datetime("2020-01-01T00:00:00Z"), + str_to_datetime("2020-01-02T00:00:00Z"), + ) + + summaries_ext.datatake_id = ["123456", "654321"] + summaries_ext.processing_datetime = processing_datetime + summaries_ext.shape = [[10980, 10980]] + + assert summaries_ext.datatake_id == ["123456", "654321"] + assert summaries_ext.processing_datetime == processing_datetime + assert summaries_ext.shape == [[10980, 10980]] + + summaries_dict = collection.to_dict()["summaries"] + assert summaries_dict["s1:datatake_id"] == ["123456", "654321"] + assert summaries_dict["s1:processing_datetime"] == { + "minimum": "2020-01-01T00:00:00Z", + "maximum": "2020-01-02T00:00:00Z", + } + assert summaries_dict["s1:shape"] == [[10980, 10980]] + + +def test_collection_hint(collection: Collection) -> None: + with pytest.raises( + ExtensionTypeError, + match=r"Hint: Did you mean to use `Sentinel1Extension.summaries` instead\\?", + ): + Sentinel1Extension.ext(cast(Any, collection)) + + +def test_summaries_ext_add_to(collection: Collection) -> None: + if Sentinel1Extension.get_schema_uri() in collection.stac_extensions: + collection.stac_extensions.remove(Sentinel1Extension.get_schema_uri()) + + summaries_ext = Sentinel1Extension.summaries(collection, add_if_missing=True) + + assert isinstance(summaries_ext, SummariesSentinel1Extension) + assert Sentinel1Extension.get_schema_uri() in collection.stac_extensions diff --git a/extensions/sentinel2/README.md b/extensions/sentinel2/README.md new file mode 100644 index 000000000..14a8b7cfc --- /dev/null +++ b/extensions/sentinel2/README.md @@ -0,0 +1,13 @@ +# pystac-ext-sentinel-2 + +[PySTAC](https://pypi.org/project/pystac/) extension package for the [Sentinel-2 Extension](https://github.com/stac-extensions/sentinel-2). +This extension provides fields for Sentinel-2-specific product, processing, datatake, tile, scene classification, and reflectance metadata. + +## Supported versions + +- [v1.0.0](https://stac-extensions.github.io/sentinel-2/v1.0.0/schema.json) + +## Versioning + +This package's version corresponds to the version of the extension specification it targets. +When we release updates to the package code without changing the target extension version, we use [post releases](https://packaging.python.org/en/latest/discussions/versioning/#post-releases), e.g. `1.0.0.post1`. diff --git a/extensions/sentinel2/pyproject.toml b/extensions/sentinel2/pyproject.toml new file mode 100644 index 000000000..213765faf --- /dev/null +++ b/extensions/sentinel2/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "pystac-ext-sentinel-2" +description = "Sentinel-2 extension for PySTAC" +readme = "README.md" +version = "1.0.0" +authors = [] +maintainers = [] +keywords = ["pystac", "imagery", "raster", "catalog", "STAC", "sentinel-2"] +license = { text = "Apache-2.0" } +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Natural Language :: English", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +requires-python = ">=3.10" +dependencies = ["pystac-core"] + +[project.urls] +Documentation = "https://pystac.readthedocs.io" +Repository = "https://github.com/stac-utils/pystac" +Issues = "https://github.com/stac-utils/pystac/issues" +Changelog = "https://github.com/stac-utils/pystac/blob/main/CHANGELOG.md" +Discussions = "https://github.com/radiantearth/stac-spec/discussions/categories/stac-software" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["pystac"] diff --git a/extensions/sentinel2/pystac/extensions/py.typed b/extensions/sentinel2/pystac/extensions/py.typed new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/extensions/sentinel2/pystac/extensions/py.typed @@ -0,0 +1 @@ + diff --git a/extensions/sentinel2/pystac/extensions/sentinel2.py b/extensions/sentinel2/pystac/extensions/sentinel2.py new file mode 100644 index 000000000..19cac2b66 --- /dev/null +++ b/extensions/sentinel2/pystac/extensions/sentinel2.py @@ -0,0 +1,653 @@ +"""Implements the :stac-ext:`Sentinel-2 Extension `.""" + +from __future__ import annotations + +import re +from datetime import datetime +from typing import Any, Generic, Literal, TypeVar, cast + +import pystac +from pystac.extensions.base import ( + ExtensionManagementMixin, + PropertiesExtension, + SummariesExtension, +) +from pystac.extensions.hooks import ExtensionHooks +from pystac.summaries import RangeSummary +from pystac.utils import datetime_to_str, map_opt, str_to_datetime + +T = TypeVar("T", bound=pystac.Item) + +SCHEMA_URI = "https://stac-extensions.github.io/sentinel-2/v1.0.0/schema.json" +PREFIX = "s2:" + +TILE_ID_PROP = PREFIX + "tile_id" +GRANULE_ID_PROP = PREFIX + "granule_id" +DATATAKE_ID_PROP = PREFIX + "datatake_id" +PRODUCT_URI_PROP = PREFIX + "product_uri" +DATASTRIP_ID_PROP = PREFIX + "datastrip_id" +PRODUCT_TYPE_PROP = PREFIX + "product_type" +DATATAKE_TYPE_PROP = PREFIX + "datatake_type" +GENERATION_TIME_PROP = PREFIX + "generation_time" +PROCESSING_BASELINE_PROP = PREFIX + "processing_baseline" +WATER_PERCENTAGE_PROP = PREFIX + "water_percentage" +MEAN_SOLAR_ZENITH_PROP = PREFIX + "mean_solar_zenith" +MEAN_SOLAR_AZIMUTH_PROP = PREFIX + "mean_solar_azimuth" +SNOW_ICE_PERCENTAGE_PROP = PREFIX + "snow_ice_percentage" +VEGETATION_PERCENTAGE_PROP = PREFIX + "vegetation_percentage" +THIN_CIRRUS_PERCENTAGE_PROP = PREFIX + "thin_cirrus_percentage" +CLOUD_SHADOW_PERCENTAGE_PROP = PREFIX + "cloud_shadow_percentage" +NODATA_PIXEL_PERCENTAGE_PROP = PREFIX + "nodata_pixel_percentage" +UNCLASSIFIED_PERCENTAGE_PROP = PREFIX + "unclassified_percentage" +DARK_FEATURES_PERCENTAGE_PROP = PREFIX + "dark_features_percentage" +NOT_VEGETATED_PERCENTAGE_PROP = PREFIX + "not_vegetated_percentage" +DEGRADED_MSI_DATA_PERCENTAGE_PROP = PREFIX + "degraded_msi_data_percentage" +HIGH_PROBA_CLOUDS_PERCENTAGE_PROP = PREFIX + "high_proba_clouds_percentage" +MEDIUM_PROBA_CLOUDS_PERCENTAGE_PROP = PREFIX + "medium_proba_clouds_percentage" +SATURATED_DEFECTIVE_PIXEL_PERCENTAGE_PROP = ( + PREFIX + "saturated_defective_pixel_percentage" +) +REFLECTANCE_CONVERSION_FACTOR_PROP = PREFIX + "reflectance_conversion_factor" +MGRS_TILE_PROP = PREFIX + "mgrs_tile" + +PERCENTAGE_PROPS = { + WATER_PERCENTAGE_PROP, + SNOW_ICE_PERCENTAGE_PROP, + VEGETATION_PERCENTAGE_PROP, + THIN_CIRRUS_PERCENTAGE_PROP, + CLOUD_SHADOW_PERCENTAGE_PROP, + NODATA_PIXEL_PERCENTAGE_PROP, + UNCLASSIFIED_PERCENTAGE_PROP, + DARK_FEATURES_PERCENTAGE_PROP, + NOT_VEGETATED_PERCENTAGE_PROP, + DEGRADED_MSI_DATA_PERCENTAGE_PROP, + HIGH_PROBA_CLOUDS_PERCENTAGE_PROP, + MEDIUM_PROBA_CLOUDS_PERCENTAGE_PROP, + SATURATED_DEFECTIVE_PIXEL_PERCENTAGE_PROP, +} + +PROCESSING_BASELINE_RE = re.compile(r"^\d\d\.\d\d$") +MGRS_TILE_RE = re.compile( + r"^\d\d?[CDEFGHJKLMNPQRSTUVWX][ABCDEFGHJKLMNPQRSTUVWXYZ]" + r"[ABCDEFGHJKLMNPQRSTUV]$" +) + + +def _validate_processing_baseline(v: str | None) -> str | None: + if v is None: + return None + if not PROCESSING_BASELINE_RE.match(v): + raise ValueError(f"{PROCESSING_BASELINE_PROP} must match NN.NN. Got: {v}") + return v + + +def _validate_mgrs_tile(v: str | None) -> str | None: + if v is None: + return None + if not MGRS_TILE_RE.match(v): + raise ValueError(f"{MGRS_TILE_PROP} is not a valid Sentinel-2 MGRS tile: {v}") + return v + + +def _validate_percentage(prop: str, v: float | None) -> float | None: + if v is None: + return None + if not 0 <= v <= 100: + raise ValueError(f"{prop} must be between 0 and 100. Got: {v}") + return v + + +class Sentinel2Extension( + Generic[T], + PropertiesExtension, + ExtensionManagementMixin[pystac.Item | pystac.Collection], +): + name: Literal["s2"] = "s2" + + def apply( + self, + tile_id: str | None = None, + granule_id: str | None = None, + datatake_id: str | None = None, + product_uri: str | None = None, + datastrip_id: str | None = None, + product_type: str | None = None, + datatake_type: str | None = None, + generation_time: datetime | None = None, + processing_baseline: str | None = None, + water_percentage: float | None = None, + mean_solar_zenith: float | None = None, + mean_solar_azimuth: float | None = None, + snow_ice_percentage: float | None = None, + vegetation_percentage: float | None = None, + thin_cirrus_percentage: float | None = None, + cloud_shadow_percentage: float | None = None, + nodata_pixel_percentage: float | None = None, + unclassified_percentage: float | None = None, + dark_features_percentage: float | None = None, + not_vegetated_percentage: float | None = None, + degraded_msi_data_percentage: float | None = None, + high_proba_clouds_percentage: float | None = None, + medium_proba_clouds_percentage: float | None = None, + saturated_defective_pixel_percentage: float | None = None, + reflectance_conversion_factor: float | None = None, + mgrs_tile: str | None = None, + ) -> None: + self.tile_id = tile_id + self.granule_id = granule_id + self.datatake_id = datatake_id + self.product_uri = product_uri + self.datastrip_id = datastrip_id + self.product_type = product_type + self.datatake_type = datatake_type + self.generation_time = generation_time + self.processing_baseline = processing_baseline + self.water_percentage = water_percentage + self.mean_solar_zenith = mean_solar_zenith + self.mean_solar_azimuth = mean_solar_azimuth + self.snow_ice_percentage = snow_ice_percentage + self.vegetation_percentage = vegetation_percentage + self.thin_cirrus_percentage = thin_cirrus_percentage + self.cloud_shadow_percentage = cloud_shadow_percentage + self.nodata_pixel_percentage = nodata_pixel_percentage + self.unclassified_percentage = unclassified_percentage + self.dark_features_percentage = dark_features_percentage + self.not_vegetated_percentage = not_vegetated_percentage + self.degraded_msi_data_percentage = degraded_msi_data_percentage + self.high_proba_clouds_percentage = high_proba_clouds_percentage + self.medium_proba_clouds_percentage = medium_proba_clouds_percentage + self.saturated_defective_pixel_percentage = saturated_defective_pixel_percentage + self.reflectance_conversion_factor = reflectance_conversion_factor + self.mgrs_tile = mgrs_tile + + def _get_str(self, prop: str) -> str | None: + return self._get_property(prop, str) + + def _set_str(self, prop: str, v: str | None) -> None: + self._set_property(prop, v) + + def _get_float(self, prop: str) -> float | None: + return self._get_property(prop, float) + + def _set_float(self, prop: str, v: float | None) -> None: + if prop in PERCENTAGE_PROPS: + v = _validate_percentage(prop, v) + self._set_property(prop, v) + + @property + def tile_id(self) -> str | None: + return self._get_str(TILE_ID_PROP) + + @tile_id.setter + def tile_id(self, v: str | None) -> None: + self._set_str(TILE_ID_PROP, v) + + @property + def granule_id(self) -> str | None: + return self._get_str(GRANULE_ID_PROP) + + @granule_id.setter + def granule_id(self, v: str | None) -> None: + self._set_str(GRANULE_ID_PROP, v) + + @property + def datatake_id(self) -> str | None: + return self._get_str(DATATAKE_ID_PROP) + + @datatake_id.setter + def datatake_id(self, v: str | None) -> None: + self._set_str(DATATAKE_ID_PROP, v) + + @property + def product_uri(self) -> str | None: + return self._get_str(PRODUCT_URI_PROP) + + @product_uri.setter + def product_uri(self, v: str | None) -> None: + self._set_str(PRODUCT_URI_PROP, v) + + @property + def datastrip_id(self) -> str | None: + return self._get_str(DATASTRIP_ID_PROP) + + @datastrip_id.setter + def datastrip_id(self, v: str | None) -> None: + self._set_str(DATASTRIP_ID_PROP, v) + + @property + def product_type(self) -> str | None: + return self._get_str(PRODUCT_TYPE_PROP) + + @product_type.setter + def product_type(self, v: str | None) -> None: + self._set_str(PRODUCT_TYPE_PROP, v) + + @property + def datatake_type(self) -> str | None: + return self._get_str(DATATAKE_TYPE_PROP) + + @datatake_type.setter + def datatake_type(self, v: str | None) -> None: + self._set_str(DATATAKE_TYPE_PROP, v) + + @property + def generation_time(self) -> datetime | None: + return map_opt(str_to_datetime, self._get_property(GENERATION_TIME_PROP, str)) + + @generation_time.setter + def generation_time(self, v: datetime | None) -> None: + self._set_property(GENERATION_TIME_PROP, map_opt(datetime_to_str, v)) + + @property + def processing_baseline(self) -> str | None: + return self._get_str(PROCESSING_BASELINE_PROP) + + @processing_baseline.setter + def processing_baseline(self, v: str | None) -> None: + self._set_property(PROCESSING_BASELINE_PROP, _validate_processing_baseline(v)) + + @property + def water_percentage(self) -> float | None: + return self._get_float(WATER_PERCENTAGE_PROP) + + @water_percentage.setter + def water_percentage(self, v: float | None) -> None: + self._set_float(WATER_PERCENTAGE_PROP, v) + + @property + def mean_solar_zenith(self) -> float | None: + return self._get_float(MEAN_SOLAR_ZENITH_PROP) + + @mean_solar_zenith.setter + def mean_solar_zenith(self, v: float | None) -> None: + self._set_float(MEAN_SOLAR_ZENITH_PROP, v) + + @property + def mean_solar_azimuth(self) -> float | None: + return self._get_float(MEAN_SOLAR_AZIMUTH_PROP) + + @mean_solar_azimuth.setter + def mean_solar_azimuth(self, v: float | None) -> None: + self._set_float(MEAN_SOLAR_AZIMUTH_PROP, v) + + @property + def snow_ice_percentage(self) -> float | None: + return self._get_float(SNOW_ICE_PERCENTAGE_PROP) + + @snow_ice_percentage.setter + def snow_ice_percentage(self, v: float | None) -> None: + self._set_float(SNOW_ICE_PERCENTAGE_PROP, v) + + @property + def vegetation_percentage(self) -> float | None: + return self._get_float(VEGETATION_PERCENTAGE_PROP) + + @vegetation_percentage.setter + def vegetation_percentage(self, v: float | None) -> None: + self._set_float(VEGETATION_PERCENTAGE_PROP, v) + + @property + def thin_cirrus_percentage(self) -> float | None: + return self._get_float(THIN_CIRRUS_PERCENTAGE_PROP) + + @thin_cirrus_percentage.setter + def thin_cirrus_percentage(self, v: float | None) -> None: + self._set_float(THIN_CIRRUS_PERCENTAGE_PROP, v) + + @property + def cloud_shadow_percentage(self) -> float | None: + return self._get_float(CLOUD_SHADOW_PERCENTAGE_PROP) + + @cloud_shadow_percentage.setter + def cloud_shadow_percentage(self, v: float | None) -> None: + self._set_float(CLOUD_SHADOW_PERCENTAGE_PROP, v) + + @property + def nodata_pixel_percentage(self) -> float | None: + return self._get_float(NODATA_PIXEL_PERCENTAGE_PROP) + + @nodata_pixel_percentage.setter + def nodata_pixel_percentage(self, v: float | None) -> None: + self._set_float(NODATA_PIXEL_PERCENTAGE_PROP, v) + + @property + def unclassified_percentage(self) -> float | None: + return self._get_float(UNCLASSIFIED_PERCENTAGE_PROP) + + @unclassified_percentage.setter + def unclassified_percentage(self, v: float | None) -> None: + self._set_float(UNCLASSIFIED_PERCENTAGE_PROP, v) + + @property + def dark_features_percentage(self) -> float | None: + return self._get_float(DARK_FEATURES_PERCENTAGE_PROP) + + @dark_features_percentage.setter + def dark_features_percentage(self, v: float | None) -> None: + self._set_float(DARK_FEATURES_PERCENTAGE_PROP, v) + + @property + def not_vegetated_percentage(self) -> float | None: + return self._get_float(NOT_VEGETATED_PERCENTAGE_PROP) + + @not_vegetated_percentage.setter + def not_vegetated_percentage(self, v: float | None) -> None: + self._set_float(NOT_VEGETATED_PERCENTAGE_PROP, v) + + @property + def degraded_msi_data_percentage(self) -> float | None: + return self._get_float(DEGRADED_MSI_DATA_PERCENTAGE_PROP) + + @degraded_msi_data_percentage.setter + def degraded_msi_data_percentage(self, v: float | None) -> None: + self._set_float(DEGRADED_MSI_DATA_PERCENTAGE_PROP, v) + + @property + def high_proba_clouds_percentage(self) -> float | None: + return self._get_float(HIGH_PROBA_CLOUDS_PERCENTAGE_PROP) + + @high_proba_clouds_percentage.setter + def high_proba_clouds_percentage(self, v: float | None) -> None: + self._set_float(HIGH_PROBA_CLOUDS_PERCENTAGE_PROP, v) + + @property + def medium_proba_clouds_percentage(self) -> float | None: + return self._get_float(MEDIUM_PROBA_CLOUDS_PERCENTAGE_PROP) + + @medium_proba_clouds_percentage.setter + def medium_proba_clouds_percentage(self, v: float | None) -> None: + self._set_float(MEDIUM_PROBA_CLOUDS_PERCENTAGE_PROP, v) + + @property + def saturated_defective_pixel_percentage(self) -> float | None: + return self._get_float(SATURATED_DEFECTIVE_PIXEL_PERCENTAGE_PROP) + + @saturated_defective_pixel_percentage.setter + def saturated_defective_pixel_percentage(self, v: float | None) -> None: + self._set_float(SATURATED_DEFECTIVE_PIXEL_PERCENTAGE_PROP, v) + + @property + def reflectance_conversion_factor(self) -> float | None: + return self._get_float(REFLECTANCE_CONVERSION_FACTOR_PROP) + + @reflectance_conversion_factor.setter + def reflectance_conversion_factor(self, v: float | None) -> None: + self._set_float(REFLECTANCE_CONVERSION_FACTOR_PROP, v) + + @property + def mgrs_tile(self) -> str | None: + return self._get_str(MGRS_TILE_PROP) + + @mgrs_tile.setter + def mgrs_tile(self, v: str | None) -> None: + self._set_property(MGRS_TILE_PROP, _validate_mgrs_tile(v)) + + @classmethod + def get_schema_uri(cls) -> str: + return SCHEMA_URI + + @classmethod + def ext(cls, obj: T, add_if_missing: bool = False) -> Sentinel2Extension[T]: + if isinstance(obj, pystac.Item): + cls.ensure_has_extension(obj, add_if_missing) + return cast(Sentinel2Extension[T], ItemSentinel2Extension(obj)) + raise pystac.ExtensionTypeError(cls._ext_error_message(obj)) + + @classmethod + def summaries( + cls, obj: pystac.Collection, add_if_missing: bool = False + ) -> SummariesSentinel2Extension: + cls.ensure_has_extension(obj, add_if_missing) + return SummariesSentinel2Extension(obj) + + +class ItemSentinel2Extension(Sentinel2Extension[pystac.Item]): + item: pystac.Item + properties: dict[str, Any] + + def __init__(self, item: pystac.Item): + self.item = item + self.properties = item.properties + + def __repr__(self) -> str: + return f"" + + +class SummariesSentinel2Extension(SummariesExtension): + def _get_list(self, prop: str) -> list[str] | None: + return cast(list[str] | None, self.summaries.get_list(prop)) + + def _get_range(self, prop: str) -> RangeSummary[float] | None: + return cast(RangeSummary[float] | None, self.summaries.get_range(prop)) + + @property + def tile_id(self) -> list[str] | None: + return self._get_list(TILE_ID_PROP) + + @tile_id.setter + def tile_id(self, v: list[str] | None) -> None: + self._set_summary(TILE_ID_PROP, v) + + @property + def granule_id(self) -> list[str] | None: + return self._get_list(GRANULE_ID_PROP) + + @granule_id.setter + def granule_id(self, v: list[str] | None) -> None: + self._set_summary(GRANULE_ID_PROP, v) + + @property + def datatake_id(self) -> list[str] | None: + return self._get_list(DATATAKE_ID_PROP) + + @datatake_id.setter + def datatake_id(self, v: list[str] | None) -> None: + self._set_summary(DATATAKE_ID_PROP, v) + + @property + def product_uri(self) -> list[str] | None: + return self._get_list(PRODUCT_URI_PROP) + + @product_uri.setter + def product_uri(self, v: list[str] | None) -> None: + self._set_summary(PRODUCT_URI_PROP, v) + + @property + def datastrip_id(self) -> list[str] | None: + return self._get_list(DATASTRIP_ID_PROP) + + @datastrip_id.setter + def datastrip_id(self, v: list[str] | None) -> None: + self._set_summary(DATASTRIP_ID_PROP, v) + + @property + def product_type(self) -> list[str] | None: + return self._get_list(PRODUCT_TYPE_PROP) + + @product_type.setter + def product_type(self, v: list[str] | None) -> None: + self._set_summary(PRODUCT_TYPE_PROP, v) + + @property + def datatake_type(self) -> list[str] | None: + return self._get_list(DATATAKE_TYPE_PROP) + + @datatake_type.setter + def datatake_type(self, v: list[str] | None) -> None: + self._set_summary(DATATAKE_TYPE_PROP, v) + + @property + def generation_time(self) -> RangeSummary[datetime] | None: + return map_opt( + lambda s: RangeSummary( + str_to_datetime(s.minimum), str_to_datetime(s.maximum) + ), + self.summaries.get_range(GENERATION_TIME_PROP), + ) + + @generation_time.setter + def generation_time(self, v: RangeSummary[datetime] | None) -> None: + self._set_summary( + GENERATION_TIME_PROP, + map_opt( + lambda s: RangeSummary( + datetime_to_str(s.minimum), datetime_to_str(s.maximum) + ), + v, + ), + ) + + @property + def processing_baseline(self) -> list[str] | None: + return self._get_list(PROCESSING_BASELINE_PROP) + + @processing_baseline.setter + def processing_baseline(self, v: list[str] | None) -> None: + self._set_summary(PROCESSING_BASELINE_PROP, v) + + @property + def water_percentage(self) -> RangeSummary[float] | None: + return self._get_range(WATER_PERCENTAGE_PROP) + + @water_percentage.setter + def water_percentage(self, v: RangeSummary[float] | None) -> None: + self._set_summary(WATER_PERCENTAGE_PROP, v) + + @property + def mean_solar_zenith(self) -> RangeSummary[float] | None: + return self._get_range(MEAN_SOLAR_ZENITH_PROP) + + @mean_solar_zenith.setter + def mean_solar_zenith(self, v: RangeSummary[float] | None) -> None: + self._set_summary(MEAN_SOLAR_ZENITH_PROP, v) + + @property + def mean_solar_azimuth(self) -> RangeSummary[float] | None: + return self._get_range(MEAN_SOLAR_AZIMUTH_PROP) + + @mean_solar_azimuth.setter + def mean_solar_azimuth(self, v: RangeSummary[float] | None) -> None: + self._set_summary(MEAN_SOLAR_AZIMUTH_PROP, v) + + @property + def snow_ice_percentage(self) -> RangeSummary[float] | None: + return self._get_range(SNOW_ICE_PERCENTAGE_PROP) + + @snow_ice_percentage.setter + def snow_ice_percentage(self, v: RangeSummary[float] | None) -> None: + self._set_summary(SNOW_ICE_PERCENTAGE_PROP, v) + + @property + def vegetation_percentage(self) -> RangeSummary[float] | None: + return self._get_range(VEGETATION_PERCENTAGE_PROP) + + @vegetation_percentage.setter + def vegetation_percentage(self, v: RangeSummary[float] | None) -> None: + self._set_summary(VEGETATION_PERCENTAGE_PROP, v) + + @property + def thin_cirrus_percentage(self) -> RangeSummary[float] | None: + return self._get_range(THIN_CIRRUS_PERCENTAGE_PROP) + + @thin_cirrus_percentage.setter + def thin_cirrus_percentage(self, v: RangeSummary[float] | None) -> None: + self._set_summary(THIN_CIRRUS_PERCENTAGE_PROP, v) + + @property + def cloud_shadow_percentage(self) -> RangeSummary[float] | None: + return self._get_range(CLOUD_SHADOW_PERCENTAGE_PROP) + + @cloud_shadow_percentage.setter + def cloud_shadow_percentage(self, v: RangeSummary[float] | None) -> None: + self._set_summary(CLOUD_SHADOW_PERCENTAGE_PROP, v) + + @property + def nodata_pixel_percentage(self) -> RangeSummary[float] | None: + return self._get_range(NODATA_PIXEL_PERCENTAGE_PROP) + + @nodata_pixel_percentage.setter + def nodata_pixel_percentage(self, v: RangeSummary[float] | None) -> None: + self._set_summary(NODATA_PIXEL_PERCENTAGE_PROP, v) + + @property + def unclassified_percentage(self) -> RangeSummary[float] | None: + return self._get_range(UNCLASSIFIED_PERCENTAGE_PROP) + + @unclassified_percentage.setter + def unclassified_percentage(self, v: RangeSummary[float] | None) -> None: + self._set_summary(UNCLASSIFIED_PERCENTAGE_PROP, v) + + @property + def dark_features_percentage(self) -> RangeSummary[float] | None: + return self._get_range(DARK_FEATURES_PERCENTAGE_PROP) + + @dark_features_percentage.setter + def dark_features_percentage(self, v: RangeSummary[float] | None) -> None: + self._set_summary(DARK_FEATURES_PERCENTAGE_PROP, v) + + @property + def not_vegetated_percentage(self) -> RangeSummary[float] | None: + return self._get_range(NOT_VEGETATED_PERCENTAGE_PROP) + + @not_vegetated_percentage.setter + def not_vegetated_percentage(self, v: RangeSummary[float] | None) -> None: + self._set_summary(NOT_VEGETATED_PERCENTAGE_PROP, v) + + @property + def degraded_msi_data_percentage(self) -> RangeSummary[float] | None: + return self._get_range(DEGRADED_MSI_DATA_PERCENTAGE_PROP) + + @degraded_msi_data_percentage.setter + def degraded_msi_data_percentage(self, v: RangeSummary[float] | None) -> None: + self._set_summary(DEGRADED_MSI_DATA_PERCENTAGE_PROP, v) + + @property + def high_proba_clouds_percentage(self) -> RangeSummary[float] | None: + return self._get_range(HIGH_PROBA_CLOUDS_PERCENTAGE_PROP) + + @high_proba_clouds_percentage.setter + def high_proba_clouds_percentage(self, v: RangeSummary[float] | None) -> None: + self._set_summary(HIGH_PROBA_CLOUDS_PERCENTAGE_PROP, v) + + @property + def medium_proba_clouds_percentage(self) -> RangeSummary[float] | None: + return self._get_range(MEDIUM_PROBA_CLOUDS_PERCENTAGE_PROP) + + @medium_proba_clouds_percentage.setter + def medium_proba_clouds_percentage(self, v: RangeSummary[float] | None) -> None: + self._set_summary(MEDIUM_PROBA_CLOUDS_PERCENTAGE_PROP, v) + + @property + def saturated_defective_pixel_percentage(self) -> RangeSummary[float] | None: + return self._get_range(SATURATED_DEFECTIVE_PIXEL_PERCENTAGE_PROP) + + @saturated_defective_pixel_percentage.setter + def saturated_defective_pixel_percentage( + self, v: RangeSummary[float] | None + ) -> None: + self._set_summary(SATURATED_DEFECTIVE_PIXEL_PERCENTAGE_PROP, v) + + @property + def reflectance_conversion_factor(self) -> RangeSummary[float] | None: + return self._get_range(REFLECTANCE_CONVERSION_FACTOR_PROP) + + @reflectance_conversion_factor.setter + def reflectance_conversion_factor(self, v: RangeSummary[float] | None) -> None: + self._set_summary(REFLECTANCE_CONVERSION_FACTOR_PROP, v) + + @property + def mgrs_tile(self) -> list[str] | None: + return self._get_list(MGRS_TILE_PROP) + + @mgrs_tile.setter + def mgrs_tile(self, v: list[str] | None) -> None: + self._set_summary(MGRS_TILE_PROP, v) + + +class Sentinel2ExtensionHooks(ExtensionHooks): + schema_uri: str = SCHEMA_URI + prev_extension_ids = {"sentinel-2"} + stac_object_types = {pystac.STACObjectType.COLLECTION, pystac.STACObjectType.ITEM} + + +SENTINEL2_EXTENSION_HOOKS: ExtensionHooks = Sentinel2ExtensionHooks() diff --git a/extensions/sentinel2/tests/cassettes/test_sentinel2/test_apply_and_validate.yaml b/extensions/sentinel2/tests/cassettes/test_sentinel2/test_apply_and_validate.yaml new file mode 100644 index 000000000..632295875 --- /dev/null +++ b/extensions/sentinel2/tests/cassettes/test_sentinel2/test_apply_and_validate.yaml @@ -0,0 +1,87 @@ +interactions: +- request: + body: null + headers: {} + method: GET + uri: https://stac-extensions.github.io/sentinel-2/v1.0.0/schema.json + response: + body: + string: "{\n \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n \"$id\": + \"https://stac-extensions.github.io/sentinel-2/v1.0.0/schema.json\",\n \"title\": + \"Sentinel-2 Extension\",\n \"description\": \"Sentinel-2 Extension to STAC + Items.\",\n \"allOf\": [\n {\n \"type\": \"object\",\n \"required\": + [\n \"type\",\n \"properties\"\n ],\n \"properties\": + {\n \"type\": {\n \"const\": \"Feature\"\n },\n \"properties\": + {\n \"allOf\": [\n {\n \"required\": []\n + \ },\n {\n \"$ref\": \"#/definitions/fields\"\n + \ }\n ]\n }\n }\n },\n {\n \"$ref\": + \"#/definitions/stac_extensions\"\n }\n ],\n \"definitions\": {\n \"stac_extensions\": + {\n \"type\": \"object\",\n \"required\": [\n \"stac_extensions\"\n + \ ],\n \"properties\": {\n \"stac_extensions\": {\n \"type\": + \"array\",\n \"contains\": {\n \"const\": \"https://stac-extensions.github.io/sentinel-2/v1.0.0/schema.json\"\n + \ }\n }\n }\n },\n \"fields\": {\n \"type\": + \"object\",\n \"properties\": {\n \"s2:tile_id\": {\n \"title\": + \"Tile Identifier\",\n \"type\": \"string\"\n },\n \"s2:granule_id\": + {\n \"title\": \"Granule Identifier\",\n \"type\": \"string\",\n + \ \"deprecated\": true\n },\n \"s2:datatake_id\": {\n + \ \"title\": \"Datatake Identifier\",\n \"type\": \"string\"\n + \ },\n \"s2:product_uri\": {\n \"title\": \"Product + URI\",\n \"type\": \"string\"\n },\n \"s2:datastrip_id\": + {\n \"title\": \"Datastrip Identifier\",\n \"type\": \"string\"\n + \ },\n \"s2:product_type\": {\n \"title\": \"Product + Type\",\n \"type\": \"string\"\n },\n \"s2:datatake_type\": + {\n \"title\": \"Datatake Type\",\n \"type\": \"string\"\n + \ },\n \"s2:generation_time\": {\n \"title\": \"Generation + Time\",\n \"type\": \"string\",\n \"format\": \"date-time\"\n + \ },\n \"s2:processing_baseline\": {\n \"title\": \"Processing + Baseline\",\n \"type\": \"string\",\n \"pattern\": \"^\\\\d\\\\d\\\\.\\\\d\\\\d$\"\n + \ },\n \"s2:water_percentage\": {\n \"title\": \"Water + Percentage\",\n \"type\": \"number\",\n \"minimum\": 0,\n + \ \"maximum\": 100\n },\n \"s2:mean_solar_zenith\": + {\n \"title\": \"Mean Solar Zenith\",\n \"type\": \"number\",\n + \ \"minimum\": 0,\n \"maximum\": 180,\n \"deprecated\": + true\n },\n \"s2:mean_solar_azimuth\": {\n \"title\": + \"Mean Solar Azimuth\",\n \"type\": \"number\",\n \"minimum\": + 0,\n \"maximum\": 180, \n \"deprecated\": true\n },\n + \ \"s2:snow_ice_percentage\": {\n \"title\": \"Snow and Ice + Percentage\",\n \"type\": \"number\",\n \"minimum\": 0,\n + \ \"maximum\": 100\n },\n \"s2:vegetation_percentage\": + {\n \"title\": \"Vegetation Percentage\",\n \"type\": \"number\",\n + \ \"minimum\": 0,\n \"maximum\": 100\n },\n \"s2:thin_cirrus_percentage\": + {\n \"title\": \"Thin Cirrus Percentage\",\n \"type\": \"number\",\n + \ \"minimum\": 0,\n \"maximum\": 100\n },\n \"s2:cloud_shadow_percentage\": + {\n \"title\": \"Cloud Shadow Percentage\",\n \"type\": + \"number\",\n \"minimum\": 0,\n \"maximum\": 100\n },\n + \ \"s2:nodata_pixel_percentage\": {\n \"title\": \"No Data + Pixel Percentage\",\n \"type\": \"number\",\n \"minimum\": + 0,\n \"maximum\": 100\n },\n \"s2:unclassified_percentage\": + {\n \"title\": \"Unclassified Percentage\",\n \"type\": + \"number\",\n \"minimum\": 0,\n \"maximum\": 100\n },\n + \ \"s2:dark_features_percentage\": {\n \"title\": \"Dark Features + Percentage\",\n \"type\": \"number\",\n \"minimum\": 0,\n + \ \"maximum\": 100\n },\n \"s2:not_vegetated_percentage\": + {\n \"title\": \"Not Vegetated Percentage\",\n \"type\": + \"number\",\n \"minimum\": 0,\n \"maximum\": 100\n },\n + \ \"s2:degraded_msi_data_percentage\": {\n \"title\": \"Degraded + MSI Data Percentage\",\n \"type\": \"number\",\n \"minimum\": + 0,\n \"maximum\": 100\n },\n \"s2:high_proba_clouds_percentage\": + {\n \"title\": \"High Probability Clouds Percentage\",\n \"type\": + \"number\",\n \"minimum\": 0,\n \"maximum\": 100\n },\n + \ \"s2:medium_proba_clouds_percentage\": {\n \"title\": \"Medium + Probability Clouds Percentage\",\n \"type\": \"number\",\n \"minimum\": + 0,\n \"maximum\": 100\n },\n \"s2:saturated_defective_pixel_percentage\": + {\n \"title\": \"Saturated Defective Pixel Percentage\",\n \"type\": + \"number\",\n \"minimum\": 0,\n \"maximum\": 100\n },\n + \ \"s2:reflectance_conversion_factor\": {\n \"title\": \"Reflectance + Conversion Factor\",\n \"type\": \"number\"\n },\n \"s2:mgrs_tile\": + {\n \"title\": \"Sentinel-2 MGRS Tile Identifier\",\n \"type\": + \"string\",\n \"pattern\": \"^\\\\d\\\\d?[CDEFGHJKLMNPQRSTUVWX][ABCDEFGHJKLMNPQRSTUVWXYZ][ABCDEFGHJKLMNPQRSTUV]$\",\n + \ \"deprecated\": true\n }\n },\n \"patternProperties\": + {\n \"^(?!s2:)\": {\n \"$comment\": \"Do not allow other fields + with this prefix\"\n }\n },\n \"additionalProperties\": false\n + \ }\n }\n}" + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/extensions/sentinel2/tests/cassettes/test_sentinel2/test_no_args_fails.yaml b/extensions/sentinel2/tests/cassettes/test_sentinel2/test_no_args_fails.yaml new file mode 100644 index 000000000..632295875 --- /dev/null +++ b/extensions/sentinel2/tests/cassettes/test_sentinel2/test_no_args_fails.yaml @@ -0,0 +1,87 @@ +interactions: +- request: + body: null + headers: {} + method: GET + uri: https://stac-extensions.github.io/sentinel-2/v1.0.0/schema.json + response: + body: + string: "{\n \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n \"$id\": + \"https://stac-extensions.github.io/sentinel-2/v1.0.0/schema.json\",\n \"title\": + \"Sentinel-2 Extension\",\n \"description\": \"Sentinel-2 Extension to STAC + Items.\",\n \"allOf\": [\n {\n \"type\": \"object\",\n \"required\": + [\n \"type\",\n \"properties\"\n ],\n \"properties\": + {\n \"type\": {\n \"const\": \"Feature\"\n },\n \"properties\": + {\n \"allOf\": [\n {\n \"required\": []\n + \ },\n {\n \"$ref\": \"#/definitions/fields\"\n + \ }\n ]\n }\n }\n },\n {\n \"$ref\": + \"#/definitions/stac_extensions\"\n }\n ],\n \"definitions\": {\n \"stac_extensions\": + {\n \"type\": \"object\",\n \"required\": [\n \"stac_extensions\"\n + \ ],\n \"properties\": {\n \"stac_extensions\": {\n \"type\": + \"array\",\n \"contains\": {\n \"const\": \"https://stac-extensions.github.io/sentinel-2/v1.0.0/schema.json\"\n + \ }\n }\n }\n },\n \"fields\": {\n \"type\": + \"object\",\n \"properties\": {\n \"s2:tile_id\": {\n \"title\": + \"Tile Identifier\",\n \"type\": \"string\"\n },\n \"s2:granule_id\": + {\n \"title\": \"Granule Identifier\",\n \"type\": \"string\",\n + \ \"deprecated\": true\n },\n \"s2:datatake_id\": {\n + \ \"title\": \"Datatake Identifier\",\n \"type\": \"string\"\n + \ },\n \"s2:product_uri\": {\n \"title\": \"Product + URI\",\n \"type\": \"string\"\n },\n \"s2:datastrip_id\": + {\n \"title\": \"Datastrip Identifier\",\n \"type\": \"string\"\n + \ },\n \"s2:product_type\": {\n \"title\": \"Product + Type\",\n \"type\": \"string\"\n },\n \"s2:datatake_type\": + {\n \"title\": \"Datatake Type\",\n \"type\": \"string\"\n + \ },\n \"s2:generation_time\": {\n \"title\": \"Generation + Time\",\n \"type\": \"string\",\n \"format\": \"date-time\"\n + \ },\n \"s2:processing_baseline\": {\n \"title\": \"Processing + Baseline\",\n \"type\": \"string\",\n \"pattern\": \"^\\\\d\\\\d\\\\.\\\\d\\\\d$\"\n + \ },\n \"s2:water_percentage\": {\n \"title\": \"Water + Percentage\",\n \"type\": \"number\",\n \"minimum\": 0,\n + \ \"maximum\": 100\n },\n \"s2:mean_solar_zenith\": + {\n \"title\": \"Mean Solar Zenith\",\n \"type\": \"number\",\n + \ \"minimum\": 0,\n \"maximum\": 180,\n \"deprecated\": + true\n },\n \"s2:mean_solar_azimuth\": {\n \"title\": + \"Mean Solar Azimuth\",\n \"type\": \"number\",\n \"minimum\": + 0,\n \"maximum\": 180, \n \"deprecated\": true\n },\n + \ \"s2:snow_ice_percentage\": {\n \"title\": \"Snow and Ice + Percentage\",\n \"type\": \"number\",\n \"minimum\": 0,\n + \ \"maximum\": 100\n },\n \"s2:vegetation_percentage\": + {\n \"title\": \"Vegetation Percentage\",\n \"type\": \"number\",\n + \ \"minimum\": 0,\n \"maximum\": 100\n },\n \"s2:thin_cirrus_percentage\": + {\n \"title\": \"Thin Cirrus Percentage\",\n \"type\": \"number\",\n + \ \"minimum\": 0,\n \"maximum\": 100\n },\n \"s2:cloud_shadow_percentage\": + {\n \"title\": \"Cloud Shadow Percentage\",\n \"type\": + \"number\",\n \"minimum\": 0,\n \"maximum\": 100\n },\n + \ \"s2:nodata_pixel_percentage\": {\n \"title\": \"No Data + Pixel Percentage\",\n \"type\": \"number\",\n \"minimum\": + 0,\n \"maximum\": 100\n },\n \"s2:unclassified_percentage\": + {\n \"title\": \"Unclassified Percentage\",\n \"type\": + \"number\",\n \"minimum\": 0,\n \"maximum\": 100\n },\n + \ \"s2:dark_features_percentage\": {\n \"title\": \"Dark Features + Percentage\",\n \"type\": \"number\",\n \"minimum\": 0,\n + \ \"maximum\": 100\n },\n \"s2:not_vegetated_percentage\": + {\n \"title\": \"Not Vegetated Percentage\",\n \"type\": + \"number\",\n \"minimum\": 0,\n \"maximum\": 100\n },\n + \ \"s2:degraded_msi_data_percentage\": {\n \"title\": \"Degraded + MSI Data Percentage\",\n \"type\": \"number\",\n \"minimum\": + 0,\n \"maximum\": 100\n },\n \"s2:high_proba_clouds_percentage\": + {\n \"title\": \"High Probability Clouds Percentage\",\n \"type\": + \"number\",\n \"minimum\": 0,\n \"maximum\": 100\n },\n + \ \"s2:medium_proba_clouds_percentage\": {\n \"title\": \"Medium + Probability Clouds Percentage\",\n \"type\": \"number\",\n \"minimum\": + 0,\n \"maximum\": 100\n },\n \"s2:saturated_defective_pixel_percentage\": + {\n \"title\": \"Saturated Defective Pixel Percentage\",\n \"type\": + \"number\",\n \"minimum\": 0,\n \"maximum\": 100\n },\n + \ \"s2:reflectance_conversion_factor\": {\n \"title\": \"Reflectance + Conversion Factor\",\n \"type\": \"number\"\n },\n \"s2:mgrs_tile\": + {\n \"title\": \"Sentinel-2 MGRS Tile Identifier\",\n \"type\": + \"string\",\n \"pattern\": \"^\\\\d\\\\d?[CDEFGHJKLMNPQRSTUVWX][ABCDEFGHJKLMNPQRSTUVWXYZ][ABCDEFGHJKLMNPQRSTUV]$\",\n + \ \"deprecated\": true\n }\n },\n \"patternProperties\": + {\n \"^(?!s2:)\": {\n \"$comment\": \"Do not allow other fields + with this prefix\"\n }\n },\n \"additionalProperties\": false\n + \ }\n }\n}" + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/extensions/sentinel2/tests/cassettes/test_sentinel2/test_no_args_validate.yaml b/extensions/sentinel2/tests/cassettes/test_sentinel2/test_no_args_validate.yaml new file mode 100644 index 000000000..632295875 --- /dev/null +++ b/extensions/sentinel2/tests/cassettes/test_sentinel2/test_no_args_validate.yaml @@ -0,0 +1,87 @@ +interactions: +- request: + body: null + headers: {} + method: GET + uri: https://stac-extensions.github.io/sentinel-2/v1.0.0/schema.json + response: + body: + string: "{\n \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n \"$id\": + \"https://stac-extensions.github.io/sentinel-2/v1.0.0/schema.json\",\n \"title\": + \"Sentinel-2 Extension\",\n \"description\": \"Sentinel-2 Extension to STAC + Items.\",\n \"allOf\": [\n {\n \"type\": \"object\",\n \"required\": + [\n \"type\",\n \"properties\"\n ],\n \"properties\": + {\n \"type\": {\n \"const\": \"Feature\"\n },\n \"properties\": + {\n \"allOf\": [\n {\n \"required\": []\n + \ },\n {\n \"$ref\": \"#/definitions/fields\"\n + \ }\n ]\n }\n }\n },\n {\n \"$ref\": + \"#/definitions/stac_extensions\"\n }\n ],\n \"definitions\": {\n \"stac_extensions\": + {\n \"type\": \"object\",\n \"required\": [\n \"stac_extensions\"\n + \ ],\n \"properties\": {\n \"stac_extensions\": {\n \"type\": + \"array\",\n \"contains\": {\n \"const\": \"https://stac-extensions.github.io/sentinel-2/v1.0.0/schema.json\"\n + \ }\n }\n }\n },\n \"fields\": {\n \"type\": + \"object\",\n \"properties\": {\n \"s2:tile_id\": {\n \"title\": + \"Tile Identifier\",\n \"type\": \"string\"\n },\n \"s2:granule_id\": + {\n \"title\": \"Granule Identifier\",\n \"type\": \"string\",\n + \ \"deprecated\": true\n },\n \"s2:datatake_id\": {\n + \ \"title\": \"Datatake Identifier\",\n \"type\": \"string\"\n + \ },\n \"s2:product_uri\": {\n \"title\": \"Product + URI\",\n \"type\": \"string\"\n },\n \"s2:datastrip_id\": + {\n \"title\": \"Datastrip Identifier\",\n \"type\": \"string\"\n + \ },\n \"s2:product_type\": {\n \"title\": \"Product + Type\",\n \"type\": \"string\"\n },\n \"s2:datatake_type\": + {\n \"title\": \"Datatake Type\",\n \"type\": \"string\"\n + \ },\n \"s2:generation_time\": {\n \"title\": \"Generation + Time\",\n \"type\": \"string\",\n \"format\": \"date-time\"\n + \ },\n \"s2:processing_baseline\": {\n \"title\": \"Processing + Baseline\",\n \"type\": \"string\",\n \"pattern\": \"^\\\\d\\\\d\\\\.\\\\d\\\\d$\"\n + \ },\n \"s2:water_percentage\": {\n \"title\": \"Water + Percentage\",\n \"type\": \"number\",\n \"minimum\": 0,\n + \ \"maximum\": 100\n },\n \"s2:mean_solar_zenith\": + {\n \"title\": \"Mean Solar Zenith\",\n \"type\": \"number\",\n + \ \"minimum\": 0,\n \"maximum\": 180,\n \"deprecated\": + true\n },\n \"s2:mean_solar_azimuth\": {\n \"title\": + \"Mean Solar Azimuth\",\n \"type\": \"number\",\n \"minimum\": + 0,\n \"maximum\": 180, \n \"deprecated\": true\n },\n + \ \"s2:snow_ice_percentage\": {\n \"title\": \"Snow and Ice + Percentage\",\n \"type\": \"number\",\n \"minimum\": 0,\n + \ \"maximum\": 100\n },\n \"s2:vegetation_percentage\": + {\n \"title\": \"Vegetation Percentage\",\n \"type\": \"number\",\n + \ \"minimum\": 0,\n \"maximum\": 100\n },\n \"s2:thin_cirrus_percentage\": + {\n \"title\": \"Thin Cirrus Percentage\",\n \"type\": \"number\",\n + \ \"minimum\": 0,\n \"maximum\": 100\n },\n \"s2:cloud_shadow_percentage\": + {\n \"title\": \"Cloud Shadow Percentage\",\n \"type\": + \"number\",\n \"minimum\": 0,\n \"maximum\": 100\n },\n + \ \"s2:nodata_pixel_percentage\": {\n \"title\": \"No Data + Pixel Percentage\",\n \"type\": \"number\",\n \"minimum\": + 0,\n \"maximum\": 100\n },\n \"s2:unclassified_percentage\": + {\n \"title\": \"Unclassified Percentage\",\n \"type\": + \"number\",\n \"minimum\": 0,\n \"maximum\": 100\n },\n + \ \"s2:dark_features_percentage\": {\n \"title\": \"Dark Features + Percentage\",\n \"type\": \"number\",\n \"minimum\": 0,\n + \ \"maximum\": 100\n },\n \"s2:not_vegetated_percentage\": + {\n \"title\": \"Not Vegetated Percentage\",\n \"type\": + \"number\",\n \"minimum\": 0,\n \"maximum\": 100\n },\n + \ \"s2:degraded_msi_data_percentage\": {\n \"title\": \"Degraded + MSI Data Percentage\",\n \"type\": \"number\",\n \"minimum\": + 0,\n \"maximum\": 100\n },\n \"s2:high_proba_clouds_percentage\": + {\n \"title\": \"High Probability Clouds Percentage\",\n \"type\": + \"number\",\n \"minimum\": 0,\n \"maximum\": 100\n },\n + \ \"s2:medium_proba_clouds_percentage\": {\n \"title\": \"Medium + Probability Clouds Percentage\",\n \"type\": \"number\",\n \"minimum\": + 0,\n \"maximum\": 100\n },\n \"s2:saturated_defective_pixel_percentage\": + {\n \"title\": \"Saturated Defective Pixel Percentage\",\n \"type\": + \"number\",\n \"minimum\": 0,\n \"maximum\": 100\n },\n + \ \"s2:reflectance_conversion_factor\": {\n \"title\": \"Reflectance + Conversion Factor\",\n \"type\": \"number\"\n },\n \"s2:mgrs_tile\": + {\n \"title\": \"Sentinel-2 MGRS Tile Identifier\",\n \"type\": + \"string\",\n \"pattern\": \"^\\\\d\\\\d?[CDEFGHJKLMNPQRSTUVWX][ABCDEFGHJKLMNPQRSTUVWXYZ][ABCDEFGHJKLMNPQRSTUV]$\",\n + \ \"deprecated\": true\n }\n },\n \"patternProperties\": + {\n \"^(?!s2:)\": {\n \"$comment\": \"Do not allow other fields + with this prefix\"\n }\n },\n \"additionalProperties\": false\n + \ }\n }\n}" + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/extensions/sentinel2/tests/test_sentinel2.py b/extensions/sentinel2/tests/test_sentinel2.py new file mode 100644 index 000000000..8ceb2c524 --- /dev/null +++ b/extensions/sentinel2/tests/test_sentinel2.py @@ -0,0 +1,224 @@ +"""Tests for pystac.extensions.sentinel2.""" + +from datetime import datetime +from typing import Any, cast + +import pytest + +import pystac +from pystac import Collection, ExtensionTypeError, Item +from pystac.extensions import sentinel2 +from pystac.extensions.sentinel2 import ( + SCHEMA_URI, + Sentinel2Extension, + SummariesSentinel2Extension, +) +from pystac.summaries import RangeSummary +from pystac.utils import str_to_datetime +from tests.utils import TestCases + + +@pytest.fixture +def item() -> Item: + item = pystac.Item( + id="sentinel2-item", + geometry=None, + bbox=None, + datetime=datetime(2020, 1, 1), + properties={}, + ) + Sentinel2Extension.add_to(item) + return item + + +@pytest.fixture +def collection() -> Collection: + return Collection.from_file( + TestCases.get_path("data-files/collections/multi-extent.json") + ) + + +def test_stac_extensions(item: Item) -> None: + assert Sentinel2Extension.has_extension(item) + + +def test_item_repr(item: Item) -> None: + assert ( + Sentinel2Extension.ext(item).__repr__() + == f"" + ) + + +@pytest.mark.vcr() +def test_no_args_validate(item: Item) -> None: + Sentinel2Extension.ext(item).apply() + item.validate() + + +@pytest.mark.vcr() +def test_apply_and_validate(item: Item) -> None: + generation_time = str_to_datetime("2020-01-02T03:04:05Z") + + Sentinel2Extension.ext(item).apply( + tile_id="S2B_OPER_MSI_L2A_TL_SGS__20200102T030405_A012345_T32TQM_N02.09", + datatake_id="GS2B_20200102T030405_012345_N02.09", + product_uri="S2B_MSIL2A_20200101T103029_N0213_R108_T32TQM_20200101T132423.SAFE", + datastrip_id="S2B_OPER_MSI_L2A_DS_SGS__20200102T030405_S20200101T103029_N02.09", + datatake_type="INS-NOBS", + generation_time=generation_time, + processing_baseline="02.13", + water_percentage=1.5, + snow_ice_percentage=0.2, + vegetation_percentage=34.7, + reflectance_conversion_factor=1.032, + mgrs_tile="32TQM", + ) + + ext = Sentinel2Extension.ext(item) + assert ext.tile_id == ( + "S2B_OPER_MSI_L2A_TL_SGS__20200102T030405_A012345_T32TQM_N02.09" + ) + assert ext.datatake_id == "GS2B_20200102T030405_012345_N02.09" + assert ( + ext.product_uri + == "S2B_MSIL2A_20200101T103029_N0213_R108_T32TQM_20200101T132423.SAFE" + ) + assert ( + ext.datastrip_id + == "S2B_OPER_MSI_L2A_DS_SGS__20200102T030405_S20200101T103029_N02.09" + ) + assert ext.datatake_type == "INS-NOBS" + assert ext.generation_time == generation_time + assert ext.processing_baseline == "02.13" + assert ext.water_percentage == 1.5 + assert ext.snow_ice_percentage == 0.2 + assert ext.vegetation_percentage == 34.7 + assert ext.reflectance_conversion_factor == 1.032 + assert ext.mgrs_tile == "32TQM" + + item.validate() + + +def test_processing_baseline_validation(item: Item) -> None: + with pytest.raises(ValueError, match=r"must match NN.NN"): + Sentinel2Extension.ext(item).processing_baseline = "2.13" + + +def test_percentage_validation(item: Item) -> None: + with pytest.raises(ValueError, match=r"water_percentage"): + Sentinel2Extension.ext(item).water_percentage = 101 + + +def test_from_dict() -> None: + d: dict[str, Any] = { + "type": "Feature", + "stac_version": "1.0.0", + "id": "sentinel2-item", + "properties": { + "datetime": "2020-01-01T00:00:00Z", + "s2:tile_id": ( + "S2A_OPER_MSI_L1C_TL_SGS__20200101T000000_A012345_T32TQM_N02.09" + ), + "s2:reflectance_conversion_factor": 1.032, + }, + "geometry": None, + "links": [], + "assets": {}, + "stac_extensions": [SCHEMA_URI], + } + item = pystac.Item.from_dict(d) + + ext = Sentinel2Extension.ext(item) + assert ( + ext.tile_id == "S2A_OPER_MSI_L1C_TL_SGS__20200101T000000_A012345_T32TQM_N02.09" + ) + assert ext.reflectance_conversion_factor == 1.032 + + +def test_to_from_dict(item: Item) -> None: + generation_time = str_to_datetime("2020-01-02T03:04:05Z") + Sentinel2Extension.ext(item).apply( + tile_id="S2A_OPER_MSI_L1C_TL_SGS__20200101T000000_A012345_T32TQM_N02.09", + generation_time=generation_time, + water_percentage=1.5, + ) + + d = item.to_dict() + assert d["properties"][sentinel2.TILE_ID_PROP].startswith("S2A_OPER_MSI_L1C_TL") + assert d["properties"][sentinel2.GENERATION_TIME_PROP] == "2020-01-02T03:04:05Z" + assert d["properties"][sentinel2.WATER_PERCENTAGE_PROP] == 1.5 + + item = pystac.Item.from_dict(d) + ext = Sentinel2Extension.ext(item) + assert ext.generation_time == generation_time + assert ext.water_percentage == 1.5 + + +def test_extension_not_implemented(item: Item) -> None: + item.stac_extensions.remove(Sentinel2Extension.get_schema_uri()) + + with pytest.raises(pystac.ExtensionNotImplemented): + _ = Sentinel2Extension.ext(item) + + +def test_item_ext_add_to(item: Item) -> None: + item.stac_extensions.remove(Sentinel2Extension.get_schema_uri()) + assert Sentinel2Extension.get_schema_uri() not in item.stac_extensions + + _ = Sentinel2Extension.ext(item, add_if_missing=True) + + assert Sentinel2Extension.get_schema_uri() in item.stac_extensions + + +def test_should_raise_exception_when_passing_invalid_extension_object() -> None: + with pytest.raises( + ExtensionTypeError, + match=r"^Sentinel2Extension does not apply to type 'object'$", + ): + Sentinel2Extension.ext(object()) # type: ignore + + +def test_summaries(collection: Collection) -> None: + summaries_ext = Sentinel2Extension.summaries(collection, True) + generation_time = RangeSummary( + str_to_datetime("2020-01-01T00:00:00Z"), + str_to_datetime("2020-01-02T00:00:00Z"), + ) + water_percentage = RangeSummary(0.0, 10.0) + + summaries_ext.tile_id = ["32TQM", "32TQL"] + summaries_ext.generation_time = generation_time + summaries_ext.water_percentage = water_percentage + + assert summaries_ext.tile_id == ["32TQM", "32TQL"] + assert summaries_ext.generation_time == generation_time + assert summaries_ext.water_percentage == water_percentage + + summaries_dict = collection.to_dict()["summaries"] + assert summaries_dict["s2:tile_id"] == ["32TQM", "32TQL"] + assert summaries_dict["s2:generation_time"] == { + "minimum": "2020-01-01T00:00:00Z", + "maximum": "2020-01-02T00:00:00Z", + } + assert summaries_dict["s2:water_percentage"] == { + "minimum": 0.0, + "maximum": 10.0, + } + + +def test_collection_hint(collection: Collection) -> None: + with pytest.raises( + ExtensionTypeError, + match=r"Hint: Did you mean to use `Sentinel2Extension.summaries` instead\\?", + ): + Sentinel2Extension.ext(cast(Any, collection)) + + +def test_summaries_ext_add_to(collection: Collection) -> None: + if Sentinel2Extension.get_schema_uri() in collection.stac_extensions: + collection.stac_extensions.remove(Sentinel2Extension.get_schema_uri()) + + summaries_ext = Sentinel2Extension.summaries(collection, add_if_missing=True) + + assert isinstance(summaries_ext, SummariesSentinel2Extension) + assert Sentinel2Extension.get_schema_uri() in collection.stac_extensions diff --git a/extensions/sentinel3/README.md b/extensions/sentinel3/README.md new file mode 100644 index 000000000..fc07575cc --- /dev/null +++ b/extensions/sentinel3/README.md @@ -0,0 +1,13 @@ +# pystac-ext-sentinel-3 + +[PySTAC](https://pypi.org/project/pystac/) extension package for the [Sentinel-3 Extension](https://github.com/stac-extensions/sentinel-3). +This extension provides fields for Sentinel-3-specific product, processing, quality flag, shape, and altimetry metadata. + +## Supported versions + +- [v0.2.0](https://stac-extensions.github.io/sentinel-3/v0.2.0/schema.json) + +## Versioning + +This package's version corresponds to the version of the extension specification it targets. +When we release updates to the package code without changing the target extension version, we use [post releases](https://packaging.python.org/en/latest/discussions/versioning/#post-releases), e.g. `0.2.0.post1`. diff --git a/extensions/sentinel3/pyproject.toml b/extensions/sentinel3/pyproject.toml new file mode 100644 index 000000000..579e932b8 --- /dev/null +++ b/extensions/sentinel3/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "pystac-ext-sentinel-3" +description = "Sentinel-3 extension for PySTAC" +readme = "README.md" +version = "0.2.0" +authors = [] +maintainers = [] +keywords = ["pystac", "imagery", "raster", "catalog", "STAC", "sentinel-3"] +license = { text = "Apache-2.0" } +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Natural Language :: English", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +requires-python = ">=3.10" +dependencies = ["pystac-core"] + +[project.urls] +Documentation = "https://pystac.readthedocs.io" +Repository = "https://github.com/stac-utils/pystac" +Issues = "https://github.com/stac-utils/pystac/issues" +Changelog = "https://github.com/stac-utils/pystac/blob/main/CHANGELOG.md" +Discussions = "https://github.com/radiantearth/stac-spec/discussions/categories/stac-software" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["pystac"] diff --git a/extensions/sentinel3/pystac/extensions/py.typed b/extensions/sentinel3/pystac/extensions/py.typed new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/extensions/sentinel3/pystac/extensions/py.typed @@ -0,0 +1 @@ + diff --git a/extensions/sentinel3/pystac/extensions/sentinel3.py b/extensions/sentinel3/pystac/extensions/sentinel3.py new file mode 100644 index 000000000..b8afb5195 --- /dev/null +++ b/extensions/sentinel3/pystac/extensions/sentinel3.py @@ -0,0 +1,630 @@ +"""Implements the :stac-ext:`Sentinel-3 Extension `.""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any, Generic, Literal, TypedDict, TypeVar, cast + +import pystac +from pystac.extensions.base import ( + ExtensionManagementMixin, + PropertiesExtension, + SummariesExtension, +) +from pystac.extensions.hooks import ExtensionHooks +from pystac.summaries import RangeSummary + +T = TypeVar("T", pystac.Item, pystac.Asset, pystac.ItemAssetDefinition) + +SCHEMA_URI = "https://stac-extensions.github.io/sentinel-3/v0.2.0/schema.json" +PREFIX = "s3:" + +PRODUCT_TYPE_PROP = PREFIX + "product_type" +PRODUCT_NAME_PROP = PREFIX + "product_name" +PROCESSING_TIMELINESS_PROP = PREFIX + "processing_timeliness" +GSD_PROP = PREFIX + "gsd" +LRM_MODE_PROP = PREFIX + "lrm_mode" +SAR_MODE_PROP = PREFIX + "sar_mode" +BRIGHT_PROP = PREFIX + "bright" +CLOSED_SEA_PROP = PREFIX + "closed_sea" +COASTAL_PROP = PREFIX + "coastal" +CONTINENTAL_ICE_PROP = PREFIX + "continental_ice" +COSMETIC_PROP = PREFIX + "cosmetic" +DUBIOUS_SAMPLES_PROP = PREFIX + "dubious_samples" +DUPLICATED_PROP = PREFIX + "duplicated" +FRESH_INLAND_WATER_PROP = PREFIX + "fresh_inland_water" +INVALID_PROP = PREFIX + "invalid" +LAND_PROP = PREFIX + "land" +OPEN_OCEAN_PROP = PREFIX + "open_ocean" +OUT_OF_RANGE_PROP = PREFIX + "out_of_range" +SALINE_WATER_PROP = PREFIX + "saline_water" +SATURATED_PROP = PREFIX + "saturated" +TIDAL_REGION_PROP = PREFIX + "tidal_region" +SNOW_OR_ICE_PROP = PREFIX + "snow_or_ice" +SHAPE_PROP = PREFIX + "shape" +SPATIAL_RESOLUTION_PROP = PREFIX + "spatial_resolution" +ALTIMETRY_BANDS_PROP = PREFIX + "altimetry_bands" + +SRALGSD = TypedDict("SRALGSD", {"along-track": int, "across-track": int}) +SLSTRGSD = TypedDict("SLSTRGSD", {"S1-S6": int, "S7-S9 and F1-F2": int}) + + +class SynergyGSD(TypedDict): + OLCI: int + SLSTR: SLSTRGSD + + +class AltimetryBand(TypedDict): + """Altimetry band description for Sentinel-3 assets.""" + + band_width: float + description: str + frequency_band: str + center_frequency: float + + +S3GSD = int | SRALGSD | SLSTRGSD | SynergyGSD + + +def _validate_int_list(prop_name: str, v: list[int] | None) -> list[int] | None: + if v is None: + return None + if not all(isinstance(x, int) for x in v): + raise ValueError(f"{prop_name} must contain only integers.") + return v + + +def _validate_altimetry_bands( + v: list[AltimetryBand] | None, +) -> list[AltimetryBand] | None: + if v is None: + return None + allowed_keys = { + "band_width", + "description", + "frequency_band", + "center_frequency", + } + for i, band in enumerate(v): + for key in band.keys(): + if key not in allowed_keys: + raise ValueError(f"{ALTIMETRY_BANDS_PROP}[{i}] has invalid key {key}.") + return v + + +class Sentinel3Extension( + Generic[T], + PropertiesExtension, + ExtensionManagementMixin[pystac.Item | pystac.Collection], +): + """Extension API for the Sentinel-3 extension.""" + + name: Literal["s3"] = "s3" + + def apply( + self, + product_type: str | None = None, + product_name: str | None = None, + processing_timeliness: str | None = None, + gsd: S3GSD | None = None, + lrm_mode: float | None = None, + sar_mode: float | None = None, + bright: float | None = None, + closed_sea: float | None = None, + coastal: float | None = None, + continental_ice: float | None = None, + cosmetic: float | None = None, + dubious_samples: float | None = None, + duplicated: float | None = None, + fresh_inland_water: float | None = None, + invalid: float | None = None, + land: float | None = None, + open_ocean: float | None = None, + out_of_range: float | None = None, + saline_water: float | None = None, + saturated: float | None = None, + tidal_region: float | None = None, + snow_or_ice: float | None = None, + shape: list[int] | None = None, + spatial_resolution: list[int] | None = None, + altimetry_bands: list[AltimetryBand] | None = None, + ) -> None: + self.product_type = product_type + self.product_name = product_name + self.processing_timeliness = processing_timeliness + self.gsd = gsd + self.lrm_mode = lrm_mode + self.sar_mode = sar_mode + self.bright = bright + self.closed_sea = closed_sea + self.coastal = coastal + self.continental_ice = continental_ice + self.cosmetic = cosmetic + self.dubious_samples = dubious_samples + self.duplicated = duplicated + self.fresh_inland_water = fresh_inland_water + self.invalid = invalid + self.land = land + self.open_ocean = open_ocean + self.out_of_range = out_of_range + self.saline_water = saline_water + self.saturated = saturated + self.tidal_region = tidal_region + self.snow_or_ice = snow_or_ice + self.shape = shape + self.spatial_resolution = spatial_resolution + self.altimetry_bands = altimetry_bands + + def _get_str(self, prop: str) -> str | None: + return self._get_property(prop, str) + + def _set_str(self, prop: str, v: str | None) -> None: + self._set_property(prop, v) + + def _get_float(self, prop: str) -> float | None: + return self._get_property(prop, float) + + def _set_float(self, prop: str, v: float | None) -> None: + self._set_property(prop, v) + + @property + def product_type(self) -> str | None: + return self._get_str(PRODUCT_TYPE_PROP) + + @product_type.setter + def product_type(self, v: str | None) -> None: + self._set_str(PRODUCT_TYPE_PROP, v) + + @property + def product_name(self) -> str | None: + return self._get_str(PRODUCT_NAME_PROP) + + @product_name.setter + def product_name(self, v: str | None) -> None: + self._set_str(PRODUCT_NAME_PROP, v) + + @property + def processing_timeliness(self) -> str | None: + return self._get_str(PROCESSING_TIMELINESS_PROP) + + @processing_timeliness.setter + def processing_timeliness(self, v: str | None) -> None: + self._set_str(PROCESSING_TIMELINESS_PROP, v) + + @property + def gsd(self) -> S3GSD | None: + return cast( + S3GSD | None, + self._get_property(GSD_PROP, dict) or self._get_property(GSD_PROP, int), + ) + + @gsd.setter + def gsd(self, v: S3GSD | None) -> None: + self._set_property(GSD_PROP, v) + + @property + def lrm_mode(self) -> float | None: + return self._get_float(LRM_MODE_PROP) + + @lrm_mode.setter + def lrm_mode(self, v: float | None) -> None: + self._set_float(LRM_MODE_PROP, v) + + @property + def sar_mode(self) -> float | None: + return self._get_float(SAR_MODE_PROP) + + @sar_mode.setter + def sar_mode(self, v: float | None) -> None: + self._set_float(SAR_MODE_PROP, v) + + @property + def bright(self) -> float | None: + return self._get_float(BRIGHT_PROP) + + @bright.setter + def bright(self, v: float | None) -> None: + self._set_float(BRIGHT_PROP, v) + + @property + def closed_sea(self) -> float | None: + return self._get_float(CLOSED_SEA_PROP) + + @closed_sea.setter + def closed_sea(self, v: float | None) -> None: + self._set_float(CLOSED_SEA_PROP, v) + + @property + def coastal(self) -> float | None: + return self._get_float(COASTAL_PROP) + + @coastal.setter + def coastal(self, v: float | None) -> None: + self._set_float(COASTAL_PROP, v) + + @property + def continental_ice(self) -> float | None: + return self._get_float(CONTINENTAL_ICE_PROP) + + @continental_ice.setter + def continental_ice(self, v: float | None) -> None: + self._set_float(CONTINENTAL_ICE_PROP, v) + + @property + def cosmetic(self) -> float | None: + return self._get_float(COSMETIC_PROP) + + @cosmetic.setter + def cosmetic(self, v: float | None) -> None: + self._set_float(COSMETIC_PROP, v) + + @property + def dubious_samples(self) -> float | None: + return self._get_float(DUBIOUS_SAMPLES_PROP) + + @dubious_samples.setter + def dubious_samples(self, v: float | None) -> None: + self._set_float(DUBIOUS_SAMPLES_PROP, v) + + @property + def duplicated(self) -> float | None: + return self._get_float(DUPLICATED_PROP) + + @duplicated.setter + def duplicated(self, v: float | None) -> None: + self._set_float(DUPLICATED_PROP, v) + + @property + def fresh_inland_water(self) -> float | None: + return self._get_float(FRESH_INLAND_WATER_PROP) + + @fresh_inland_water.setter + def fresh_inland_water(self, v: float | None) -> None: + self._set_float(FRESH_INLAND_WATER_PROP, v) + + @property + def invalid(self) -> float | None: + return self._get_float(INVALID_PROP) + + @invalid.setter + def invalid(self, v: float | None) -> None: + self._set_float(INVALID_PROP, v) + + @property + def land(self) -> float | None: + return self._get_float(LAND_PROP) + + @land.setter + def land(self, v: float | None) -> None: + self._set_float(LAND_PROP, v) + + @property + def open_ocean(self) -> float | None: + return self._get_float(OPEN_OCEAN_PROP) + + @open_ocean.setter + def open_ocean(self, v: float | None) -> None: + self._set_float(OPEN_OCEAN_PROP, v) + + @property + def out_of_range(self) -> float | None: + return self._get_float(OUT_OF_RANGE_PROP) + + @out_of_range.setter + def out_of_range(self, v: float | None) -> None: + self._set_float(OUT_OF_RANGE_PROP, v) + + @property + def saline_water(self) -> float | None: + return self._get_float(SALINE_WATER_PROP) + + @saline_water.setter + def saline_water(self, v: float | None) -> None: + self._set_float(SALINE_WATER_PROP, v) + + @property + def saturated(self) -> float | None: + return self._get_float(SATURATED_PROP) + + @saturated.setter + def saturated(self, v: float | None) -> None: + self._set_float(SATURATED_PROP, v) + + @property + def tidal_region(self) -> float | None: + return self._get_float(TIDAL_REGION_PROP) + + @tidal_region.setter + def tidal_region(self, v: float | None) -> None: + self._set_float(TIDAL_REGION_PROP, v) + + @property + def snow_or_ice(self) -> float | None: + return self._get_float(SNOW_OR_ICE_PROP) + + @snow_or_ice.setter + def snow_or_ice(self, v: float | None) -> None: + self._set_float(SNOW_OR_ICE_PROP, v) + + @property + def shape(self) -> list[int] | None: + return cast(list[int] | None, self._get_property(SHAPE_PROP, list)) + + @shape.setter + def shape(self, v: list[int] | None) -> None: + self._set_property(SHAPE_PROP, _validate_int_list(SHAPE_PROP, v)) + + @property + def spatial_resolution(self) -> list[int] | None: + return cast(list[int] | None, self._get_property(SPATIAL_RESOLUTION_PROP, list)) + + @spatial_resolution.setter + def spatial_resolution(self, v: list[int] | None) -> None: + self._set_property( + SPATIAL_RESOLUTION_PROP, + _validate_int_list(SPATIAL_RESOLUTION_PROP, v), + ) + + @property + def altimetry_bands(self) -> list[AltimetryBand] | None: + return cast( + list[AltimetryBand] | None, + self._get_property(ALTIMETRY_BANDS_PROP, list), + ) + + @altimetry_bands.setter + def altimetry_bands(self, v: list[AltimetryBand] | None) -> None: + self._set_property(ALTIMETRY_BANDS_PROP, _validate_altimetry_bands(v)) + + @classmethod + def get_schema_uri(cls) -> str: + return SCHEMA_URI + + @classmethod + def ext(cls, obj: T, add_if_missing: bool = False) -> Sentinel3Extension[T]: + if isinstance(obj, pystac.Item): + cls.ensure_has_extension(obj, add_if_missing) + return cast(Sentinel3Extension[T], ItemSentinel3Extension(obj)) + if isinstance(obj, pystac.Asset): + cls.ensure_owner_has_extension(obj, add_if_missing) + return cast(Sentinel3Extension[T], AssetSentinel3Extension(obj)) + if isinstance(obj, pystac.ItemAssetDefinition): + cls.ensure_owner_has_extension(obj, add_if_missing) + return cast(Sentinel3Extension[T], ItemAssetsSentinel3Extension(obj)) + raise pystac.ExtensionTypeError(cls._ext_error_message(obj)) + + @classmethod + def summaries( + cls, obj: pystac.Collection, add_if_missing: bool = False + ) -> SummariesSentinel3Extension: + cls.ensure_has_extension(obj, add_if_missing) + return SummariesSentinel3Extension(obj) + + +class ItemSentinel3Extension(Sentinel3Extension[pystac.Item]): + item: pystac.Item + properties: dict[str, Any] + + def __init__(self, item: pystac.Item): + self.item = item + self.properties = item.properties + + def __repr__(self) -> str: + return f"" + + +class AssetSentinel3Extension(Sentinel3Extension[pystac.Asset]): + asset_href: str + properties: dict[str, Any] + additional_read_properties: Iterable[dict[str, Any]] | None = None + + def __init__(self, asset: pystac.Asset): + self.asset_href = asset.href + self.properties = asset.extra_fields + if asset.owner and isinstance(asset.owner, pystac.Item): + self.additional_read_properties = [asset.owner.properties] + + def __repr__(self) -> str: + return f"" + + +class ItemAssetsSentinel3Extension(Sentinel3Extension[pystac.ItemAssetDefinition]): + properties: dict[str, Any] + asset_defn: pystac.ItemAssetDefinition + + def __init__(self, item_asset: pystac.ItemAssetDefinition): + self.asset_defn = item_asset + self.properties = item_asset.properties + + +class SummariesSentinel3Extension(SummariesExtension): + def _get_list(self, prop: str) -> list[str] | None: + return cast(list[str] | None, self.summaries.get_list(prop)) + + def _get_range(self, prop: str) -> RangeSummary[float] | None: + return cast(RangeSummary[float] | None, self.summaries.get_range(prop)) + + @property + def product_type(self) -> list[str] | None: + return self._get_list(PRODUCT_TYPE_PROP) + + @product_type.setter + def product_type(self, v: list[str] | None) -> None: + self._set_summary(PRODUCT_TYPE_PROP, v) + + @property + def product_name(self) -> list[str] | None: + return self._get_list(PRODUCT_NAME_PROP) + + @product_name.setter + def product_name(self, v: list[str] | None) -> None: + self._set_summary(PRODUCT_NAME_PROP, v) + + @property + def processing_timeliness(self) -> list[str] | None: + return self._get_list(PROCESSING_TIMELINESS_PROP) + + @processing_timeliness.setter + def processing_timeliness(self, v: list[str] | None) -> None: + self._set_summary(PROCESSING_TIMELINESS_PROP, v) + + @property + def gsd(self) -> list[Any] | None: + return self.summaries.get_list(GSD_PROP) + + @gsd.setter + def gsd(self, v: list[Any] | None) -> None: + self._set_summary(GSD_PROP, v) + + @property + def lrm_mode(self) -> RangeSummary[float] | None: + return self._get_range(LRM_MODE_PROP) + + @lrm_mode.setter + def lrm_mode(self, v: RangeSummary[float] | None) -> None: + self._set_summary(LRM_MODE_PROP, v) + + @property + def sar_mode(self) -> RangeSummary[float] | None: + return self._get_range(SAR_MODE_PROP) + + @sar_mode.setter + def sar_mode(self, v: RangeSummary[float] | None) -> None: + self._set_summary(SAR_MODE_PROP, v) + + @property + def bright(self) -> RangeSummary[float] | None: + return self._get_range(BRIGHT_PROP) + + @bright.setter + def bright(self, v: RangeSummary[float] | None) -> None: + self._set_summary(BRIGHT_PROP, v) + + @property + def closed_sea(self) -> RangeSummary[float] | None: + return self._get_range(CLOSED_SEA_PROP) + + @closed_sea.setter + def closed_sea(self, v: RangeSummary[float] | None) -> None: + self._set_summary(CLOSED_SEA_PROP, v) + + @property + def coastal(self) -> RangeSummary[float] | None: + return self._get_range(COASTAL_PROP) + + @coastal.setter + def coastal(self, v: RangeSummary[float] | None) -> None: + self._set_summary(COASTAL_PROP, v) + + @property + def continental_ice(self) -> RangeSummary[float] | None: + return self._get_range(CONTINENTAL_ICE_PROP) + + @continental_ice.setter + def continental_ice(self, v: RangeSummary[float] | None) -> None: + self._set_summary(CONTINENTAL_ICE_PROP, v) + + @property + def cosmetic(self) -> RangeSummary[float] | None: + return self._get_range(COSMETIC_PROP) + + @cosmetic.setter + def cosmetic(self, v: RangeSummary[float] | None) -> None: + self._set_summary(COSMETIC_PROP, v) + + @property + def dubious_samples(self) -> RangeSummary[float] | None: + return self._get_range(DUBIOUS_SAMPLES_PROP) + + @dubious_samples.setter + def dubious_samples(self, v: RangeSummary[float] | None) -> None: + self._set_summary(DUBIOUS_SAMPLES_PROP, v) + + @property + def duplicated(self) -> RangeSummary[float] | None: + return self._get_range(DUPLICATED_PROP) + + @duplicated.setter + def duplicated(self, v: RangeSummary[float] | None) -> None: + self._set_summary(DUPLICATED_PROP, v) + + @property + def fresh_inland_water(self) -> RangeSummary[float] | None: + return self._get_range(FRESH_INLAND_WATER_PROP) + + @fresh_inland_water.setter + def fresh_inland_water(self, v: RangeSummary[float] | None) -> None: + self._set_summary(FRESH_INLAND_WATER_PROP, v) + + @property + def invalid(self) -> RangeSummary[float] | None: + return self._get_range(INVALID_PROP) + + @invalid.setter + def invalid(self, v: RangeSummary[float] | None) -> None: + self._set_summary(INVALID_PROP, v) + + @property + def land(self) -> RangeSummary[float] | None: + return self._get_range(LAND_PROP) + + @land.setter + def land(self, v: RangeSummary[float] | None) -> None: + self._set_summary(LAND_PROP, v) + + @property + def open_ocean(self) -> RangeSummary[float] | None: + return self._get_range(OPEN_OCEAN_PROP) + + @open_ocean.setter + def open_ocean(self, v: RangeSummary[float] | None) -> None: + self._set_summary(OPEN_OCEAN_PROP, v) + + @property + def out_of_range(self) -> RangeSummary[float] | None: + return self._get_range(OUT_OF_RANGE_PROP) + + @out_of_range.setter + def out_of_range(self, v: RangeSummary[float] | None) -> None: + self._set_summary(OUT_OF_RANGE_PROP, v) + + @property + def saline_water(self) -> RangeSummary[float] | None: + return self._get_range(SALINE_WATER_PROP) + + @saline_water.setter + def saline_water(self, v: RangeSummary[float] | None) -> None: + self._set_summary(SALINE_WATER_PROP, v) + + @property + def saturated(self) -> RangeSummary[float] | None: + return self._get_range(SATURATED_PROP) + + @saturated.setter + def saturated(self, v: RangeSummary[float] | None) -> None: + self._set_summary(SATURATED_PROP, v) + + @property + def tidal_region(self) -> RangeSummary[float] | None: + return self._get_range(TIDAL_REGION_PROP) + + @tidal_region.setter + def tidal_region(self, v: RangeSummary[float] | None) -> None: + self._set_summary(TIDAL_REGION_PROP, v) + + @property + def snow_or_ice(self) -> RangeSummary[float] | None: + return self._get_range(SNOW_OR_ICE_PROP) + + @snow_or_ice.setter + def snow_or_ice(self, v: RangeSummary[float] | None) -> None: + self._set_summary(SNOW_OR_ICE_PROP, v) + + +class Sentinel3ExtensionHooks(ExtensionHooks): + schema_uri: str = SCHEMA_URI + prev_extension_ids = {"sentinel-3"} + stac_object_types = {pystac.STACObjectType.ITEM, pystac.STACObjectType.COLLECTION} + + +SENTINEL3_EXTENSION_HOOKS: ExtensionHooks = Sentinel3ExtensionHooks() diff --git a/extensions/sentinel3/tests/cassettes/test_sentinel3/test_apply_and_validate.yaml b/extensions/sentinel3/tests/cassettes/test_sentinel3/test_apply_and_validate.yaml new file mode 100644 index 000000000..16d6d811f --- /dev/null +++ b/extensions/sentinel3/tests/cassettes/test_sentinel3/test_apply_and_validate.yaml @@ -0,0 +1,101 @@ +interactions: +- request: + body: null + headers: {} + method: GET + uri: https://stac-extensions.github.io/sentinel-3/v0.2.0/schema.json + response: + body: + string: "{\n \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n \"$id\": + \"https://stac-extensions.github.io/sentinel-3/v0.2.0/schema.json#\",\n \"title\": + \"Sentinel-3 Extension\",\n \"description\": \"STAC Sentinel-3 Extension + for STAC Items and STAC Collection Summaries.\",\n \"type\": \"object\",\n + \ \"required\": [\n \"stac_extensions\"\n ],\n \"properties\": {\n \"stac_extensions\": + {\n \"type\": \"array\",\n \"contains\": {\n \"const\": \"https://stac-extensions.github.io/sentinel-3/v0.2.0/schema.json\"\n + \ }\n }\n },\n \"oneOf\": [\n {\n \"$comment\": \"This is + the schema for STAC Items.\",\n \"type\": \"object\",\n \"required\": + [\n \"type\",\n \"properties\",\n \"assets\"\n ],\n + \ \"properties\": {\n \"type\": {\n \"const\": \"Feature\"\n + \ },\n \"properties\": {\n \"allOf\": [\n {\n + \ \"$ref\": \"#/definitions/require_any\"\n },\n {\n + \ \"$ref\": \"#/definitions/fields\"\n }\n ]\n + \ },\n \"assets\": {\n \"$ref\": \"#/definitions/assets\"\n + \ }\n }\n },\n {\n \"$comment\": \"This is the schema + for STAC Collections, or more specifically only Collection Summaries in this + case. By default, only checks the existence of the properties, but not the + schema of the summaries.\",\n \"type\": \"object\",\n \"required\": + [\n \"type\",\n \"summaries\"\n ],\n \"properties\": + {\n \"type\": {\n \"const\": \"Collection\"\n },\n + \ \"summaries\": {\n \"$ref\": \"#/definitions/require_any\"\n + \ },\n \"item_assets\": {\n \"$ref\": \"#/definitions/assets\"\n + \ },\n \"assets\": {\n \"$ref\": \"#/definitions/assets\"\n + \ }\n }\n }\n ],\n \"definitions\": {\n \"require_any\": + {\n \"$comment\": \"Please list all fields here so that we can force + the existence of one of them in other parts of the schemas.\",\n \"anyOf\": + [\n {\"required\": [\"s3:bright\"]},\n {\"required\": [\"s3:closed_sea\"]},\n + \ {\"required\": [\"s3:coastal\"]},\n {\"required\": [\"s3:continental_ice\"]},\n + \ {\"required\": [\"s3:cosmetic\"]},\n {\"required\": [\"s3:dubious_samples\"]},\n + \ {\"required\": [\"s3:duplicated\"]},\n {\"required\": [\"s3:fresh_inland_water\"]},\n + \ {\"required\": [\"s3:invalid\"]},\n {\"required\": [\"s3:land\"]},\n + \ {\"required\": [\"s3:open_ocean\"]},\n {\"required\": [\"s3:out_of_range\"]},\n + \ {\"required\": [\"s3:saline_water\"]},\n {\"required\": [\"s3:saturated\"]},\n + \ {\"required\": [\"s3:tidal_region\"]}\n ]\n },\n \"fields\": + {\n \"$comment\": \" Don't require fields here, do that above in the + corresponding schema.\",\n \"type\": \"object\",\n \"properties\": + {\n \"s3:product_type\": {\n \"type\": \"string\"\n },\n + \ \"s3:product_name\": {\n \"type\": \"string\"\n },\n + \ \"s3:processing_timeliness\": {\n \"type\": \"string\"\n + \ },\n \"s3:gsd\": {\n \"oneOf\": [\n {\n + \ \"type\": \"integer\"\n },\n {\n \"title\": + \"SRAL GSD Object\",\n \"type\": \"object\",\n \"required\": + [\"along-track\", \"across-track\"],\n \"properties\": {\n \"along-track\": + {\n \"type\": \"integer\"\n },\n \"across-track\": + {\n \"type\": \"integer\"\n }\n },\n + \ \"additionalProperties\": false\n },\n {\n + \ \"title\": \"Synergy GSD Object\",\n \"type\": + \"object\",\n \"required\": [\"OLCI\", \"SLSTR\"],\n \"properties\": + {\n \"OLCI\": {\n \"type\": \"integer\"\n + \ },\n \"SLSTR\": {\n \"$ref\": + \"#/definitions/SLSTR\"\n }\n },\n \"additionalProperties\": + false\n }, \n {\n \"$ref\": \"#/definitions/SLSTR\"\n + \ }\n ]\n },\n \"s3:lrm_mode\": {\n \"type\": + \"number\"\n },\n \"s3:sar_mode\": {\n \"type\": \"number\"\n + \ },\n \"s3:bright\": {\n \"type\": \"number\"\n },\n + \ \"s3:closed_sea\": {\n \"type\": \"number\"\n },\n + \ \"s3:coastal\": {\n \"type\": \"number\"\n },\n \"s3:continental_ice\": + {\n \"type\": \"number\"\n },\n \"s3:cosmetic\": {\n + \ \"type\": \"number\"\n },\n \"s3:dubious_samples\": + {\n \"type\": \"number\"\n },\n \"s3:duplicated\": + {\n \"type\": \"number\"\n },\n \"s3:fresh_inland_water\": + {\n \"type\": \"number\"\n },\n \"s3:invalid\": {\n + \ \"type\": \"number\"\n },\n \"s3:land\": {\n \"type\": + \"number\"\n },\n \"s3:open_ocean\": {\n \"type\": + \"number\"\n },\n \"s3:out_of_range\": {\n \"type\": + \"number\"\n },\n \"s3:saline_water\": {\n \"type\": + \"number\"\n },\n \"s3:saturated\": {\n \"type\": \"number\"\n + \ },\n \"s3:tidal_region\": {\n \"type\": \"number\"\n + \ },\n \"s3:snow_or_ice\": {\n \"type\": \"number\"\n + \ }\n },\n \"patternProperties\": {\n \"^(?!s3:)\": + {}\n },\n \"additionalProperties\": false\n },\n \"SLSTR\": + {\n \"title\": \"SLSTR GSD Object\",\n \"type\": \"object\",\n \"required\": + [\"S1-S6\", \"S7-S9 and F1-F2\"],\n \"properties\": {\n \"S1-S6\": + {\n \"type\": \"integer\"\n },\n \"S7-S9 and F1-F2\": + {\n \"type\": \"integer\"\n }\n },\n \"additionalProperties\": + false\n },\n \"assets\": {\n \"additionalProperties\": {\n \"type\": + \"object\",\n \"properties\": {\n \"s3:shape\": {\n \"type\": + \"array\",\n \"items\": {\n \"type\": \"integer\"\n + \ }\n },\n \"s3:spatial_resolution\": {\n \"type\": + \"array\",\n \"items\": {\n \"type\": \"integer\"\n + \ }\n },\n \"s3:altimetry_bands\": {\n \"type\": + \"array\",\n \"items\": {\n \"type\": \"object\",\n + \ \"properties\": {\n \"band_width\": {\n \"type\": + \"number\"\n },\n \"description\": {\n \"type\": + \"string\"\n },\n \"frequency_band\": {\n \"type\": + \"string\"\n },\n \"center_frequency\": {\n + \ \"type\": \"number\"\n }\n }\n + \ }\n }\n }\n }\n }\n }\n}\n" + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/extensions/sentinel3/tests/cassettes/test_sentinel3/test_no_args_fails.yaml b/extensions/sentinel3/tests/cassettes/test_sentinel3/test_no_args_fails.yaml new file mode 100644 index 000000000..16d6d811f --- /dev/null +++ b/extensions/sentinel3/tests/cassettes/test_sentinel3/test_no_args_fails.yaml @@ -0,0 +1,101 @@ +interactions: +- request: + body: null + headers: {} + method: GET + uri: https://stac-extensions.github.io/sentinel-3/v0.2.0/schema.json + response: + body: + string: "{\n \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n \"$id\": + \"https://stac-extensions.github.io/sentinel-3/v0.2.0/schema.json#\",\n \"title\": + \"Sentinel-3 Extension\",\n \"description\": \"STAC Sentinel-3 Extension + for STAC Items and STAC Collection Summaries.\",\n \"type\": \"object\",\n + \ \"required\": [\n \"stac_extensions\"\n ],\n \"properties\": {\n \"stac_extensions\": + {\n \"type\": \"array\",\n \"contains\": {\n \"const\": \"https://stac-extensions.github.io/sentinel-3/v0.2.0/schema.json\"\n + \ }\n }\n },\n \"oneOf\": [\n {\n \"$comment\": \"This is + the schema for STAC Items.\",\n \"type\": \"object\",\n \"required\": + [\n \"type\",\n \"properties\",\n \"assets\"\n ],\n + \ \"properties\": {\n \"type\": {\n \"const\": \"Feature\"\n + \ },\n \"properties\": {\n \"allOf\": [\n {\n + \ \"$ref\": \"#/definitions/require_any\"\n },\n {\n + \ \"$ref\": \"#/definitions/fields\"\n }\n ]\n + \ },\n \"assets\": {\n \"$ref\": \"#/definitions/assets\"\n + \ }\n }\n },\n {\n \"$comment\": \"This is the schema + for STAC Collections, or more specifically only Collection Summaries in this + case. By default, only checks the existence of the properties, but not the + schema of the summaries.\",\n \"type\": \"object\",\n \"required\": + [\n \"type\",\n \"summaries\"\n ],\n \"properties\": + {\n \"type\": {\n \"const\": \"Collection\"\n },\n + \ \"summaries\": {\n \"$ref\": \"#/definitions/require_any\"\n + \ },\n \"item_assets\": {\n \"$ref\": \"#/definitions/assets\"\n + \ },\n \"assets\": {\n \"$ref\": \"#/definitions/assets\"\n + \ }\n }\n }\n ],\n \"definitions\": {\n \"require_any\": + {\n \"$comment\": \"Please list all fields here so that we can force + the existence of one of them in other parts of the schemas.\",\n \"anyOf\": + [\n {\"required\": [\"s3:bright\"]},\n {\"required\": [\"s3:closed_sea\"]},\n + \ {\"required\": [\"s3:coastal\"]},\n {\"required\": [\"s3:continental_ice\"]},\n + \ {\"required\": [\"s3:cosmetic\"]},\n {\"required\": [\"s3:dubious_samples\"]},\n + \ {\"required\": [\"s3:duplicated\"]},\n {\"required\": [\"s3:fresh_inland_water\"]},\n + \ {\"required\": [\"s3:invalid\"]},\n {\"required\": [\"s3:land\"]},\n + \ {\"required\": [\"s3:open_ocean\"]},\n {\"required\": [\"s3:out_of_range\"]},\n + \ {\"required\": [\"s3:saline_water\"]},\n {\"required\": [\"s3:saturated\"]},\n + \ {\"required\": [\"s3:tidal_region\"]}\n ]\n },\n \"fields\": + {\n \"$comment\": \" Don't require fields here, do that above in the + corresponding schema.\",\n \"type\": \"object\",\n \"properties\": + {\n \"s3:product_type\": {\n \"type\": \"string\"\n },\n + \ \"s3:product_name\": {\n \"type\": \"string\"\n },\n + \ \"s3:processing_timeliness\": {\n \"type\": \"string\"\n + \ },\n \"s3:gsd\": {\n \"oneOf\": [\n {\n + \ \"type\": \"integer\"\n },\n {\n \"title\": + \"SRAL GSD Object\",\n \"type\": \"object\",\n \"required\": + [\"along-track\", \"across-track\"],\n \"properties\": {\n \"along-track\": + {\n \"type\": \"integer\"\n },\n \"across-track\": + {\n \"type\": \"integer\"\n }\n },\n + \ \"additionalProperties\": false\n },\n {\n + \ \"title\": \"Synergy GSD Object\",\n \"type\": + \"object\",\n \"required\": [\"OLCI\", \"SLSTR\"],\n \"properties\": + {\n \"OLCI\": {\n \"type\": \"integer\"\n + \ },\n \"SLSTR\": {\n \"$ref\": + \"#/definitions/SLSTR\"\n }\n },\n \"additionalProperties\": + false\n }, \n {\n \"$ref\": \"#/definitions/SLSTR\"\n + \ }\n ]\n },\n \"s3:lrm_mode\": {\n \"type\": + \"number\"\n },\n \"s3:sar_mode\": {\n \"type\": \"number\"\n + \ },\n \"s3:bright\": {\n \"type\": \"number\"\n },\n + \ \"s3:closed_sea\": {\n \"type\": \"number\"\n },\n + \ \"s3:coastal\": {\n \"type\": \"number\"\n },\n \"s3:continental_ice\": + {\n \"type\": \"number\"\n },\n \"s3:cosmetic\": {\n + \ \"type\": \"number\"\n },\n \"s3:dubious_samples\": + {\n \"type\": \"number\"\n },\n \"s3:duplicated\": + {\n \"type\": \"number\"\n },\n \"s3:fresh_inland_water\": + {\n \"type\": \"number\"\n },\n \"s3:invalid\": {\n + \ \"type\": \"number\"\n },\n \"s3:land\": {\n \"type\": + \"number\"\n },\n \"s3:open_ocean\": {\n \"type\": + \"number\"\n },\n \"s3:out_of_range\": {\n \"type\": + \"number\"\n },\n \"s3:saline_water\": {\n \"type\": + \"number\"\n },\n \"s3:saturated\": {\n \"type\": \"number\"\n + \ },\n \"s3:tidal_region\": {\n \"type\": \"number\"\n + \ },\n \"s3:snow_or_ice\": {\n \"type\": \"number\"\n + \ }\n },\n \"patternProperties\": {\n \"^(?!s3:)\": + {}\n },\n \"additionalProperties\": false\n },\n \"SLSTR\": + {\n \"title\": \"SLSTR GSD Object\",\n \"type\": \"object\",\n \"required\": + [\"S1-S6\", \"S7-S9 and F1-F2\"],\n \"properties\": {\n \"S1-S6\": + {\n \"type\": \"integer\"\n },\n \"S7-S9 and F1-F2\": + {\n \"type\": \"integer\"\n }\n },\n \"additionalProperties\": + false\n },\n \"assets\": {\n \"additionalProperties\": {\n \"type\": + \"object\",\n \"properties\": {\n \"s3:shape\": {\n \"type\": + \"array\",\n \"items\": {\n \"type\": \"integer\"\n + \ }\n },\n \"s3:spatial_resolution\": {\n \"type\": + \"array\",\n \"items\": {\n \"type\": \"integer\"\n + \ }\n },\n \"s3:altimetry_bands\": {\n \"type\": + \"array\",\n \"items\": {\n \"type\": \"object\",\n + \ \"properties\": {\n \"band_width\": {\n \"type\": + \"number\"\n },\n \"description\": {\n \"type\": + \"string\"\n },\n \"frequency_band\": {\n \"type\": + \"string\"\n },\n \"center_frequency\": {\n + \ \"type\": \"number\"\n }\n }\n + \ }\n }\n }\n }\n }\n }\n}\n" + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/extensions/sentinel3/tests/test_sentinel3.py b/extensions/sentinel3/tests/test_sentinel3.py new file mode 100644 index 000000000..d0dd5d9b8 --- /dev/null +++ b/extensions/sentinel3/tests/test_sentinel3.py @@ -0,0 +1,214 @@ +"""Tests for pystac.extensions.sentinel3.""" + +from datetime import datetime +from typing import Any, cast + +import pytest + +import pystac +from pystac import Collection, ExtensionTypeError, Item +from pystac.extensions import sentinel3 +from pystac.extensions.sentinel3 import ( + SCHEMA_URI, + AltimetryBand, + Sentinel3Extension, + SummariesSentinel3Extension, +) +from pystac.summaries import RangeSummary +from tests.utils import TestCases + + +@pytest.fixture +def item() -> Item: + item = pystac.Item( + id="sentinel3-item", + geometry=None, + bbox=None, + datetime=datetime(2020, 1, 1), + properties={}, + ) + item.add_asset( + "measurement", pystac.Asset(href="https://example.com/measurement.nc") + ) + Sentinel3Extension.add_to(item) + return item + + +@pytest.fixture +def collection() -> Collection: + return Collection.from_file( + TestCases.get_path("data-files/collections/multi-extent.json") + ) + + +def test_stac_extensions(item: Item) -> None: + assert Sentinel3Extension.has_extension(item) + + +def test_item_repr(item: Item) -> None: + assert ( + Sentinel3Extension.ext(item).__repr__() + == f"" + ) + + +def test_asset_repr(item: Item) -> None: + asset = item.assets["measurement"] + assert ( + Sentinel3Extension.ext(asset).__repr__() + == f"" + ) + + +@pytest.mark.vcr() +def test_no_args_fails(item: Item) -> None: + Sentinel3Extension.ext(item).apply() + with pytest.raises(pystac.STACValidationError): + item.validate() + + +@pytest.mark.vcr() +def test_apply_and_validate(item: Item) -> None: + altimetry_bands: list[AltimetryBand] = [ + { + "band_width": 320.0, + "description": "Ku band", + "frequency_band": "Ku", + "center_frequency": 13.575, + } + ] + + Sentinel3Extension.ext(item).apply( + product_type="OL_1_EFR___", + product_name="S3A_OL_1_EFR____20200101T000000_20200101T000300_20200102T120000", + processing_timeliness="NT", + gsd={"OLCI": 300, "SLSTR": {"S1-S6": 500, "S7-S9 and F1-F2": 1000}}, + bright=1.0, + ) + Sentinel3Extension.ext(item.assets["measurement"]).apply( + shape=[4091, 4865], + spatial_resolution=[300, 300], + altimetry_bands=altimetry_bands, + ) + + ext = Sentinel3Extension.ext(item) + assert ext.product_type == "OL_1_EFR___" + assert ext.processing_timeliness == "NT" + assert ext.bright == 1.0 + assert ext.gsd == { + "OLCI": 300, + "SLSTR": {"S1-S6": 500, "S7-S9 and F1-F2": 1000}, + } + + asset_ext = Sentinel3Extension.ext(item.assets["measurement"]) + assert asset_ext.shape == [4091, 4865] + assert asset_ext.spatial_resolution == [300, 300] + assert asset_ext.altimetry_bands == altimetry_bands + + item.validate() + + +def test_shape_must_be_ints(item: Item) -> None: + with pytest.raises(ValueError, match=r"must contain only integers"): + Sentinel3Extension.ext(item.assets["measurement"]).shape = [1, "2"] # type: ignore[list-item] + + +def test_from_dict() -> None: + d: dict[str, Any] = { + "type": "Feature", + "stac_version": "1.0.0", + "id": "sentinel3-item", + "properties": { + "datetime": "2020-01-01T00:00:00Z", + "s3:bright": 1.0, + "s3:product_type": "OL_1_EFR___", + }, + "geometry": None, + "links": [], + "assets": { + "measurement": { + "href": "https://example.com/measurement.nc", + "s3:shape": [4091, 4865], + } + }, + "stac_extensions": [SCHEMA_URI], + } + item = pystac.Item.from_dict(d) + + assert Sentinel3Extension.ext(item).bright == 1.0 + assert Sentinel3Extension.ext(item.assets["measurement"]).shape == [4091, 4865] + + +def test_to_from_dict(item: Item) -> None: + Sentinel3Extension.ext(item).apply(bright=1.0, product_type="OL_1_EFR___") + Sentinel3Extension.ext(item.assets["measurement"]).apply(shape=[100, 200]) + + d = item.to_dict() + assert d["properties"][sentinel3.BRIGHT_PROP] == 1.0 + assert d["properties"][sentinel3.PRODUCT_TYPE_PROP] == "OL_1_EFR___" + assert d["assets"]["measurement"][sentinel3.SHAPE_PROP] == [100, 200] + + item = pystac.Item.from_dict(d) + assert Sentinel3Extension.ext(item).bright == 1.0 + assert Sentinel3Extension.ext(item.assets["measurement"]).shape == [100, 200] + + +def test_extension_not_implemented(item: Item) -> None: + item.stac_extensions.remove(Sentinel3Extension.get_schema_uri()) + + with pytest.raises(pystac.ExtensionNotImplemented): + _ = Sentinel3Extension.ext(item) + + with pytest.raises(pystac.ExtensionNotImplemented): + _ = Sentinel3Extension.ext(item.assets["measurement"]) + + +def test_item_ext_add_to(item: Item) -> None: + item.stac_extensions.remove(Sentinel3Extension.get_schema_uri()) + _ = Sentinel3Extension.ext(item, add_if_missing=True) + assert Sentinel3Extension.get_schema_uri() in item.stac_extensions + + +def test_asset_ext_add_to(item: Item) -> None: + item.stac_extensions.remove(Sentinel3Extension.get_schema_uri()) + _ = Sentinel3Extension.ext(item.assets["measurement"], add_if_missing=True) + assert Sentinel3Extension.get_schema_uri() in item.stac_extensions + + +def test_should_raise_exception_when_passing_invalid_extension_object() -> None: + with pytest.raises( + ExtensionTypeError, + match=r"^Sentinel3Extension does not apply to type 'object'$", + ): + Sentinel3Extension.ext(object()) # type: ignore + + +def test_summaries(collection: Collection) -> None: + summaries_ext = Sentinel3Extension.summaries(collection, True) + summaries_ext.product_type = ["OL_1_EFR___"] + summaries_ext.bright = RangeSummary(0.0, 1.0) + + assert summaries_ext.product_type == ["OL_1_EFR___"] + assert summaries_ext.bright == RangeSummary(0.0, 1.0) + + summaries_dict = collection.to_dict()["summaries"] + assert summaries_dict["s3:product_type"] == ["OL_1_EFR___"] + assert summaries_dict["s3:bright"] == {"minimum": 0.0, "maximum": 1.0} + + +def test_collection_hint(collection: Collection) -> None: + with pytest.raises( + ExtensionTypeError, + match=r"Hint: Did you mean to use `Sentinel3Extension.summaries` instead\\?", + ): + Sentinel3Extension.ext(cast(Any, collection)) + + +def test_summaries_ext_add_to(collection: Collection) -> None: + if Sentinel3Extension.get_schema_uri() in collection.stac_extensions: + collection.stac_extensions.remove(Sentinel3Extension.get_schema_uri()) + + summaries_ext = Sentinel3Extension.summaries(collection, add_if_missing=True) + + assert isinstance(summaries_ext, SummariesSentinel3Extension) + assert Sentinel3Extension.get_schema_uri() in collection.stac_extensions diff --git a/pyproject.toml b/pyproject.toml index f8a61616b..5bfc4668e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,20 +26,28 @@ dependencies = [ "pystac-core==1.15.0-rc.1", # x-release-please-version "pystac-ext-classification", "pystac-ext-datacube", + "pystac-ext-earthquake", "pystac-ext-eo", "pystac-ext-file", "pystac-ext-grid", + "pystac-ext-insar", "pystac-ext-item-assets", "pystac-ext-label", "pystac-ext-mgrs", "pystac-ext-mlm", + "pystac-ext-order", "pystac-ext-pointcloud", + "pystac-ext-processing", + "pystac-ext-product", "pystac-ext-projection", "pystac-ext-raster", "pystac-ext-render", "pystac-ext-sar", "pystac-ext-sat", "pystac-ext-scientific", + "pystac-ext-sentinel-1", + "pystac-ext-sentinel-2", + "pystac-ext-sentinel-3", "pystac-ext-storage", "pystac-ext-table", "pystac-ext-timestamps", @@ -137,20 +145,28 @@ mypy_path = [ "core", "extensions/classification", "extensions/datacube", + "extensions/earthquake", "extensions/eo", "extensions/file", "extensions/grid", + "extensions/insar", "extensions/item_assets", "extensions/label", "extensions/mgrs", "extensions/mlm", + "extensions/order", "extensions/pointcloud", + "extensions/processing", + "extensions/product", "extensions/projection", "extensions/raster", "extensions/render", "extensions/sar", "extensions/sat", "extensions/scientific", + "extensions/sentinel1", + "extensions/sentinel2", + "extensions/sentinel3", "extensions/storage", "extensions/table", "extensions/timestamps", @@ -183,20 +199,28 @@ only-include = ["README.md"] pystac-core = { workspace = true } pystac-ext-classification = { workspace = true } pystac-ext-datacube = { workspace = true } +pystac-ext-earthquake = { workspace = true } pystac-ext-eo = { workspace = true } pystac-ext-file = { workspace = true } pystac-ext-grid = { workspace = true } +pystac-ext-insar = { workspace = true } pystac-ext-item-assets = { workspace = true } pystac-ext-label = { workspace = true } pystac-ext-mgrs = { workspace = true } pystac-ext-mlm = { workspace = true } +pystac-ext-order = { workspace = true } pystac-ext-pointcloud = { workspace = true } +pystac-ext-processing = { workspace = true } +pystac-ext-product = { workspace = true } pystac-ext-projection = { workspace = true } pystac-ext-raster = { workspace = true } pystac-ext-render = { workspace = true } pystac-ext-sar = { workspace = true } pystac-ext-sat = { workspace = true } pystac-ext-scientific = { workspace = true } +pystac-ext-sentinel-1 = { workspace = true } +pystac-ext-sentinel-2 = { workspace = true } +pystac-ext-sentinel-3 = { workspace = true } pystac-ext-storage = { workspace = true } pystac-ext-table = { workspace = true } pystac-ext-timestamps = { workspace = true } diff --git a/uv.lock b/uv.lock index d365ccf52..68b41b8ee 100644 --- a/uv.lock +++ b/uv.lock @@ -24,20 +24,28 @@ members = [ "pystac-core", "pystac-ext-classification", "pystac-ext-datacube", + "pystac-ext-earthquake", "pystac-ext-eo", "pystac-ext-file", "pystac-ext-grid", + "pystac-ext-insar", "pystac-ext-item-assets", "pystac-ext-label", "pystac-ext-mgrs", "pystac-ext-mlm", + "pystac-ext-order", "pystac-ext-pointcloud", + "pystac-ext-processing", + "pystac-ext-product", "pystac-ext-projection", "pystac-ext-raster", "pystac-ext-render", "pystac-ext-sar", "pystac-ext-sat", "pystac-ext-scientific", + "pystac-ext-sentinel-1", + "pystac-ext-sentinel-2", + "pystac-ext-sentinel-3", "pystac-ext-storage", "pystac-ext-table", "pystac-ext-timestamps", @@ -3671,20 +3679,28 @@ dependencies = [ { name = "pystac-core" }, { name = "pystac-ext-classification" }, { name = "pystac-ext-datacube" }, + { name = "pystac-ext-earthquake" }, { name = "pystac-ext-eo" }, { name = "pystac-ext-file" }, { name = "pystac-ext-grid" }, + { name = "pystac-ext-insar" }, { name = "pystac-ext-item-assets" }, { name = "pystac-ext-label" }, { name = "pystac-ext-mgrs" }, { name = "pystac-ext-mlm" }, + { name = "pystac-ext-order" }, { name = "pystac-ext-pointcloud" }, + { name = "pystac-ext-processing" }, + { name = "pystac-ext-product" }, { name = "pystac-ext-projection" }, { name = "pystac-ext-raster" }, { name = "pystac-ext-render" }, { name = "pystac-ext-sar" }, { name = "pystac-ext-sat" }, { name = "pystac-ext-scientific" }, + { name = "pystac-ext-sentinel-1" }, + { name = "pystac-ext-sentinel-2" }, + { name = "pystac-ext-sentinel-3" }, { name = "pystac-ext-storage" }, { name = "pystac-ext-table" }, { name = "pystac-ext-timestamps" }, @@ -3778,20 +3794,28 @@ requires-dist = [ { name = "pystac-core", editable = "core" }, { name = "pystac-ext-classification", editable = "extensions/classification" }, { name = "pystac-ext-datacube", editable = "extensions/datacube" }, + { name = "pystac-ext-earthquake", editable = "extensions/earthquake" }, { name = "pystac-ext-eo", editable = "extensions/eo" }, { name = "pystac-ext-file", editable = "extensions/file" }, { name = "pystac-ext-grid", editable = "extensions/grid" }, + { name = "pystac-ext-insar", editable = "extensions/insar" }, { name = "pystac-ext-item-assets", editable = "extensions/item_assets" }, { name = "pystac-ext-label", editable = "extensions/label" }, { name = "pystac-ext-mgrs", editable = "extensions/mgrs" }, { name = "pystac-ext-mlm", editable = "extensions/mlm" }, + { name = "pystac-ext-order", editable = "extensions/order" }, { name = "pystac-ext-pointcloud", editable = "extensions/pointcloud" }, + { name = "pystac-ext-processing", editable = "extensions/processing" }, + { name = "pystac-ext-product", editable = "extensions/product" }, { name = "pystac-ext-projection", editable = "extensions/projection" }, { name = "pystac-ext-raster", editable = "extensions/raster" }, { name = "pystac-ext-render", editable = "extensions/render" }, { name = "pystac-ext-sar", editable = "extensions/sar" }, { name = "pystac-ext-sat", editable = "extensions/sat" }, { name = "pystac-ext-scientific", editable = "extensions/scientific" }, + { name = "pystac-ext-sentinel-1", editable = "extensions/sentinel1" }, + { name = "pystac-ext-sentinel-2", editable = "extensions/sentinel2" }, + { name = "pystac-ext-sentinel-3", editable = "extensions/sentinel3" }, { name = "pystac-ext-storage", editable = "extensions/storage" }, { name = "pystac-ext-table", editable = "extensions/table" }, { name = "pystac-ext-timestamps", editable = "extensions/timestamps" }, @@ -3902,6 +3926,17 @@ dependencies = [ [package.metadata] requires-dist = [{ name = "pystac-core", editable = "core" }] +[[package]] +name = "pystac-ext-earthquake" +version = "1.0.0" +source = { editable = "extensions/earthquake" } +dependencies = [ + { name = "pystac-core" }, +] + +[package.metadata] +requires-dist = [{ name = "pystac-core", editable = "core" }] + [[package]] name = "pystac-ext-eo" version = "1.1.0rc0" @@ -3935,6 +3970,17 @@ dependencies = [ [package.metadata] requires-dist = [{ name = "pystac-core", editable = "core" }] +[[package]] +name = "pystac-ext-insar" +version = "1.0.0" +source = { editable = "extensions/insar" } +dependencies = [ + { name = "pystac-core" }, +] + +[package.metadata] +requires-dist = [{ name = "pystac-core", editable = "core" }] + [[package]] name = "pystac-ext-item-assets" version = "1.0.0rc0" @@ -3979,6 +4025,17 @@ dependencies = [ [package.metadata] requires-dist = [{ name = "pystac-core", editable = "core" }] +[[package]] +name = "pystac-ext-order" +version = "1.1.0" +source = { editable = "extensions/order" } +dependencies = [ + { name = "pystac-core" }, +] + +[package.metadata] +requires-dist = [{ name = "pystac-core", editable = "core" }] + [[package]] name = "pystac-ext-pointcloud" version = "1.0.0rc0" @@ -3990,6 +4047,28 @@ dependencies = [ [package.metadata] requires-dist = [{ name = "pystac-core", editable = "core" }] +[[package]] +name = "pystac-ext-processing" +version = "1.2.0" +source = { editable = "extensions/processing" } +dependencies = [ + { name = "pystac-core" }, +] + +[package.metadata] +requires-dist = [{ name = "pystac-core", editable = "core" }] + +[[package]] +name = "pystac-ext-product" +version = "1.0.0" +source = { editable = "extensions/product" } +dependencies = [ + { name = "pystac-core" }, +] + +[package.metadata] +requires-dist = [{ name = "pystac-core", editable = "core" }] + [[package]] name = "pystac-ext-projection" version = "2.0.0rc0" @@ -4056,6 +4135,39 @@ dependencies = [ [package.metadata] requires-dist = [{ name = "pystac-core", editable = "core" }] +[[package]] +name = "pystac-ext-sentinel-1" +version = "0.2.0" +source = { editable = "extensions/sentinel1" } +dependencies = [ + { name = "pystac-core" }, +] + +[package.metadata] +requires-dist = [{ name = "pystac-core", editable = "core" }] + +[[package]] +name = "pystac-ext-sentinel-2" +version = "1.0.0" +source = { editable = "extensions/sentinel2" } +dependencies = [ + { name = "pystac-core" }, +] + +[package.metadata] +requires-dist = [{ name = "pystac-core", editable = "core" }] + +[[package]] +name = "pystac-ext-sentinel-3" +version = "0.2.0" +source = { editable = "extensions/sentinel3" } +dependencies = [ + { name = "pystac-core" }, +] + +[package.metadata] +requires-dist = [{ name = "pystac-core", editable = "core" }] + [[package]] name = "pystac-ext-storage" version = "2.0.0rc0"