Repository: Open-EO/openeo-python-client

Issue:
load_stac metadata detection
reported by @JorisCod:

```python
cube = connection.load_stac(
    "https://stac.terrascope.be/collections/sentinel-2-l2a",
    spatial_extent={"west": 3, "south": 50, "east": 3.01, "north": 50.01},
    temporal_extent=["2020-06-01", "2020-06-30"],
    bands=["B02", "B04", "B08"],
)
...
cube.max_time()
```
fails with `MetadataException: No temporal dimension`

the STAC at URL above does not use cube extension, which openeo python client depends on to detect the temporal dimension.
So at the moment, the client currently handles this as "data has no temporal dimension".

There are other indicators in the STAC metadata that there is a temporal dimension, (e.g. "extent" > "temporal") so the client could be a bit more lenient here.

However, the client also needs the name of the dimension (e.g. "t", "time", or "temporal"). As far as I know there is no standard/convention/recommendation for the temporal dimension, so it's not ideal to pick one arbitrary here

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index aa030952..7b83adab 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ### Fixed
 
+- `load_stac`: use fallback temporal dimension when no "cube:dimensions" in STAC Collection ([#666](https://github.com/Open-EO/openeo-python-client/issues/666))
 
 ## [0.35.0] - 2024-11-19
 
diff --git a/openeo/metadata.py b/openeo/metadata.py
index 1de8c38b..8b770157 100644
--- a/openeo/metadata.py
+++ b/openeo/metadata.py
@@ -11,7 +11,7 @@ import pystac.extensions.eo
 import pystac.extensions.item_assets
 
 from openeo.internal.jupyter import render_component
-from openeo.util import deep_get
+from openeo.util import Rfc3339, deep_get
 
 _log = logging.getLogger(__name__)
 
@@ -691,6 +691,11 @@ class _StacMetadataParser:
                 if len(temporal_dims) == 1:
                     name, extent = temporal_dims[0]
                     return TemporalDimension(name=name, extent=extent)
+            elif isinstance(stac_obj, pystac.Collection) and stac_obj.extent.temporal:
+                # No explicit "cube:dimensions": build fallback from "extent.temporal",
+                # with dimension name "t" (openEO API recommendation).
+                extent = [Rfc3339(propagate_none=True).normalize(d) for d in stac_obj.extent.temporal.intervals[0]]
+                return TemporalDimension(name="t", extent=extent)
         else:
             if isinstance(stac_obj, pystac.Item):
                 cube_dimensions = stac_obj.properties.get("cube:dimensions", {})
diff --git a/openeo/rest/datacube.py b/openeo/rest/datacube.py
index c91fb722..f0da5884 100644
--- a/openeo/rest/datacube.py
+++ b/openeo/rest/datacube.py
@@ -203,6 +203,7 @@ class DataCube(_ProcessGraphAbstraction):
                 metadata = None
             if metadata:
                 bands = [b if isinstance(b, str) else metadata.band_dimension.band_name(b) for b in bands]
+                # TODO: also apply spatial/temporal filters to metadata?
                 metadata = metadata.filter_bands(bands)
             arguments['bands'] = bands
 
@@ -385,6 +386,9 @@ class DataCube(_ProcessGraphAbstraction):
         graph = PGNode("load_stac", arguments=arguments)
         try:
             metadata = metadata_from_stac(url)
+            if bands:
+                # TODO: also apply spatial/temporal filters to metadata?
+                metadata = metadata.filter_bands(band_names=bands)
         except Exception:
             log.warning(f"Failed to extract cube metadata from STAC URL {url}", exc_info=True)
             metadata = None
diff --git a/openeo/util.py b/openeo/util.py
index 6bbd4d89..44842124 100644
--- a/openeo/util.py
+++ b/openeo/util.py
@@ -172,7 +172,7 @@ class Rfc3339:
     @classmethod
     def _format_datetime(cls, d: dt.datetime) -> str:
         """Format given datetime as RFC-3339 date-time string."""
-        if d.tzinfo not in {None, dt.timezone.utc}:
+        if not (d.tzinfo is None or d.tzinfo.tzname(d) == "UTC"):
             # TODO: add support for non-UTC timezones?
             raise ValueError(f"No support for non-UTC timezone {d.tzinfo}")
         return d.strftime(cls._FMT_DATETIME)
diff --git a/setup.py b/setup.py
index b5a2fa75..d8ff51ea 100644
--- a/setup.py
+++ b/setup.py
@@ -30,6 +30,7 @@ tests_require = [
     "pyproj>=3.2.0",  # Pyproj is an optional, best-effort runtime dependency
     "dirty_equals>=0.8.0",
     "pyarrow>=10.0.1",  # For Parquet read/write support in pandas
+    "python-dateutil>=2.7.0",
 ]
 
 docs_require = [


Test patch:
diff --git a/tests/rest/test_connection.py b/tests/rest/test_connection.py
index a7e7d318..a447cd6b 100644
--- a/tests/rest/test_connection.py
+++ b/tests/rest/test_connection.py
@@ -18,6 +18,7 @@ import shapely.geometry
 import openeo
 from openeo.capabilities import ApiVersionException
 from openeo.internal.graph_building import FlatGraphableMixin, PGNode
+from openeo.metadata import _PYSTAC_1_9_EXTENSION_INTERFACE, TemporalDimension
 from openeo.rest import (
     CapabilitiesException,
     OpenEoApiError,
@@ -40,7 +41,7 @@ from openeo.rest.connection import (
 )
 from openeo.rest.vectorcube import VectorCube
 from openeo.testing.stac import StacDummyBuilder
-from openeo.util import ContextTimer, dict_no_none
+from openeo.util import ContextTimer, deep_get, dict_no_none
 
 from .auth.test_cli import auth_config, refresh_token_store
 
@@ -2622,6 +2623,51 @@ class TestLoadStac:
             },
         }
 
+    @pytest.mark.skipif(
+        not _PYSTAC_1_9_EXTENSION_INTERFACE,
+        reason="No backport of implementation/test below PySTAC 1.9 extension interface",
+    )
+    @pytest.mark.parametrize(
+        ["collection_extent", "dim_extent"],
+        [
+            (
+                {"spatial": {"bbox": [[3, 4, 5, 6]]}, "temporal": {"interval": [["2024-01-01", "2024-05-05"]]}},
+                ["2024-01-01T00:00:00Z", "2024-05-05T00:00:00Z"],
+            ),
+            (
+                {"spatial": {"bbox": [[3, 4, 5, 6]]}, "temporal": {"interval": [[None, "2024-05-05"]]}},
+                [None, "2024-05-05T00:00:00Z"],
+            ),
+        ],
+    )
+    def test_load_stac_no_cube_extension_temporal_dimension(self, con120, tmp_path, collection_extent, dim_extent):
+        """
+        Metadata detection when STAC metadata does not use "cube" extension
+        https://github.com/Open-EO/openeo-python-client/issues/666
+        """
+        stac_path = tmp_path / "stac.json"
+        stac_data = StacDummyBuilder.collection(extent=collection_extent)
+        # No cube:dimensions, but at least "temporal" extent is set as indicator for having a temporal dimension
+        assert "cube:dimensions" not in stac_data
+        assert deep_get(stac_data, "extent", "temporal")
+        stac_path.write_text(json.dumps(stac_data))
+
+        cube = con120.load_stac(str(stac_path))
+        assert cube.metadata.temporal_dimension == TemporalDimension(name="t", extent=dim_extent)
+
+    def test_load_stac_band_filtering(self, con120, tmp_path):
+        stac_path = tmp_path / "stac.json"
+        stac_data = StacDummyBuilder.collection(
+            summaries={"eo:bands": [{"name": "B01"}, {"name": "B02"}, {"name": "B03"}]}
+        )
+        stac_path.write_text(json.dumps(stac_data))
+
+        cube = con120.load_stac(str(stac_path))
+        assert cube.metadata.band_names == ["B01", "B02", "B03"]
+
+        cube = con120.load_stac(str(stac_path), bands=["B03", "B02"])
+        assert cube.metadata.band_names == ["B03", "B02"]
+
 
 @pytest.mark.parametrize(
     "data",
diff --git a/tests/test_metadata.py b/tests/test_metadata.py
index fce90186..a11bc6aa 100644
--- a/tests/test_metadata.py
+++ b/tests/test_metadata.py
@@ -849,6 +849,10 @@ def test_metadata_from_stac_collection_bands_from_item_assets(test_data, tmp_pat
     assert warn_count == (0 if eo_extension_is_declared else 1)
 
 
+@pytest.mark.skipif(
+    not _PYSTAC_1_9_EXTENSION_INTERFACE,
+    reason="No backport of implementation/test below PySTAC 1.9 extension interface",
+)
 @pytest.mark.parametrize(
     ["stac_dict", "expected"],
     [
@@ -868,7 +872,7 @@ def test_metadata_from_stac_collection_bands_from_item_assets(test_data, tmp_pat
         ),
         (
             StacDummyBuilder.collection(),
-            None,
+            ("t", ["2024-01-01T00:00:00Z", "2024-05-05T00:00:00Z"]),
         ),
         (
             StacDummyBuilder.collection(
diff --git a/tests/test_util.py b/tests/test_util.py
index 345c1f99..803efd54 100644
--- a/tests/test_util.py
+++ b/tests/test_util.py
@@ -7,6 +7,7 @@ import re
 import unittest.mock as mock
 from typing import List, Union
 
+import dateutil.tz
 import pyproj
 import pytest
 import shapely.geometry
@@ -87,21 +88,12 @@ class TestRfc3339:
         assert "2020-03-17T12:34:56Z" == rfc3339.datetime([2020, 3, 17, 12, 34, 56])
         assert "2020-03-17T12:34:56Z" == rfc3339.datetime(2020, 3, 17, 12, 34, 56)
         assert "2020-03-17T12:34:00Z" == rfc3339.datetime(2020, 3, 17, 12, 34)
-        assert "2020-03-17T12:34:56Z" == rfc3339.datetime(
-            (2020, "3", 17, "12", "34", 56)
-        )
-        assert "2020-09-17T12:34:56Z" == rfc3339.datetime(
-            [2020, "09", 17, "12", "34", 56]
-        )
-        assert "2020-09-17T12:34:56Z" == rfc3339.datetime(
-            2020, "09", "17", "12", "34", 56
-        )
-        assert "2020-03-17T12:34:56Z" == rfc3339.datetime(
-            dt.datetime(2020, 3, 17, 12, 34, 56, tzinfo=None)
-        )
-        assert "2020-03-17T12:34:56Z" == rfc3339.datetime(
-            dt.datetime(2020, 3, 17, 12, 34, 56, tzinfo=dt.timezone.utc)
-        )
+        assert "2020-03-17T12:34:56Z" == rfc3339.datetime((2020, "3", 17, "12", "34", 56))
+        assert "2020-09-17T12:34:56Z" == rfc3339.datetime([2020, "09", 17, "12", "34", 56])
+        assert "2020-09-17T12:34:56Z" == rfc3339.datetime(2020, "09", "17", "12", "34", 56)
+        assert "2020-03-17T12:34:56Z" == rfc3339.datetime(dt.datetime(2020, 3, 17, 12, 34, 56, tzinfo=None))
+        assert "2020-03-17T12:34:56Z" == rfc3339.datetime(dt.datetime(2020, 3, 17, 12, 34, 56, tzinfo=dt.timezone.utc))
+        assert "2020-03-17T12:34:56Z" == rfc3339.datetime(dt.datetime(2020, 3, 17, 12, 34, 56, tzinfo=dateutil.tz.UTC))
         assert "2020-03-17T12:34:56Z" == rfc3339.datetime(
             dt.datetime(
                 *(2020, 3, 17, 12, 34, 56),
@@ -125,15 +117,10 @@ class TestRfc3339:
             "2020-03-17T12:34:56.44546546Z"
         )
         assert "2020-03-17" == rfc3339.normalize(dt.date(2020, 3, 17))
-        assert "2020-03-17T12:34:56Z" == rfc3339.normalize(
-            dt.datetime(2020, 3, 17, 12, 34, 56)
-        )
-        assert "2020-03-17T12:34:56Z" == rfc3339.normalize(
-            dt.datetime(2020, 3, 17, 12, 34, 56, tzinfo=None)
-        )
-        assert "2020-03-17T12:34:56Z" == rfc3339.normalize(
-            dt.datetime(2020, 3, 17, 12, 34, 56, tzinfo=dt.timezone.utc)
-        )
+        assert "2020-03-17T12:34:56Z" == rfc3339.normalize(dt.datetime(2020, 3, 17, 12, 34, 56))
+        assert "2020-03-17T12:34:56Z" == rfc3339.normalize(dt.datetime(2020, 3, 17, 12, 34, 56, tzinfo=None))
+        assert "2020-03-17T12:34:56Z" == rfc3339.normalize(dt.datetime(2020, 3, 17, 12, 34, 56, tzinfo=dt.timezone.utc))
+        assert "2020-03-17T12:34:56Z" == rfc3339.normalize(dt.datetime(2020, 3, 17, 12, 34, 56, tzinfo=dateutil.tz.UTC))
         assert "2020-03-17T12:34:56Z" == rfc3339.normalize(
             dt.datetime(
                 *(2020, 3, 17, 12, 34, 56),