From 278ecf4f13ccb88c138482d4fe68be6dbe76f1ee Mon Sep 17 00:00:00 2001 From: guillemc23 Date: Fri, 10 Oct 2025 18:29:18 +0200 Subject: [PATCH 01/12] feat: Added `processing` extension and properties --- pystac/extensions/processing.py | 161 ++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 pystac/extensions/processing.py diff --git a/pystac/extensions/processing.py b/pystac/extensions/processing.py new file mode 100644 index 000000000..67664c567 --- /dev/null +++ b/pystac/extensions/processing.py @@ -0,0 +1,161 @@ +"""Implements the :stac-ext:`Processing ` STAC Extension. + +https://github.com/stac-extensions/processing +""" + +from __future__ import annotations + +from typing import ( + Any, + Generic, + Literal, + Self, + TypeVar, + cast, +) + +import pystac +from pystac.extensions import item_assets +from pystac.extensions.base import ( + ExtensionManagementMixin, + PropertiesExtension, +) +from pystac.utils import StringEnum + +T = TypeVar("T", pystac.Item, pystac.Asset, item_assets.AssetDefinition) + +SCHEMA_URI: str = "https://stac-extensions.github.io/processing/v1.2.0/schema.json" +SCHEMA_URIS: list[str] = [ + SCHEMA_URI, +] +PREFIX: str = "processing:" + +# Field names +LEVEL_PROP: str = PREFIX + "level" +DATETIME_PROP: str = PREFIX + "datetime" +EXPRESSION_PROP: str = PREFIX + "expression" +LINEAGE_PROP: str = PREFIX + "lineage" +FACILITY_PROP: str = PREFIX + "facility" +VERSION_PROP: str = PREFIX + "version" +SOFTWARE_PROP: str = PREFIX + "software" + + +class ProcessingLevel(StringEnum): + RAW = "RAW" + L0 = "L0" + L1 = "L1" + L2 = "L2" + L3 = "L3" + L4 = "L4" + + +class ProcessingExtension( + Generic[T], + PropertiesExtension, + ExtensionManagementMixin[pystac.Item | pystac.Collection], +): + """An abstract class that can be used to extend the properties of an + :class:`~pystac.Item` or :class:`~pystac.Asset` with properties from the + :stac-ext:`Processing Extension `. This class is generic over the type + of STAC Object to be extended (e.g. :class:`~pystac.Item`, + :class:`~pystac.Collection`). + + To create a concrete instance of :class:`ProcessingExtension`, use the + :meth:`ProcessingExtension.ext` method. For example: + + .. code-block:: python + + >>> item: pystac.Item = ... + >>> proc_ext = ProcessingExtension.ext(item) + + """ + + def __init__(self: Self, item: pystac.Item) -> None: + self.item = item + self.properties = item.properties + + def __repr__(self: Self) -> str: + return f"" + + def apply(self: Self, level: str | None = None) -> None: + self.level = level + + @property + def level(self: Self) -> str | None: + return self._get_property(LEVEL_PROP, str) + + @level.setter + def level(self: Self, v: str | None) -> None: + self._set_property(LEVEL_PROP, v, pop_if_none=True) + + @property + def datetime(self: Self) -> str | None: + return self._get_property(DATETIME_PROP, str) + + @datetime.setter + def datetime(self: Self, v: str | None) -> None: + self._set_property(DATETIME_PROP, v, pop_if_none=True) + + @property + def expression(self: Self) -> str | None: + return self._get_property(EXPRESSION_PROP, str) + + @expression.setter + def expression(self: Self, v: str | None) -> None: + self._set_property(EXPRESSION_PROP, v, pop_if_none=True) + + @property + def lineage(self: Self) -> str | None: + return self._get_property(LINEAGE_PROP, str) + + @lineage.setter + def lineage(self: Self, v: str | None) -> None: + self._set_property(LINEAGE_PROP, v, pop_if_none=True) + + @property + def facility(self: Self) -> str | None: + return self._get_property(FACILITY_PROP, str) + + @facility.setter + def facility(self: Self, v: str | None) -> None: + self._set_property(FACILITY_PROP, v, pop_if_none=True) + + @property + def version(self: Self) -> str | None: + return self._get_property(VERSION_PROP, str) + + @version.setter + def version(self: Self, v: str | None) -> None: + self._set_property(VERSION_PROP, v, pop_if_none=True) + + @property + def software(self: Self) -> str | None: + return self._get_property(SOFTWARE_PROP, str) + + @software.setter + def software(self: Self, v: str | None) -> None: + self._set_property(SOFTWARE_PROP, v, pop_if_none=True) + + @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, ItemProcessingExtension(obj)) + else: + raise pystac.ExtensionTypeError(cls._ext_error_message(obj)) + + +class ItemProcessingExtension(ProcessingExtension[pystac.Item]): + item: pystac.Item + properties: dict[str, Any] + + def __init__(self: Self, item: pystac.Item) -> None: + self.item = item + self.properties = item.properties + + def __repr__(self: Self) -> str: + return f"" From 40a44dc53195e16c20b6cde06aa37e67635580ba Mon Sep 17 00:00:00 2001 From: guillemc23 Date: Fri, 10 Oct 2025 18:38:15 +0200 Subject: [PATCH 02/12] feat: Added `ProcessingLevel` options and validation --- pystac/extensions/processing.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/pystac/extensions/processing.py b/pystac/extensions/processing.py index 67664c567..187480730 100644 --- a/pystac/extensions/processing.py +++ b/pystac/extensions/processing.py @@ -20,7 +20,7 @@ ExtensionManagementMixin, PropertiesExtension, ) -from pystac.utils import StringEnum +from pystac.utils import StringEnum, datetime_to_str, map_opt, str_to_datetime T = TypeVar("T", pystac.Item, pystac.Asset, item_assets.AssetDefinition) @@ -44,7 +44,11 @@ class ProcessingLevel(StringEnum): RAW = "RAW" L0 = "L0" L1 = "L1" + L1A = "L1A" + L1B = "L1B" + L1C = "L1C" L2 = "L2" + L2A = "L2A" L3 = "L3" L4 = "L4" @@ -81,12 +85,15 @@ def apply(self: Self, level: str | None = None) -> None: self.level = level @property - def level(self: Self) -> str | None: - return self._get_property(LEVEL_PROP, str) + def level(self: Self) -> ProcessingLevel | None: + """Get or sets the processing level of the object.""" + return map_opt( + lambda x: ProcessingLevel(x), self._get_property(LEVEL_PROP, str) + ) @level.setter - def level(self: Self, v: str | None) -> None: - self._set_property(LEVEL_PROP, v, pop_if_none=True) + def level(self: Self, v: ProcessingLevel | None) -> None: + self._set_property(LEVEL_PROP, map_opt(lambda x: x.value, v), pop_if_none=True) @property def datetime(self: Self) -> str | None: From 7167d6c545bf50923b95ee9e7d661d2075a43553 Mon Sep 17 00:00:00 2001 From: guillemc23 Date: Fri, 10 Oct 2025 20:00:24 +0200 Subject: [PATCH 03/12] feat: Properties and RelTypes --- pystac/extensions/processing.py | 180 ++++++++++++++++++++++++++++---- 1 file changed, 162 insertions(+), 18 deletions(-) diff --git a/pystac/extensions/processing.py b/pystac/extensions/processing.py index 187480730..38d48b0d1 100644 --- a/pystac/extensions/processing.py +++ b/pystac/extensions/processing.py @@ -5,6 +5,7 @@ from __future__ import annotations +from datetime import datetime from typing import ( Any, Generic, @@ -42,15 +43,70 @@ class ProcessingLevel(StringEnum): RAW = "RAW" + """Data in their original packets, as received from the instrument.""" L0 = "L0" + """Reconstructed unprocessed instrument data at full space time resolution with all + available supplemental information to be used in subsequent processing + (e.g., ephemeris, health and safety) appended.""" L1 = "L1" + """Unpacked, reformatted level 0 data, with all supplemental information to be used + in subsequent processing appended. Optional radiometric and geometric correction + applied to produce parameters in physical units. Data generally presented as full + time/space resolution.""" L1A = "L1A" + """Unpacked, reformatted level 0 data, with all supplemental information to be used + in subsequent processing appended. Optional radiometric and geometric correction + applied to produce parameters in physical units. Data generally presented as full + time/space resolution.""" L1B = "L1B" + """Unpacked, reformatted level 0 data, with all supplemental information to be used + in subsequent processing appended. Optional radiometric and geometric correction + applied to produce parameters in physical units. Data generally presented as full + time/space resolution.""" L1C = "L1C" + """Unpacked, reformatted level 0 data, with all supplemental information to be used + in subsequent processing appended. Optional radiometric and geometric correction + applied to produce parameters in physical units. Data generally presented as full + time/space resolution.""" L2 = "L2" + """Retrieved environmental variables (e.g., ocean wave height, soil-moisture, + ice concentration) at the same resolution and location as the level 1 source + data.""" L2A = "L2A" + """Retrieved environmental variables (e.g., ocean wave height, soil-moisture, + ice concentration) at the same resolution and location as the level 1 source + data.""" L3 = "L3" + """Data or retrieved environmental variables which have been spatially and/or + temporally re-sampled (i.e., derived from level 1 or 2 products). Such + re-sampling may include averaging and compositing.""" L4 = "L4" + """Model output or results from analyses of lower level data (i.e., variables that + are not directly measured by the instruments, but are derived from these + measurements)""" + + +class ProcessingRelType(StringEnum): + """A list of rel types defined in the Processing Extension. + + See the :stac-ext:`Processing Extension Relation types + ` documentation + for details.""" + + EXPRESSION = "processing-expression" + """A processing chain (or script) that describes how the data has been processed.""" + + EXECUTION = "processing-execution" + """URL to any resource representing the processing execution + (e.g. OGC Process API).""" + + SOFTWARE = "processing-software" + """URL to any resource that identifies the software and versions used for processing + the data, e.g. a Pipfile.lock (Python) or package-lock.json (NodeJS).""" + + VALIDATION = "processing-validation" + """URL to any kind of validation that has been applied after processing, e.g. a + validation report or a script used for validation.""" class ProcessingExtension( @@ -74,6 +130,8 @@ class ProcessingExtension( """ + name: Literal["processing"] = "processing" + def __init__(self: Self, item: pystac.Item) -> None: self.item = item self.properties = item.properties @@ -81,67 +139,153 @@ def __init__(self: Self, item: pystac.Item) -> None: def __repr__(self: Self) -> str: return f"" - def apply(self: Self, level: str | None = None) -> None: + def apply( + self: Self, + level: ProcessingLevel | None = None, + datetime: datetime | None = None, + expression: str | None = None, + lineage: str | None = None, + facility: str | None = None, + version: str | None = None, + software: dict[str, str] | None = None, + ) -> None: + """Applies the processing extension properties to the extended Item. + + Args: + level: The processing level of the product. This should be the short name, + as one of the available options under the `ProcessingLevel` enum. + datetime: The datetime when the product was processed. Might be already + specified in the common STAC metadata. + expression: The expression used to obtain the processed product, like + `gdal-calc` or `rio-calc`. + lineage: Free text information about the how observations were processed or + models that were used to create the resource being described. + facility: The name of the facility that produced the data, like ESA. + version: The version of the primary processing software or processing chain + that produced the data, like the processing baseline for the Sentinel + missions. + software: A dictionary describing one or more applications or libraries that + were involved during the production of the data for provenance purposes. + """ self.level = level + self.datetime = datetime + self.expression = expression + self.lineage = lineage + self.facility = facility + self.version = version + self.software = software @property def level(self: Self) -> ProcessingLevel | None: - """Get or sets the processing level of the object.""" + """Get or sets the processing level as the name commonly used to refer to the + processing level to make it easier to search for product level across + collections or items. This property is expected to be a `ProcessingLevel`""" return map_opt( lambda x: ProcessingLevel(x), self._get_property(LEVEL_PROP, str) ) @level.setter def level(self: Self, v: ProcessingLevel | None) -> None: - self._set_property(LEVEL_PROP, map_opt(lambda x: x.value, v), pop_if_none=True) + self._set_property(LEVEL_PROP, map_opt(lambda x: x.value, v)) @property - def datetime(self: Self) -> str | None: - return self._get_property(DATETIME_PROP, str) + def datetime(self: Self) -> datetime | None: + """Gets or set the processing date and time of the corresponding data formatted + according to RFC 3339, section 5.6, in UTC. The time of the processing can be + specified as a global field in processing:datetime, but it can also be specified + directly and individually via the created properties of the target asset as + specified in the STAC Common metadata. See more at + https://github.com/stac-extensions/processing?tab=readme-ov-file#processing-date-time""" + return map_opt(str_to_datetime, self._get_property(DATETIME_PROP, str)) @datetime.setter - def datetime(self: Self, v: str | None) -> None: - self._set_property(DATETIME_PROP, v, pop_if_none=True) + def datetime(self: Self, v: datetime | None) -> None: + self._set_property(DATETIME_PROP, map_opt(datetime_to_str, v)) @property - def expression(self: Self) -> str | None: - return self._get_property(EXPRESSION_PROP, str) + def expression(self: Self) -> dict[str, str | Any] | None: + """Gets or sets an expression or processing chain that describes how the data + has been processed. Alternatively, you can also link to a processing chain with + the relation type processing-expression. + .. code-block:: python + >>> proc_ext.expression = "(b4-b1)/(b4+b1)" + """ + return self._get_property(EXPRESSION_PROP, dict[str, str | Any]) @expression.setter - def expression(self: Self, v: str | None) -> None: - self._set_property(EXPRESSION_PROP, v, pop_if_none=True) + def expression(self: Self, v: str | Any | None) -> None: + if isinstance(v.expression, str): + exp_format = "string" + elif isinstance(v.expression, object): + exp_format = "object" + else: + raise ValueError( + "The provided expression is not a valid type (string or object)" + ) + + expression = { + "format": exp_format, + "expression": v, + } + + self._set_property(EXPRESSION_PROP, expression) @property def lineage(self: Self) -> str | None: + """Gets or sets the lineage provided as free text information about how + observations were processed or models that were used to create the resource + being described NASA ISO. For example, GRD Post Processing for "GRD" product of + Sentinel-1 satellites. CommonMark 0.29 syntax MAY be used for rich text + representation.""" return self._get_property(LINEAGE_PROP, str) @lineage.setter def lineage(self: Self, v: str | None) -> None: - self._set_property(LINEAGE_PROP, v, pop_if_none=True) + self._set_property(LINEAGE_PROP, v) @property def facility(self: Self) -> str | None: + """Gets or sets the name of the facility that produced the data. For example, + Copernicus S1 Core Ground Segment - DPA for product of Sentinel-1 satellites.""" return self._get_property(FACILITY_PROP, str) @facility.setter def facility(self: Self, v: str | None) -> None: - self._set_property(FACILITY_PROP, v, pop_if_none=True) + self._set_property(FACILITY_PROP, v) @property def version(self: Self) -> str | None: + """Gets or sets The version of the primary processing software or processing + chain that produced the data. For example, this could be the processing baseline + for the Sentinel missions.""" return self._get_property(VERSION_PROP, str) @version.setter def version(self: Self, v: str | None) -> None: - self._set_property(VERSION_PROP, v, pop_if_none=True) + self._set_property(VERSION_PROP, v) @property - def software(self: Self) -> str | None: - return self._get_property(SOFTWARE_PROP, str) + def software(self: Self) -> dict[str, str] | None: + """Gets or sets the processing software as a dictionary with name/version for + key/value describing one or more applications or libraries that were involved + during the production of the data for provenance purposes. + + They are mostly informative and important to be complete for reproducibility + purposes. Thus, the values in the object can not just be version numbers, but + also be e.g. tag names, commit hashes or similar. For example, you could expose + a simplified version of the Pipfile.lock (Python) or package-lock.json (NodeJS). + If you need more information, you could also link to such files via the relation + type processing-software. + .. code-block:: python + >>> proc_ext.software = { + "Sentinel-1 IPF": "002.71" + } + """ + return self._get_property(SOFTWARE_PROP, dict[str, str]) @software.setter - def software(self: Self, v: str | None) -> None: - self._set_property(SOFTWARE_PROP, v, pop_if_none=True) + def software(self: Self, v: dict[str, str] | None) -> None: + self._set_property(SOFTWARE_PROP, v) @classmethod def get_schema_uri(cls) -> str: From 872f8efd855787d903fa4e7ccb7c569e3c362eb6 Mon Sep 17 00:00:00 2001 From: guillemc23 Date: Mon, 13 Oct 2025 13:33:03 +0200 Subject: [PATCH 04/12] feat: ItemProcessingExtension --- pystac/extensions/processing.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pystac/extensions/processing.py b/pystac/extensions/processing.py index 38d48b0d1..b3f5bcca7 100644 --- a/pystac/extensions/processing.py +++ b/pystac/extensions/processing.py @@ -209,14 +209,15 @@ def expression(self: Self) -> dict[str, str | Any] | None: the relation type processing-expression. .. code-block:: python >>> proc_ext.expression = "(b4-b1)/(b4+b1)" + >>> proc_ext.expression = "(b4-b1)/(b4+b1)" """ return self._get_property(EXPRESSION_PROP, dict[str, str | Any]) @expression.setter def expression(self: Self, v: str | Any | None) -> None: - if isinstance(v.expression, str): + if isinstance(v, str): exp_format = "string" - elif isinstance(v.expression, object): + elif isinstance(v, object): exp_format = "object" else: raise ValueError( From d64b82938521359066c568b56765f5d8bfeb3e9a Mon Sep 17 00:00:00 2001 From: guillemc23 Date: Mon, 13 Oct 2025 13:39:12 +0200 Subject: [PATCH 05/12] feat: `AssetProcessingExtension` --- pystac/extensions/processing.py | 39 +++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/pystac/extensions/processing.py b/pystac/extensions/processing.py index b3f5bcca7..ed19a93ac 100644 --- a/pystac/extensions/processing.py +++ b/pystac/extensions/processing.py @@ -5,6 +5,7 @@ from __future__ import annotations +from collections.abc import Iterable from datetime import datetime from typing import ( Any, @@ -311,3 +312,41 @@ def __init__(self: Self, item: pystac.Item) -> None: def __repr__(self: Self) -> str: return f"" + + +class AssetProcessingExtension(ProcessingExtension[pystac.Asset]): + """A concrete implementation of :class:`ProcessingExtension` on an + :class:`~pystac.Asset` that extends the Asset fields to include properties defined + in the :stac-ext:`Processing Extension `. + + This class should generally not be instantiated directly. Instead, call + :meth:`ProcessingExtension.ext` on an :class:`~pystac.Asset` to extend it. + """ + + asset_href: str + """The ``href`` value of the :class:`~pystac.Asset` being extended.""" + + properties: dict[str, Any] + """The :class:`~pystac.Asset` fields, including extension properties.""" + + additional_read_properties: Iterable[dict[str, Any]] | None = None + """If present, this will be a list containing 1 dictionary representing the + properties of the owning :class:`~pystac.Item`.""" + + 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 ItemAssetsProcessingExtension(ProcessingExtension[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 From 6ee59874c58d8c8825fd76d626a3c51504019438 Mon Sep 17 00:00:00 2001 From: guillemc23 Date: Mon, 13 Oct 2025 13:40:17 +0200 Subject: [PATCH 06/12] feat: `ProcessingExtensionHooks` --- pystac/extensions/processing.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pystac/extensions/processing.py b/pystac/extensions/processing.py index ed19a93ac..5a9c39604 100644 --- a/pystac/extensions/processing.py +++ b/pystac/extensions/processing.py @@ -22,6 +22,7 @@ ExtensionManagementMixin, PropertiesExtension, ) +from pystac.extensions.hooks import ExtensionHooks from pystac.utils import StringEnum, datetime_to_str, map_opt, str_to_datetime T = TypeVar("T", pystac.Item, pystac.Asset, item_assets.AssetDefinition) @@ -350,3 +351,12 @@ class ItemAssetsProcessingExtension(ProcessingExtension[pystac.ItemAssetDefiniti def __init__(self, item_asset: pystac.ItemAssetDefinition): self.asset_defn = item_asset self.properties = item_asset.properties + + +class ProcessingExtensionHooks(ExtensionHooks): + schema_uri: str = SCHEMA_URI + prev_extension_ids = {"processing"} + stac_object_types = {pystac.STACObjectType.ITEM} + + +PROCESSING_EXTENSION_HOOKS: ExtensionHooks = ProcessingExtensionHooks() From 4416a09ee7f96f61306c288baf0999d8745324fa Mon Sep 17 00:00:00 2001 From: guillemc23 Date: Mon, 13 Oct 2025 13:52:19 +0200 Subject: [PATCH 07/12] feat: `SummariesProcessingExtension` --- pystac/extensions/processing.py | 100 ++++++++++++++++++++++++++++++-- 1 file changed, 96 insertions(+), 4 deletions(-) diff --git a/pystac/extensions/processing.py b/pystac/extensions/processing.py index 5a9c39604..2d131d5a4 100644 --- a/pystac/extensions/processing.py +++ b/pystac/extensions/processing.py @@ -21,8 +21,10 @@ from pystac.extensions.base import ( ExtensionManagementMixin, PropertiesExtension, + SummariesExtension, ) from pystac.extensions.hooks import ExtensionHooks +from pystac.summaries import RangeSummary from pystac.utils import StringEnum, datetime_to_str, map_opt, str_to_datetime T = TypeVar("T", pystac.Item, pystac.Asset, item_assets.AssetDefinition) @@ -211,7 +213,6 @@ def expression(self: Self) -> dict[str, str | Any] | None: the relation type processing-expression. .. code-block:: python >>> proc_ext.expression = "(b4-b1)/(b4+b1)" - >>> proc_ext.expression = "(b4-b1)/(b4+b1)" """ return self._get_property(EXPRESSION_PROP, dict[str, str | Any]) @@ -334,13 +335,13 @@ class AssetProcessingExtension(ProcessingExtension[pystac.Asset]): """If present, this will be a list containing 1 dictionary representing the properties of the owning :class:`~pystac.Item`.""" - def __init__(self, asset: pystac.Asset): + def __init__(self: 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: + def __repr__(self: Self) -> str: return f"" @@ -348,11 +349,102 @@ class ItemAssetsProcessingExtension(ProcessingExtension[pystac.ItemAssetDefiniti properties: dict[str, Any] asset_defn: pystac.ItemAssetDefinition - def __init__(self, item_asset: pystac.ItemAssetDefinition): + def __init__(self: Self, item_asset: pystac.ItemAssetDefinition): self.asset_defn = item_asset self.properties = item_asset.properties +class SummariesProcessingExtension(SummariesExtension): + """A concrete implementation of :class:`~pystac.extensions.base.SummariesExtension` + that extends the ``summaries`` field of a :class:`~pystac.Collection` to include + properties defined in the :stac-ext:`Processing Extension `. + """ + + @property + def level(self: Self) -> list[ProcessingLevel] | None: + """Get or sets the summary of :attr:`ProcessingExtension.level` values + for this Collection. + """ + + return self.summaries.get_list(LEVEL_PROP) + + @level.setter + def level(self: Self, v: list[ProcessingLevel] | None) -> None: + self._set_summary(LEVEL_PROP, v) + + @property + def datetime(self: Self) -> RangeSummary[datetime] | None: + """Get or sets the summary of :attr:`ProcessingExtension.datetime` values + for this Collection. + """ + + return self.summaries.get_range(DATETIME_PROP) + + @datetime.setter + def datetime(self: Self, v: RangeSummary[datetime] | None) -> None: + self._set_summary(DATETIME_PROP, v) + + @property + def expression(self: Self) -> list[dict[str, str | Any]] | None: + """Get or sets the summary of :attr:`ProcessingExtension.expression` values + for this Collection. + """ + + return self.summaries.get_list(EXPRESSION_PROP) + + @expression.setter + def expression(self: Self, v: list[dict[str, str | Any]] | None) -> None: + self._set_summary(EXPRESSION_PROP, v) + + @property + def lineage(self: Self) -> RangeSummary[str] | None: + """Get or sets the summary of :attr:`ProcessingExtension.lineage` values + for this Collection. + """ + + return self.summaries.get_range(LINEAGE_PROP) + + @lineage.setter + def lineage(self: Self, v: RangeSummary[str] | None) -> None: + self._set_summary(LINEAGE_PROP, v) + + @property + def facility(self: Self) -> RangeSummary[str] | None: + """Get or sets the summary of :attr:`ProcessingExtension.facility` values + for this Collection. + """ + + return self.summaries.get_range(FACILITY_PROP) + + @facility.setter + def facility(self: Self, v: RangeSummary[str] | None) -> None: + self._set_summary(FACILITY_PROP, v) + + @property + def version(self: Self) -> RangeSummary[str] | None: + """Get or sets the summary of :attr:`ProcessingExtension.version` values + for this Collection. + """ + + return self.summaries.get_range(VERSION_PROP) + + @version.setter + def version(self: Self, v: RangeSummary[str] | None) -> None: + self._set_summary(VERSION_PROP, v) + + @property + def software(self: Self) -> RangeSummary[dict[str, str]] | None: + """Get or sets the summary of :attr:`ProcessingExtension.software` values + for this Collection. + """ + + return self.summaries.get_range(SOFTWARE_PROP) + + @software.setter + def software(self: Self, v: RangeSummary[dict[str, str]] | None) -> None: + self._set_summary(SOFTWARE_PROP, v) + + class ProcessingExtensionHooks(ExtensionHooks): schema_uri: str = SCHEMA_URI prev_extension_ids = {"processing"} From bf58f35930ff1c2da9a57703a81515099c1fc330 Mon Sep 17 00:00:00 2001 From: guillemc23 Date: Mon, 13 Oct 2025 14:00:19 +0200 Subject: [PATCH 08/12] fix: Remove `Self` typing --- pystac/extensions/processing.py | 73 ++++++++++++++++----------------- 1 file changed, 36 insertions(+), 37 deletions(-) diff --git a/pystac/extensions/processing.py b/pystac/extensions/processing.py index 2d131d5a4..83d77f2cc 100644 --- a/pystac/extensions/processing.py +++ b/pystac/extensions/processing.py @@ -11,7 +11,6 @@ Any, Generic, Literal, - Self, TypeVar, cast, ) @@ -136,15 +135,15 @@ class ProcessingExtension( name: Literal["processing"] = "processing" - def __init__(self: Self, item: pystac.Item) -> None: + def __init__(self, item: pystac.Item) -> None: self.item = item self.properties = item.properties - def __repr__(self: Self) -> str: + def __repr__(self) -> str: return f"" def apply( - self: Self, + self, level: ProcessingLevel | None = None, datetime: datetime | None = None, expression: str | None = None, @@ -180,7 +179,7 @@ def apply( self.software = software @property - def level(self: Self) -> ProcessingLevel | None: + def level(self) -> ProcessingLevel | None: """Get or sets the processing level as the name commonly used to refer to the processing level to make it easier to search for product level across collections or items. This property is expected to be a `ProcessingLevel`""" @@ -189,11 +188,11 @@ def level(self: Self) -> ProcessingLevel | None: ) @level.setter - def level(self: Self, v: ProcessingLevel | None) -> None: + def level(self, v: ProcessingLevel | None) -> None: self._set_property(LEVEL_PROP, map_opt(lambda x: x.value, v)) @property - def datetime(self: Self) -> datetime | None: + def datetime(self) -> datetime | None: """Gets or set the processing date and time of the corresponding data formatted according to RFC 3339, section 5.6, in UTC. The time of the processing can be specified as a global field in processing:datetime, but it can also be specified @@ -203,11 +202,11 @@ def datetime(self: Self) -> datetime | None: return map_opt(str_to_datetime, self._get_property(DATETIME_PROP, str)) @datetime.setter - def datetime(self: Self, v: datetime | None) -> None: + def datetime(self, v: datetime | None) -> None: self._set_property(DATETIME_PROP, map_opt(datetime_to_str, v)) @property - def expression(self: Self) -> dict[str, str | Any] | None: + def expression(self) -> dict[str, str | Any] | None: """Gets or sets an expression or processing chain that describes how the data has been processed. Alternatively, you can also link to a processing chain with the relation type processing-expression. @@ -217,7 +216,7 @@ def expression(self: Self) -> dict[str, str | Any] | None: return self._get_property(EXPRESSION_PROP, dict[str, str | Any]) @expression.setter - def expression(self: Self, v: str | Any | None) -> None: + def expression(self, v: str | Any | None) -> None: if isinstance(v, str): exp_format = "string" elif isinstance(v, object): @@ -235,7 +234,7 @@ def expression(self: Self, v: str | Any | None) -> None: self._set_property(EXPRESSION_PROP, expression) @property - def lineage(self: Self) -> str | None: + def lineage(self) -> str | None: """Gets or sets the lineage provided as free text information about how observations were processed or models that were used to create the resource being described NASA ISO. For example, GRD Post Processing for "GRD" product of @@ -244,32 +243,32 @@ def lineage(self: Self) -> str | None: return self._get_property(LINEAGE_PROP, str) @lineage.setter - def lineage(self: Self, v: str | None) -> None: + def lineage(self, v: str | None) -> None: self._set_property(LINEAGE_PROP, v) @property - def facility(self: Self) -> str | None: + def facility(self) -> str | None: """Gets or sets the name of the facility that produced the data. For example, Copernicus S1 Core Ground Segment - DPA for product of Sentinel-1 satellites.""" return self._get_property(FACILITY_PROP, str) @facility.setter - def facility(self: Self, v: str | None) -> None: + def facility(self, v: str | None) -> None: self._set_property(FACILITY_PROP, v) @property - def version(self: Self) -> str | None: + def version(self) -> str | None: """Gets or sets The version of the primary processing software or processing chain that produced the data. For example, this could be the processing baseline for the Sentinel missions.""" return self._get_property(VERSION_PROP, str) @version.setter - def version(self: Self, v: str | None) -> None: + def version(self, v: str | None) -> None: self._set_property(VERSION_PROP, v) @property - def software(self: Self) -> dict[str, str] | None: + def software(self) -> dict[str, str] | None: """Gets or sets the processing software as a dictionary with name/version for key/value describing one or more applications or libraries that were involved during the production of the data for provenance purposes. @@ -288,7 +287,7 @@ def software(self: Self) -> dict[str, str] | None: return self._get_property(SOFTWARE_PROP, dict[str, str]) @software.setter - def software(self: Self, v: dict[str, str] | None) -> None: + def software(self, v: dict[str, str] | None) -> None: self._set_property(SOFTWARE_PROP, v) @classmethod @@ -308,11 +307,11 @@ class ItemProcessingExtension(ProcessingExtension[pystac.Item]): item: pystac.Item properties: dict[str, Any] - def __init__(self: Self, item: pystac.Item) -> None: + def __init__(self, item: pystac.Item) -> None: self.item = item self.properties = item.properties - def __repr__(self: Self) -> str: + def __repr__(self) -> str: return f"" @@ -335,13 +334,13 @@ class AssetProcessingExtension(ProcessingExtension[pystac.Asset]): """If present, this will be a list containing 1 dictionary representing the properties of the owning :class:`~pystac.Item`.""" - def __init__(self: Self, asset: pystac.Asset): + 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: Self) -> str: + def __repr__(self) -> str: return f"" @@ -349,7 +348,7 @@ class ItemAssetsProcessingExtension(ProcessingExtension[pystac.ItemAssetDefiniti properties: dict[str, Any] asset_defn: pystac.ItemAssetDefinition - def __init__(self: Self, item_asset: pystac.ItemAssetDefinition): + def __init__(self, item_asset: pystac.ItemAssetDefinition): self.asset_defn = item_asset self.properties = item_asset.properties @@ -361,7 +360,7 @@ class SummariesProcessingExtension(SummariesExtension): """ @property - def level(self: Self) -> list[ProcessingLevel] | None: + def level(self) -> list[ProcessingLevel] | None: """Get or sets the summary of :attr:`ProcessingExtension.level` values for this Collection. """ @@ -369,11 +368,11 @@ def level(self: Self) -> list[ProcessingLevel] | None: return self.summaries.get_list(LEVEL_PROP) @level.setter - def level(self: Self, v: list[ProcessingLevel] | None) -> None: + def level(self, v: list[ProcessingLevel] | None) -> None: self._set_summary(LEVEL_PROP, v) @property - def datetime(self: Self) -> RangeSummary[datetime] | None: + def datetime(self) -> RangeSummary[datetime] | None: """Get or sets the summary of :attr:`ProcessingExtension.datetime` values for this Collection. """ @@ -381,11 +380,11 @@ def datetime(self: Self) -> RangeSummary[datetime] | None: return self.summaries.get_range(DATETIME_PROP) @datetime.setter - def datetime(self: Self, v: RangeSummary[datetime] | None) -> None: + def datetime(self, v: RangeSummary[datetime] | None) -> None: self._set_summary(DATETIME_PROP, v) @property - def expression(self: Self) -> list[dict[str, str | Any]] | None: + def expression(self) -> list[dict[str, str | Any]] | None: """Get or sets the summary of :attr:`ProcessingExtension.expression` values for this Collection. """ @@ -393,11 +392,11 @@ def expression(self: Self) -> list[dict[str, str | Any]] | None: return self.summaries.get_list(EXPRESSION_PROP) @expression.setter - def expression(self: Self, v: list[dict[str, str | Any]] | None) -> None: + def expression(self, v: list[dict[str, str | Any]] | None) -> None: self._set_summary(EXPRESSION_PROP, v) @property - def lineage(self: Self) -> RangeSummary[str] | None: + def lineage(self) -> RangeSummary[str] | None: """Get or sets the summary of :attr:`ProcessingExtension.lineage` values for this Collection. """ @@ -405,11 +404,11 @@ def lineage(self: Self) -> RangeSummary[str] | None: return self.summaries.get_range(LINEAGE_PROP) @lineage.setter - def lineage(self: Self, v: RangeSummary[str] | None) -> None: + def lineage(self, v: RangeSummary[str] | None) -> None: self._set_summary(LINEAGE_PROP, v) @property - def facility(self: Self) -> RangeSummary[str] | None: + def facility(self) -> RangeSummary[str] | None: """Get or sets the summary of :attr:`ProcessingExtension.facility` values for this Collection. """ @@ -417,11 +416,11 @@ def facility(self: Self) -> RangeSummary[str] | None: return self.summaries.get_range(FACILITY_PROP) @facility.setter - def facility(self: Self, v: RangeSummary[str] | None) -> None: + def facility(self, v: RangeSummary[str] | None) -> None: self._set_summary(FACILITY_PROP, v) @property - def version(self: Self) -> RangeSummary[str] | None: + def version(self) -> RangeSummary[str] | None: """Get or sets the summary of :attr:`ProcessingExtension.version` values for this Collection. """ @@ -429,11 +428,11 @@ def version(self: Self) -> RangeSummary[str] | None: return self.summaries.get_range(VERSION_PROP) @version.setter - def version(self: Self, v: RangeSummary[str] | None) -> None: + def version(self, v: RangeSummary[str] | None) -> None: self._set_summary(VERSION_PROP, v) @property - def software(self: Self) -> RangeSummary[dict[str, str]] | None: + def software(self) -> RangeSummary[dict[str, str]] | None: """Get or sets the summary of :attr:`ProcessingExtension.software` values for this Collection. """ @@ -441,7 +440,7 @@ def software(self: Self) -> RangeSummary[dict[str, str]] | None: return self.summaries.get_range(SOFTWARE_PROP) @software.setter - def software(self: Self, v: RangeSummary[dict[str, str]] | None) -> None: + def software(self, v: RangeSummary[dict[str, str]] | None) -> None: self._set_summary(SOFTWARE_PROP, v) From 59ebff6b0260807cc7202394441e522ffa0e646f Mon Sep 17 00:00:00 2001 From: guillemc23 Date: Mon, 13 Oct 2025 14:03:47 +0200 Subject: [PATCH 09/12] docs: Add PR to changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 740071adb..41eeeb16c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] - Remove unused pystac.validation import ([#1583](https://github.com/stac-utils/pystac/pull/1583)) +- Added Processing extension ([#1588](https://github.com/stac-utils/pystac/pull/1589)) ## [v1.14.1] - 2025-09-18 From 4a858d6927f77a46e06e2e1102c3bd77444b1a04 Mon Sep 17 00:00:00 2001 From: guillemc23 Date: Mon, 13 Oct 2025 14:18:09 +0200 Subject: [PATCH 10/12] fix: mypy fixes --- pystac/extensions/processing.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/pystac/extensions/processing.py b/pystac/extensions/processing.py index 83d77f2cc..9cf36889d 100644 --- a/pystac/extensions/processing.py +++ b/pystac/extensions/processing.py @@ -26,7 +26,13 @@ from pystac.summaries import RangeSummary from pystac.utils import StringEnum, datetime_to_str, map_opt, str_to_datetime -T = TypeVar("T", pystac.Item, pystac.Asset, item_assets.AssetDefinition) +T = TypeVar( + "T", + pystac.Item, + pystac.Asset, + item_assets.AssetDefinition, + pystac.ItemAssetDefinition, +) SCHEMA_URI: str = "https://stac-extensions.github.io/processing/v1.2.0/schema.json" SCHEMA_URIS: list[str] = [ @@ -298,7 +304,7 @@ def get_schema_uri(cls) -> str: 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, ItemProcessingExtension(obj)) + return cast(ProcessingExtension[pystac.Item], ItemProcessingExtension(obj)) else: raise pystac.ExtensionTypeError(cls._ext_error_message(obj)) @@ -432,15 +438,15 @@ def version(self, v: RangeSummary[str] | None) -> None: self._set_summary(VERSION_PROP, v) @property - def software(self) -> RangeSummary[dict[str, str]] | None: + def software(self) -> list[dict[str, str]] | None: """Get or sets the summary of :attr:`ProcessingExtension.software` values for this Collection. """ - return self.summaries.get_range(SOFTWARE_PROP) + return self.summaries.get_list(SOFTWARE_PROP) @software.setter - def software(self, v: RangeSummary[dict[str, str]] | None) -> None: + def software(self, v: list[dict[str, str]] | None) -> None: self._set_summary(SOFTWARE_PROP, v) From 9e0d525b35382f54b80d8e26d86570051a92a4df Mon Sep 17 00:00:00 2001 From: guillemc23 Date: Mon, 13 Oct 2025 14:19:48 +0200 Subject: [PATCH 11/12] docs: Updated docs --- docs/api/extensions/processing.rst | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 docs/api/extensions/processing.rst 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: From 9a69d46a3118ee684b12bedeec6c174772a7dd42 Mon Sep 17 00:00:00 2001 From: guillemc23 Date: Mon, 13 Oct 2025 15:37:08 +0200 Subject: [PATCH 12/12] test: Added tests --- pystac/extensions/processing.py | 59 +++++-- tests/data-files/processing/collection.json | 120 ++++++++++++++ tests/data-files/processing/item.json | 165 ++++++++++++++++++++ tests/extensions/test_processing.py | 147 +++++++++++++++++ 4 files changed, 477 insertions(+), 14 deletions(-) create mode 100644 tests/data-files/processing/collection.json create mode 100644 tests/data-files/processing/item.json create mode 100644 tests/extensions/test_processing.py diff --git a/pystac/extensions/processing.py b/pystac/extensions/processing.py index 9cf36889d..0984000c9 100644 --- a/pystac/extensions/processing.py +++ b/pystac/extensions/processing.py @@ -223,21 +223,24 @@ def expression(self) -> dict[str, str | Any] | None: @expression.setter def expression(self, v: str | Any | None) -> None: - if isinstance(v, str): - exp_format = "string" - elif isinstance(v, object): - exp_format = "object" + if v is None: + self._set_property(EXPRESSION_PROP, v) else: - raise ValueError( - "The provided expression is not a valid type (string or object)" - ) - - expression = { - "format": exp_format, - "expression": v, - } - - self._set_property(EXPRESSION_PROP, expression) + if isinstance(v, str): + exp_format = "string" + elif isinstance(v, object): + exp_format = "object" + else: + raise ValueError( + "The provided expression is not a valid type (string or object)" + ) + + expression = { + "format": exp_format, + "expression": v, + } + + self._set_property(EXPRESSION_PROP, expression) @property def lineage(self) -> str | None: @@ -302,12 +305,40 @@ def get_schema_uri(cls) -> str: @classmethod def ext(cls, obj: T, add_if_missing: bool = False) -> ProcessingExtension[T]: + import pystac.errors + if isinstance(obj, pystac.Item): + if not add_if_missing and cls.get_schema_uri() not in obj.stac_extensions: + raise pystac.errors.ExtensionNotImplemented( + f"{cls.__name__} not implemented for Item id={getattr(obj, 'id', None)}" + ) cls.ensure_has_extension(obj, add_if_missing) return cast(ProcessingExtension[pystac.Item], ItemProcessingExtension(obj)) + elif isinstance(obj, pystac.Asset): + owner = obj.owner if hasattr(obj, "owner") else None + if owner and isinstance(owner, pystac.Item): + if ( + not add_if_missing + and cls.get_schema_uri() not in owner.stac_extensions + ): + raise pystac.errors.ExtensionNotImplemented( + f"{cls.__name__} not implemented for Asset href={getattr(obj, 'href', None)} (owner Item id={getattr(owner, 'id', None)})" + ) + cls.ensure_has_extension(owner, add_if_missing) if owner else None + return cast( + ProcessingExtension[pystac.Asset], AssetProcessingExtension(obj) + ) else: raise pystac.ExtensionTypeError(cls._ext_error_message(obj)) + @classmethod + def summaries( + cls, obj: pystac.Collection, add_if_missing: bool = False + ) -> SummariesProcessingExtension: + """Returns the extended summaries object for the given collection.""" + cls.ensure_has_extension(obj, add_if_missing) + return SummariesProcessingExtension(obj) + class ItemProcessingExtension(ProcessingExtension[pystac.Item]): item: pystac.Item diff --git a/tests/data-files/processing/collection.json b/tests/data-files/processing/collection.json new file mode 100644 index 000000000..43e4cad41 --- /dev/null +++ b/tests/data-files/processing/collection.json @@ -0,0 +1,120 @@ +{ + "stac_version": "1.0.0", + "stac_extensions": [ + "https://stac-extensions.github.io/processing/v1.2.0/schema.json" + ], + "type": "Collection", + "id": "Sentinel2-L2A", + "title": "Sentinel-2 MSI: MultiSpectral Instrument, Level-2A", + "description": "Sentinel-2 is a wide-swath, high-resolution, multi-spectral imaging mission.", + "license": "proprietary", + "providers": [ + { + "name": "European Union/ESA/Copernicus", + "roles": [ + "producer", + "licensor" + ], + "url": "https://sentinel.esa.int/web/sentinel/user-guides/sentinel-2-msi", + "processing:lineage": "Generation of Level-1C User Product", + "processing:level": "L1", + "processing:facility": "Copernicus S2 Processing and Archiving Facility", + "processing:version": "02.06" + }, + { + "name": "Processing Corp.", + "roles": [ + "processor" + ], + "processing:lineage": "Generation of Level-2A User Product", + "processing:level": "L2A", + "processing:software": { + "Sentinel-2 Toolbox": "8.0.0" + } + }, + { + "name": "Storage Provider, Inc.", + "roles": [ + "host" + ] + } + ], + "extent": { + "spatial": { + "bbox": [ + [ + -180, + -56, + 180, + 83 + ] + ] + }, + "temporal": { + "interval": [ + [ + "2015-06-23T00:00:00Z", + null + ] + ] + } + }, + "summaries": { + "datetime": { + "minimum": "2015-06-23T00:00:00Z", + "maximum": "2019-07-10T13:44:56Z" + }, + "platform": [ + "sentinel-2a", + "sentinel-2b" + ], + "constellation": [ + "sentinel-2" + ], + "instruments": [ + "msi" + ], + "gsd": [ + 10, + 30, + 60 + ], + "processing:level": [ + "L1", + "L2" + ] + }, + "links": [ + { + "rel": "items", + "type": "application/geo+json", + "href": "https://planetarycomputer.microsoft.com/api/stac/v1/collections/sentinel-1-grd/items" + }, + { + "rel": "parent", + "type": "application/json", + "href": "https://planetarycomputer.microsoft.com/api/stac/v1/" + }, + { + "rel": "root", + "type": "application/json", + "href": "https://planetarycomputer.microsoft.com/api/stac/v1/" + }, + { + "rel": "self", + "type": "application/json", + "href": "https://planetarycomputer.microsoft.com/api/stac/v1/collections/sentinel-1-grd" + }, + { + "rel": "license", + "href": "https://sentinel.esa.int/documents/247904/690755/Sentinel_Data_Legal_Notice", + "title": "Copernicus Sentinel data terms" + }, + { + "rel": "describedby", + "href": "https://planetarycomputer.microsoft.com/dataset/sentinel-1-grd", + "title": "Human readable dataset overview and reference", + "type": "text/html" + } + ] +} \ No newline at end of file diff --git a/tests/data-files/processing/item.json b/tests/data-files/processing/item.json new file mode 100644 index 000000000..3ca8e3f7c --- /dev/null +++ b/tests/data-files/processing/item.json @@ -0,0 +1,165 @@ +{ + "stac_version": "1.0.0", + "stac_extensions": [ + "https://stac-extensions.github.io/sat/v1.0.0/schema.json", + "https://stac-extensions.github.io/sar/v1.0.0/schema.json", + "https://stac-extensions.github.io/processing/v1.2.0/schema.json" + ], + "id": "S1A_IW_GRDH_1SDV_20160822T182823_20160822T182848_012717_013FFE_90AF", + "properties": { + "datetime": "2016-08-22T18:28:23.368922Z", + "start_datetime": "2016-08-22T18:28:23.368922Z", + "end_datetime": "2016-08-22T18:28:48.368201Z", + "created": "2016-08-23T00:38:22Z", + "platform": "sentinel-1a", + "constellation": "sentinel-1", + "mission": "sentinel-1", + "instruments": [ + "c-sar" + ], + "sat:absolute_orbit": 12717, + "sat:orbit_state": "ascending", + "sat:relative_orbit": 45, + "sat:anx_datetime": "2016-08-22T18:24:52.513706Z", + "sar:instrument_mode": "IW", + "sar:frequency_band": "C", + "sar:polarizations": [ + "VV", + "VH" + ], + "sar:product_type": "GRD", + "processing:lineage": "GRD Post Processing", + "processing:level": "L1", + "processing:facility": "Copernicus S1 Core Ground Segment - DPA", + "processing:software": { + "Sentinel-1 IPF": "002.71" + }, + "processing:datetime": "2016-08-23T00:30:33Z" + }, + "links": [ + { + "title": "GRD Post Processing (90AF)", + "rel": "processing-execution", + "href": "https://api.example.com/processing/s1-grd-l1c/jobs/90AF", + "type": "application/json" + } + ], + "assets": { + "manifest": { + "type": "text/xml", + "roles": [ + "metadata" + ], + "title": "SAFE Manifest", + "href": "data/S1A_IW_GRDH_1SDV_20160822T182823_20160822T182848_012717_013FFE_90AF.SAFE/manifest.safe", + "created": "2016-08-23T00:30:33Z" + }, + "quick-look": { + "type": "image/png", + "roles": [ + "overview" + ], + "href": "data/S1A_IW_GRDH_1SDV_20160822T182823_20160822T182848_012717_013FFE_90AF.SAFE/preview/quick-look.png" + }, + "annotation-vv-iw": { + "type": "text/xml", + "roles": [ + "metadata" + ], + "title": "Annotation VV IW", + "href": "data/S1A_IW_GRDH_1SDV_20160822T182823_20160822T182848_012717_013FFE_90AF.SAFE/annotation/s1a-iw-grd-vv-20160822t182823-20160822t182848-012717-013ffe-001.xml", + "sar:polarizations": [ + "VV" + ] + }, + "amplitude-vv-iw": { + "type": "image/tiff; application=geotiff", + "roles": [ + "data" + ], + "title": "IW VV Amplitude pixel values", + "href": "data/S1A_IW_GRDH_1SDV_20160822T182823_20160822T182848_012717_013FFE_90AF.SAFE/annotation/s1a-iw-grd-vv-20160822t182823-20160822t182848-012717-013ffe-001.tiff", + "sar:polarizations": [ + "VV" + ] + }, + "annotation-vh-iw": { + "type": "text/xml", + "roles": [ + "metadata" + ], + "title": "Annotation VH IW", + "href": "data/S1A_IW_GRDH_1SDV_20160822T182823_20160822T182848_012717_013FFE_90AF.SAFE/annotation/s1a-iw-grd-vh-20160822t182823-20160822t182848-012717-013ffe-002.xml", + "sar:polarizations": [ + "VH" + ] + }, + "amplitude-vh-iw": { + "type": "image/tiff; application=geotiff", + "roles": [ + "data" + ], + "title": "IW VH Amplitude pixel values", + "href": "data/S1A_IW_GRDH_1SDV_20160822T182823_20160822T182848_012717_013FFE_90AF.SAFE/annotation/s1a-iw-grd-vh-20160822t182823-20160822t182848-012717-013ffe-002.tiff", + "sar:polarizations": [ + "VH" + ] + }, + "calibration-vv-iw": { + "type": "text/xml", + "roles": [ + "data" + ], + "title": "Calibration VV IW", + "href": "data/S1A_IW_GRDH_1SDV_20160822T182823_20160822T182848_012717_013FFE_90AF.SAFE/annotation/calibration/calibration-s1a-iw-grd-vv-20160822t182823-20160822t182848-012717-013ffe-001.xml", + "sar:polarizations": [ + "VV" + ] + }, + "calibration-vh-iw": { + "type": "text/xml", + "roles": [ + "data" + ], + "title": "Calibration VH IW", + "href": "data/S1A_IW_GRDH_1SDV_20160822T182823_20160822T182848_012717_013FFE_90AF.SAFE/annotation/calibration/calibration-s1a-iw-grd-vh-20160822t182823-20160822t182848-012717-013ffe-002.xml", + "sar:polarizations": [ + "VH" + ] + } + }, + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + -5.730959, + 14.953436 + ], + [ + -3.431006, + 15.388663 + ], + [ + -3.136116, + 13.880572 + ], + [ + -5.419919, + 13.441674 + ], + [ + -5.730959, + 14.953436 + ] + ] + ] + }, + "bbox": [ + -5.730959, + 13.441674, + -3.136116, + 15.388663 + ] +} \ No newline at end of file diff --git a/tests/extensions/test_processing.py b/tests/extensions/test_processing.py new file mode 100644 index 000000000..a8132598e --- /dev/null +++ b/tests/extensions/test_processing.py @@ -0,0 +1,147 @@ +"""Tests for pystac.extensions.sar.""" + +from datetime import datetime, timezone +from random import choice +from string import ascii_letters + +import pytest + +import pystac +from pystac import ExtensionTypeError +from pystac.extensions import processing +from pystac.extensions.processing import ( + ProcessingExtension, + ProcessingLevel, +) +from tests.utils import TestCases + + +@pytest.fixture +def item() -> pystac.Item: + asset_id = "my/items/2011" + start = datetime(2020, 11, 7) + item = pystac.Item( + id=asset_id, geometry=None, bbox=None, datetime=start, properties={} + ) + ProcessingExtension.add_to(item) + return item + + +@pytest.fixture +def sentinel_item() -> pystac.Item: + return pystac.Item.from_file(TestCases.get_path("data-files/processing/item.json")) + + +@pytest.fixture +def collection() -> pystac.Collection: + return pystac.Collection.from_file( + TestCases.get_path("data-files/processing/collection.json") + ) + + +def test_stac_extensions(item: pystac.Item) -> None: + assert ProcessingExtension.has_extension(item) + + +@pytest.mark.vcr() +def test_required(item: pystac.Item) -> None: + # None of the properties are required + + ProcessingExtension.ext(item).apply() + item.validate() + + +@pytest.mark.vcr() +def test_all(item: pystac.Item) -> None: + processing_level = ProcessingLevel.L1 + processing_datetime = datetime.now(timezone.utc) + processing_expression = "b1+b2" + processing_lineage = "GRD Post Processing" + processing_facility = "Copernicus S1 Core Ground Segment - DPA" + processing_version = "002.71" + processing_software = {"Sentinel-1 IPF": "002.71"} + + ProcessingExtension.ext(item).apply( + processing_level, + processing_datetime, + processing_expression, + processing_lineage, + processing_facility, + processing_version, + processing_software, + ) + + assert processing_level == ProcessingExtension.ext(item).level + assert processing.LEVEL_PROP in item.properties + + assert processing_datetime == ProcessingExtension.ext(item).datetime + assert processing.DATETIME_PROP in item.properties + + assert ( + processing_expression == ProcessingExtension.ext(item).expression["expression"] + ) + assert "string" == ProcessingExtension.ext(item).expression["format"] + assert processing.EXPRESSION_PROP in item.properties + + assert processing_lineage == ProcessingExtension.ext(item).lineage + assert processing.LINEAGE_PROP in item.properties + + assert processing_software == ProcessingExtension.ext(item).software + assert processing.SOFTWARE_PROP in item.properties + + assert processing_facility == ProcessingExtension.ext(item).facility + assert processing.FACILITY_PROP in item.properties + + assert processing_version == ProcessingExtension.ext(item).version + assert processing.VERSION_PROP in item.properties + + item.validate() + + +def test_should_return_none_when_nothing_is_set( + item: pystac.Item, +) -> None: + extension = ProcessingExtension.ext(item) + extension.apply() + + assert extension.level is None + assert extension.datetime is None + assert extension.expression is None + assert extension.software is None + assert extension.facility is None + assert extension.version is None + + +def test_extension_not_implemented(sentinel_item: pystac.Item) -> None: + # Should raise exception if Item does not include extension URI + sentinel_item.stac_extensions.remove(ProcessingExtension.get_schema_uri()) + + with pytest.raises(pystac.ExtensionNotImplemented): + _ = ProcessingExtension.ext(sentinel_item) + + # Should raise exception if owning Item does not include extension URI + asset = sentinel_item.assets["quick-look"] + + with pytest.raises(pystac.ExtensionNotImplemented): + _ = ProcessingExtension.ext(asset) + + # Should succeed if Asset has no owner + ownerless_asset = pystac.Asset.from_dict(asset.to_dict()) + _ = ProcessingExtension.ext(ownerless_asset) + + +def test_item_ext_add_to(sentinel_item: pystac.Item) -> None: + sentinel_item.stac_extensions.remove(ProcessingExtension.get_schema_uri()) + assert ProcessingExtension.get_schema_uri() not in sentinel_item.stac_extensions + + _ = ProcessingExtension.ext(sentinel_item, add_if_missing=True) + + assert ProcessingExtension.get_schema_uri() in sentinel_item.stac_extensions + + +def test_should_raise_exception_when_passing_invalid_extension_object() -> None: + with pytest.raises( + ExtensionTypeError, + match=r"^ProcessingExtension does not apply to type 'object'$", + ): + ProcessingExtension.ext(object()) # type: ignore