diff --git a/docs/api/extensions.rst b/docs/api/extensions.rst index ce37d3215..89406271b 100644 --- a/docs/api/extensions.rst +++ b/docs/api/extensions.rst @@ -24,6 +24,7 @@ pystac.extensions mlm.AssetGeneralMLMExtension mlm.AssetDetailedMLMExtension pointcloud.PointcloudExtension + product.ProductExtension projection.ProjectionExtension raster.RasterExtension render.RenderExtension diff --git a/docs/api/extensions/product.rst b/docs/api/extensions/product.rst new file mode 100644 index 000000000..8951ff7ba --- /dev/null +++ b/docs/api/extensions/product.rst @@ -0,0 +1,12 @@ +pystac.extensions.product +========================= + +The Product Extension exposes product packaging, timeliness, acquisition type, +and lifecycle status metadata on Items, Collections, Assets, and Item Asset +Definitions. Lifecycle status values are represented by +:class:`~pystac.extensions.product.ProductStatus`; Collection summaries accept +lists of those values. + +.. automodule:: pystac.extensions.product + :members: + :undoc-members: 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/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..d336518d4 --- /dev/null +++ b/extensions/product/pystac/extensions/product.py @@ -0,0 +1,283 @@ +"""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" +STATUS_PROP: str = PREFIX + "status" + + +class AcquisitionType(StringEnum): + """Allowed values for product:acquisition_type.""" + + NOMINAL = "nominal" + CALIBRATION = "calibration" + OTHER = "other" + + +class ProductStatus(StringEnum): + """Allowed values for :data:`product:status `.""" + + ARCHIVED = "archived" + ACQUIRED = "acquired" + CANCELLED = "cancelled" + FAILED = "failed" + PLANNED = "planned" + POTENTIAL = "potential" + REJECTED = "rejected" + QUALITY_DEGRADED = "qualitydegraded" + + +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, + status: ProductStatus | 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 + self.status = status + + @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)) + + @property + def status(self) -> ProductStatus | None: + """Gets the product lifecycle status.""" + raw = self._get_property(STATUS_PROP, str) + return None if raw is None else ProductStatus(raw) + + @status.setter + def status(self, v: ProductStatus | None) -> None: + """Sets the product lifecycle status. + + Args: + v: The product lifecycle status. + """ + self._set_property(STATUS_PROP, None if v is None else v.value) + + @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) + + @property + def status(self) -> list[ProductStatus] | None: + """Get the product lifecycle statuses from the summaries.""" + return self.summaries.get_list(STATUS_PROP) + + @status.setter + def status(self, v: list[ProductStatus] | None) -> None: + """Set the product lifecycle statuses in the summaries. + + Args: + v: The product lifecycle statuses. + """ + self._set_summary(STATUS_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..4f5dbd1d3 --- /dev/null +++ b/extensions/product/tests/test_product.py @@ -0,0 +1,129 @@ +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, + STATUS_PROP, + TIMELINESS_CATEGORY_PROP, + TIMELINESS_PROP, + TYPE_PROP, + AcquisitionType, + ProductExtension, + ProductStatus, +) + +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, + status=ProductStatus.ACQUIRED, + ) + + 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 item.properties[STATUS_PROP] == "acquired" + assert ext.acquisition_type == AcquisitionType.NOMINAL + assert ext.status == ProductStatus.ACQUIRED + + # 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"] + sext.status = [ProductStatus.PLANNED, ProductStatus.ACQUIRED] + assert col.summaries.lists[TYPE_PROP] == ["L1C", "L2A"] + assert col.summaries.lists[STATUS_PROP] == [ + ProductStatus.PLANNED, + ProductStatus.ACQUIRED, + ] + + +def test_product_status_values_match_schema() -> None: + assert {status.value for status in ProductStatus} == { + "archived", + "acquired", + "cancelled", + "failed", + "planned", + "potential", + "rejected", + "qualitydegraded", + } + + +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/pyproject.toml b/pyproject.toml index 0bd3e592e..736e2c6e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ dependencies = [ "pystac-ext-mgrs", "pystac-ext-mlm", "pystac-ext-pointcloud", + "pystac-ext-product", "pystac-ext-projection", "pystac-ext-raster", "pystac-ext-render", @@ -145,6 +146,7 @@ mypy_path = [ "extensions/mgrs", "extensions/mlm", "extensions/pointcloud", + "extensions/product", "extensions/projection", "extensions/raster", "extensions/render", @@ -191,6 +193,7 @@ pystac-ext-label = { workspace = true } pystac-ext-mgrs = { workspace = true } pystac-ext-mlm = { workspace = true } pystac-ext-pointcloud = { workspace = true } +pystac-ext-product = { workspace = true } pystac-ext-projection = { workspace = true } pystac-ext-raster = { workspace = true } pystac-ext-render = { workspace = true } diff --git a/pystac/extensions/ext.py b/pystac/extensions/ext.py index 0b72e9feb..552179fbf 100644 --- a/pystac/extensions/ext.py +++ b/pystac/extensions/ext.py @@ -25,6 +25,7 @@ MLMExtension, ) from pystac.extensions.pointcloud import PointcloudExtension +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 @@ -55,6 +56,7 @@ "mgrs", "mlm", "pc", + "product", "proj", "raster", "render", @@ -79,6 +81,7 @@ MgrsExtension.name: MgrsExtension, MLMExtension.name: MLMExtension, PointcloudExtension.name: PointcloudExtension, + ProductExtension.name: ProductExtension, ProjectionExtension.name: ProjectionExtension, RasterExtension.name: RasterExtension, RenderExtension.name: RenderExtension, @@ -156,6 +159,10 @@ class CollectionExt(CatalogExt): def cube(self) -> DatacubeExtension[Collection]: return DatacubeExtension.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 @@ -248,6 +255,10 @@ def mlm(self) -> MLMExtension[Item]: def pc(self) -> PointcloudExtension[Item]: return PointcloudExtension.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) @@ -363,6 +374,10 @@ def eo(self) -> EOExtension[U]: def pc(self) -> PointcloudExtension[U]: return PointcloudExtension.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) @@ -436,6 +451,10 @@ class ItemAssetExt(_AssetExt[ItemAssetDefinition]): def mlm(self) -> MLMExtension[ItemAssetDefinition]: return MLMExtension.ext(self.stac_object) + @property + def product(self) -> ProductExtension[ItemAssetDefinition]: + return ProductExtension.ext(self.stac_object) + @property def storage(self) -> StorageExtension[ItemAssetDefinition]: return StorageExtension.ext(self.stac_object) diff --git a/uv.lock b/uv.lock index 9fb77c9fd..16eb8addb 100644 --- a/uv.lock +++ b/uv.lock @@ -35,6 +35,7 @@ members = [ "pystac-ext-mgrs", "pystac-ext-mlm", "pystac-ext-pointcloud", + "pystac-ext-product", "pystac-ext-projection", "pystac-ext-raster", "pystac-ext-render", @@ -3806,6 +3807,7 @@ dependencies = [ { name = "pystac-ext-mgrs" }, { name = "pystac-ext-mlm" }, { name = "pystac-ext-pointcloud" }, + { name = "pystac-ext-product" }, { name = "pystac-ext-projection" }, { name = "pystac-ext-raster" }, { name = "pystac-ext-render" }, @@ -3914,6 +3916,7 @@ requires-dist = [ { name = "pystac-ext-mgrs", editable = "extensions/mgrs" }, { name = "pystac-ext-mlm", editable = "extensions/mlm" }, { name = "pystac-ext-pointcloud", editable = "extensions/pointcloud" }, + { 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" }, @@ -4133,6 +4136,17 @@ dependencies = [ [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.1"