Skip to content

Commit a0db65d

Browse files
committed
Merge branch 'pr867-stac-metadata'
2 parents 10e1403 + aefa565 commit a0db65d

3 files changed

Lines changed: 280 additions & 50 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1818

1919
### Fixed
2020

21+
- `metadata_from_stac()` now keeps declared STAC `cube:dimensions` as the dimension source of truth and handles STAC 1.1 common `bands` metadata without requiring the datacube extension ([#743](https://github.com/Open-EO/openeo-python-client/issues/743), [#867](https://github.com/Open-EO/openeo-python-client/pull/867)).
22+
2123

2224
## [0.52.0] - 2026-09-08
2325

‎openeo/metadata.py‎

Lines changed: 158 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,6 @@ class DimensionAlreadyExistsException(MetadataException):
3636

3737

3838
# TODO: make these dimension classes immutable data classes
39-
# TODO: align better with STAC datacube extension
40-
# TODO: align/adapt/integrate with pystac's datacube extension implementation?
4139
class Dimension:
4240
"""Base class for dimensions."""
4341

@@ -72,7 +70,6 @@ def rename_labels(self, target, source) -> Dimension:
7270

7371
class SpatialDimension(Dimension):
7472
# TODO: align better with STAC datacube extension: e.g. support "axis" (x or y)
75-
7673
DEFAULT_CRS = 4326
7774

7875
def __init__(
@@ -685,30 +682,16 @@ def metadata_from_stac(url: str) -> CubeMetadata:
685682
"""
686683
Reads the band metadata a static STAC catalog or a STAC API Collection and returns it as a :py:class:`CubeMetadata`
687684
685+
Policy:
686+
- If cube:dimensions exists: treat it as source of truth (it may omit x/y/t/bands).
687+
- Otherwise: apply openEO-style defaults (x, y, t) and (for Collection/Item) keep bands dimension even if empty.
688+
688689
:param url: The URL to a static STAC catalog (STAC Item, STAC Collection, or STAC Catalog) or a specific STAC API Collection
689690
:return: A :py:class:`CubeMetadata` containing the DataCube band metadata from the url.
690691
"""
691692
stac_object = pystac.read_file(href=url)
692-
bands = _StacMetadataParser().bands_from_stac_object(stac_object)
693-
694-
# At least assume there are spatial dimensions
695-
# TODO #743: are there conditions in which we even should not assume the presence of spatial dimensions?
696-
dimensions = [
697-
SpatialDimension(name="x", extent=[None, None]),
698-
SpatialDimension(name="y", extent=[None, None]),
699-
]
700-
701-
# TODO #743: conditionally include band dimension when there was actual indication of band metadata?
702-
band_dimension = BandDimension(name="bands", bands=bands)
703-
dimensions.append(band_dimension)
704-
705-
# TODO: is it possible to derive the actual name of temporal dimension that the backend will use?
706-
temporal_dimension = _StacMetadataParser().get_temporal_dimension(stac_object)
707-
if temporal_dimension:
708-
dimensions.append(temporal_dimension)
709-
710-
metadata = CubeMetadata(dimensions=dimensions)
711-
return metadata
693+
parser = _StacMetadataParser()
694+
return parser.metadata_from_stac_object(stac_object)
712695

713696
# Sniff for PySTAC extension API since version 1.9.0 (which is not available below Python 3.9)
714697
# TODO: remove this once support for Python 3.7 and 3.8 is dropped
@@ -766,39 +749,165 @@ def __init__(self, *, logger=_log, log_level=logging.DEBUG, supress_duplicate_wa
766749
# Use caching trick to avoid duplicate warnings
767750
self._warn = functools.lru_cache(maxsize=1000)(self._warn)
768751

752+
def metadata_from_stac_object(self, stac_object: pystac.STACObject) -> CubeMetadata:
753+
"""
754+
Build cube metadata from a STAC object.
755+
"""
756+
dimensions = self.dimensions_from_stac_object(stac_object=stac_object)
757+
return CubeMetadata(dimensions=dimensions)
758+
759+
def dimensions_from_stac_object(self, stac_object: pystac.STACObject) -> List[Dimension]:
760+
"""
761+
Build dimension metadata from a STAC object.
762+
763+
Philosophy:
764+
- If cube:dimensions exists: treat it as source of truth (it may omit x/y/t/bands).
765+
- Otherwise: apply openEO-style defaults (x, y, bands, optional t).
766+
"""
767+
bands = self.bands_from_stac_object(stac_object)
768+
if self._has_cube_dimensions(stac_object):
769+
return self._parse_declared_dimensions(stac_object=stac_object, bands=bands)
770+
771+
dimensions: List[Dimension] = [
772+
SpatialDimension(name="x", extent=[None, None]),
773+
SpatialDimension(name="y", extent=[None, None]),
774+
BandDimension(name="bands", bands=list(bands)),
775+
]
776+
temporal_dimension = self.get_temporal_dimension(stac_object)
777+
if temporal_dimension:
778+
dimensions.append(temporal_dimension)
779+
return dimensions
780+
769781
def get_temporal_dimension(self, stac_obj: pystac.STACObject) -> Union[TemporalDimension, None]:
770782
"""
771783
Extract the temporal dimension from a STAC Collection/Item (if any)
772784
"""
773-
# TODO: also extract temporal dimension from assets?
774-
if _PYSTAC_1_9_EXTENSION_INTERFACE:
775-
if stac_obj.ext.has("cube") and hasattr(stac_obj.ext, "cube"):
776-
temporal_dims = [
777-
(n, d.extent or [None, None])
778-
for (n, d) in stac_obj.ext.cube.dimensions.items()
779-
if d.dim_type == pystac.extensions.datacube.DimensionType.TEMPORAL
785+
if self._has_cube_dimensions(stac_obj):
786+
temporal_dimensions = [
787+
d
788+
for d in self._parse_declared_dimensions(stac_object=stac_obj, bands=_BandList([]))
789+
if isinstance(d, TemporalDimension)
790+
]
791+
if len(temporal_dimensions) == 1:
792+
return temporal_dimensions[0]
793+
794+
if isinstance(stac_obj, pystac.Collection) and stac_obj.extent and stac_obj.extent.temporal:
795+
extent = [Rfc3339(propagate_none=True).normalize(d) for d in stac_obj.extent.temporal.intervals[0]]
796+
return TemporalDimension(name="t", extent=extent)
797+
798+
if isinstance(stac_obj, pystac.Item):
799+
props = stac_obj.properties
800+
start = props.get("start_datetime")
801+
end = props.get("end_datetime")
802+
if start or end:
803+
extent = [
804+
Rfc3339(propagate_none=True).normalize(start),
805+
Rfc3339(propagate_none=True).normalize(end),
780806
]
781-
if len(temporal_dims) == 1:
782-
name, extent = temporal_dims[0]
783-
return TemporalDimension(name=name, extent=extent)
784-
elif isinstance(stac_obj, pystac.Collection) and stac_obj.extent.temporal:
785-
# No explicit "cube:dimensions": build fallback from "extent.temporal",
786-
# with dimension name "t" (openEO API recommendation).
787-
extent = [Rfc3339(propagate_none=True).normalize(d) for d in stac_obj.extent.temporal.intervals[0]]
788807
return TemporalDimension(name="t", extent=extent)
789-
else:
790-
if isinstance(stac_obj, pystac.Item):
791-
cube_dimensions = stac_obj.properties.get("cube:dimensions", {})
792-
elif isinstance(stac_obj, pystac.Collection):
793-
cube_dimensions = stac_obj.extra_fields.get("cube:dimensions", {})
808+
809+
dt = props.get("datetime")
810+
if dt:
811+
norm = Rfc3339(propagate_none=True).normalize(dt)
812+
return TemporalDimension(name="t", extent=[norm, norm])
813+
814+
def _has_cube_dimensions(self, stac_object: pystac.STACObject) -> bool:
815+
cube_dimensions = self._cube_dimensions_dict(stac_object)
816+
return isinstance(cube_dimensions, dict) and len(cube_dimensions) > 0
817+
818+
@staticmethod
819+
def _cube_dimensions_dict(stac_object: pystac.STACObject) -> Dict[str, dict]:
820+
"""
821+
Return raw cube:dimensions dict from a Collection/Item, or {}.
822+
"""
823+
if isinstance(stac_object, pystac.Item):
824+
return stac_object.properties.get("cube:dimensions", {}) or {}
825+
if isinstance(stac_object, pystac.Collection):
826+
return stac_object.extra_fields.get("cube:dimensions", {}) or {}
827+
return {}
828+
829+
@staticmethod
830+
def _safe_extent_from_pystac_cube_dim(dim: pystac.extensions.datacube.Dimension) -> List[Any]:
831+
"""
832+
PySTAC cube dimension wrapper may raise if 'extent' is missing.
833+
Also, depending on serialization/version, extent might live in extra_fields.
834+
"""
835+
ext = getattr(dim, "extent", None)
836+
837+
if not ext:
838+
extra = getattr(dim, "extra_fields", {}) or {}
839+
ext = extra.get("extent")
840+
841+
return ext or [None, None]
842+
843+
def _parse_declared_dimensions(self, stac_object: pystac.STACObject, bands: _BandList) -> List[Dimension]:
844+
"""
845+
Parse dimensions declared through cube:dimensions.
846+
"""
847+
if (
848+
_PYSTAC_1_9_EXTENSION_INTERFACE
849+
and getattr(stac_object, "ext", None) is not None
850+
and stac_object.ext.has("cube")
851+
and hasattr(stac_object.ext, "cube")
852+
):
853+
return self._parse_cube_dimensions_from_pystac_extension(stac_object=stac_object, bands=bands)
854+
return self._parse_cube_dimensions_from_raw_dict(stac_object=stac_object, bands=bands)
855+
856+
def _parse_cube_dimensions_from_pystac_extension(
857+
self, stac_object: pystac.STACObject, bands: _BandList
858+
) -> List[Dimension]:
859+
"""
860+
Parse dimensions from PySTAC's cube extension wrapper (assumed to be present).
861+
Supports 'spatial', 'temporal', and 'bands' (or 'spectral' as an alias).
862+
Also see ``_parse_cube_dimensions_from_raw_dict``
863+
"""
864+
dimensions = []
865+
for name, dim in stac_object.ext.cube.dimensions.items():
866+
dim_type = dim.dim_type
867+
extent = self._safe_extent_from_pystac_cube_dim(dim)
868+
869+
if dim_type == pystac.extensions.datacube.DimensionType.SPATIAL:
870+
dimensions.append(SpatialDimension(name=name, extent=extent))
871+
elif dim_type == pystac.extensions.datacube.DimensionType.TEMPORAL:
872+
dimensions.append(TemporalDimension(name=name, extent=extent))
873+
elif dim_type in ("bands", "spectral"):
874+
dimensions.append(BandDimension(name=name, bands=list(bands)))
875+
elif dim_type == pystac.extensions.datacube.DimensionType.GEOMETRIES:
876+
dimensions.append(GeometryDimension(name=name))
794877
else:
795-
cube_dimensions = {}
796-
temporal_dims = [
797-
(n, d.get("extent", [None, None])) for (n, d) in cube_dimensions.items() if d.get("type") == "temporal"
798-
]
799-
if len(temporal_dims) == 1:
800-
name, extent = temporal_dims[0]
801-
return TemporalDimension(name=name, extent=extent)
878+
self._warn(f"Unhandled cube:dimensions type {dim_type!r} for dimension {name!r}")
879+
dimensions.append(Dimension(name=name, type=dim_type))
880+
881+
return dimensions
882+
883+
def _parse_cube_dimensions_from_raw_dict(self, stac_object: pystac.STACObject, bands: _BandList) -> List[Dimension]:
884+
"""
885+
Parse dimensions from raw cube:dimensions dict.
886+
Supports 'spatial', 'temporal', and 'bands' (or 'spectral' as an alias).
887+
Also see ``_parse_cube_dimensions_from_pystac_extension``
888+
"""
889+
dimensions = []
890+
cube_dimensions = self._cube_dimensions_dict(stac_object)
891+
892+
for name, dim in cube_dimensions.items():
893+
if not isinstance(dim, dict):
894+
continue
895+
896+
dim_type = dim.get("type")
897+
extent = dim.get("extent", [None, None])
898+
899+
if dim_type == "spatial":
900+
dimensions.append(SpatialDimension(name=name, extent=extent))
901+
elif dim_type == "temporal":
902+
dimensions.append(TemporalDimension(name=name, extent=extent))
903+
elif dim_type in ("bands", "spectral"):
904+
dimensions.append(BandDimension(name=name, bands=list(bands)))
905+
elif dim_type == "geometries":
906+
dimensions.append(GeometryDimension(name=name))
907+
else:
908+
dimensions.append(Dimension(name=name, type=dim_type))
909+
910+
return dimensions
802911

803912
def _band_from_eo_bands_metadata(self, band: Union[dict, pystac.extensions.eo.Band]) -> Band:
804913
"""Construct band from metadata in eo v1.1 style"""

0 commit comments

Comments
 (0)