From d059a8704c5409dc028b3dbe496540406e1e5954 Mon Sep 17 00:00:00 2001 From: Silvren <237023679+Silvren@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:05:37 +0800 Subject: [PATCH 1/2] Control canonical links in load_stac_from_job # Conflicts: # CHANGELOG.md --- CHANGELOG.md | 2 ++ openeo/rest/connection.py | 35 ++++++++++++++++++++++++++++++----- tests/rest/test_connection.py | 29 +++++++++++++++++++++++++++-- 3 files changed, 59 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8037ed845..64d7d5389 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Allow `Connection.load_stac_from_job()` to require or avoid canonical result links. ([#634](https://github.com/Open-EO/openeo-python-client/issues/634)) + ### Changed ### Removed diff --git a/openeo/rest/connection.py b/openeo/rest/connection.py index 6a34f922a..09ba9e4eb 100644 --- a/openeo/rest/connection.py +++ b/openeo/rest/connection.py @@ -19,6 +19,7 @@ Iterable, Iterator, List, + Literal, Mapping, Optional, Sequence, @@ -1478,12 +1479,14 @@ def load_stac_from_job( temporal_extent: Union[Sequence[InputDate], Parameter, str, None] = None, bands: Optional[List[str]] = None, properties: Optional[Dict[str, Union[str, PGNode, Callable]]] = None, + *, + canonical_link: Literal["auto", "require", "avoid"] = "auto", ) -> DataCube: """ Convenience function to directly load the results of a finished openEO job (as a STAC collection) with :py:meth:`load_stac` in a new openEO process graph. - When available, the "canonical" link (signed URL) of the job results will be used. + By default, the "canonical" link (signed URL) of the job results is used when available. :param job: a :py:class:`~openeo.rest.job.BatchJob` or job id pointing to a finished job. Note that the :py:class:`~openeo.rest.job.BatchJob` approach allows to point @@ -1491,15 +1494,34 @@ def load_stac_from_job( :param spatial_extent: limit data to specified bounding box or polygons :param temporal_extent: limit data to specified temporal interval. :param bands: limit data to the specified bands + :param canonical_link: control use of the canonical result link: + ``"auto"`` prefers it but falls back to the regular job results URL, + ``"require"`` fails if it is unavailable, and ``"avoid"`` always uses + the regular job results URL. .. versionadded:: 0.30.0 + + .. versionchanged:: 0.51.0 + Added the ``canonical_link`` argument. """ - # TODO #634 add option to require or avoid the canonical link + if canonical_link not in {"auto", "require", "avoid"}: + raise ValueError(f"Invalid canonical_link mode {canonical_link!r}") + if isinstance(job, str): job = BatchJob(job_id=job, connection=self) elif not isinstance(job, BatchJob): raise ValueError("job must be a BatchJob or job id") + stac_link = job.get_results_metadata_url(full=True) + if canonical_link == "avoid": + return self.load_stac( + url=stac_link, + spatial_extent=spatial_extent, + temporal_extent=temporal_extent, + bands=bands, + properties=properties, + ) + try: job_results = job.get_results() @@ -1509,8 +1531,10 @@ def load_stac_from_job( if link.get("rel") == "canonical" and "href" in link ] if len(canonical_links) == 0: - _log.warning("No canonical link found in job results metadata. Using job results URL instead.") - stac_link = job.get_results_metadata_url(full=True) + message = "No canonical link found in job results metadata." + if canonical_link == "require": + raise OpenEoClientException(message) + _log.warning(f"{message} Using job results URL instead.") else: if len(canonical_links) > 1: _log.warning( @@ -1518,8 +1542,9 @@ def load_stac_from_job( ) stac_link = canonical_links[0] except OpenEoApiError as e: + if canonical_link == "require": + raise _log.warning(f"Failed to get the canonical job results: {e!r}. Using job results URL instead.") - stac_link = job.get_results_metadata_url(full=True) return self.load_stac( url=stac_link, diff --git a/tests/rest/test_connection.py b/tests/rest/test_connection.py index e59d88176..a710026d7 100644 --- a/tests/rest/test_connection.py +++ b/tests/rest/test_connection.py @@ -3241,7 +3241,8 @@ def test_property_filtering(self, con120, properties, expected): } } - def test_load_stac_from_job_canonical(self, con120, requests_mock): + @pytest.mark.parametrize("canonical_link", ["auto", "require"]) + def test_load_stac_from_job_canonical(self, con120, requests_mock, canonical_link): requests_mock.get( API_URL + "jobs/j0bi6/results", json={ @@ -3258,7 +3259,7 @@ def test_load_stac_from_job_canonical(self, con120, requests_mock): }, ) job = con120.job("j0bi6") - cube = con120.load_stac_from_job(job) + cube = con120.load_stac_from_job(job, canonical_link=canonical_link) fg = cube.flat_graph() assert fg == { @@ -3295,6 +3296,30 @@ def test_load_stac_from_job_unsigned(self, con120, requests_mock): } } + def test_load_stac_from_job_require_canonical_missing(self, con120, requests_mock): + requests_mock.get( + API_URL + "jobs/j0bi6/results", + json={"links": [{"href": "https://wrong.test", "rel": "self"}]}, + ) + + with pytest.raises(OpenEoClientException, match="No canonical link found in job results metadata"): + con120.load_stac_from_job("j0bi6", canonical_link="require") + + def test_load_stac_from_job_avoid_canonical(self, con120, requests_mock): + results = requests_mock.get( + API_URL + "jobs/j0bi6/results", + json={"links": [{"href": "https://stac.test", "rel": "canonical"}]}, + ) + + cube = con120.load_stac_from_job("j0bi6", canonical_link="avoid") + + assert results.called is False + assert cube.flat_graph()["loadstac1"]["arguments"]["url"] == API_URL + "jobs/j0bi6/results" + + def test_load_stac_from_job_invalid_canonical_link_mode(self, con120): + with pytest.raises(ValueError, match="Invalid canonical_link mode 'sometimes'"): + con120.load_stac_from_job("j0bi6", canonical_link="sometimes") + def test_load_stac_from_job_from_jobid(self, con120, requests_mock): requests_mock.get( API_URL + "jobs/j0bi6/results", From bae852d0e4e8b6ab3a219696f8f8a6203a919f3d Mon Sep 17 00:00:00 2001 From: Silvren <237023679+Silvren@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:05:55 +0800 Subject: [PATCH 2/2] Address canonical link review feedback # Conflicts: # CHANGELOG.md --- openeo/rest/connection.py | 68 ++++++++++++++++------------------- tests/rest/test_connection.py | 14 ++++---- 2 files changed, 37 insertions(+), 45 deletions(-) diff --git a/openeo/rest/connection.py b/openeo/rest/connection.py index 09ba9e4eb..6fb177d8b 100644 --- a/openeo/rest/connection.py +++ b/openeo/rest/connection.py @@ -1480,7 +1480,7 @@ def load_stac_from_job( bands: Optional[List[str]] = None, properties: Optional[Dict[str, Union[str, PGNode, Callable]]] = None, *, - canonical_link: Literal["auto", "require", "avoid"] = "auto", + use_canonical_link: Literal["auto", "require", "avoid"] = "auto", ) -> DataCube: """ Convenience function to directly load the results of a finished openEO job @@ -1494,18 +1494,18 @@ def load_stac_from_job( :param spatial_extent: limit data to specified bounding box or polygons :param temporal_extent: limit data to specified temporal interval. :param bands: limit data to the specified bands - :param canonical_link: control use of the canonical result link: + :param use_canonical_link: control use of the canonical result link: ``"auto"`` prefers it but falls back to the regular job results URL, ``"require"`` fails if it is unavailable, and ``"avoid"`` always uses the regular job results URL. .. versionadded:: 0.30.0 - .. versionchanged:: 0.51.0 - Added the ``canonical_link`` argument. + .. versionchanged:: 0.52.0 + Added the ``use_canonical_link`` argument. """ - if canonical_link not in {"auto", "require", "avoid"}: - raise ValueError(f"Invalid canonical_link mode {canonical_link!r}") + if use_canonical_link not in {"auto", "require", "avoid"}: + raise ValueError(f"Invalid use_canonical_link mode {use_canonical_link!r}") if isinstance(job, str): job = BatchJob(job_id=job, connection=self) @@ -1513,38 +1513,30 @@ def load_stac_from_job( raise ValueError("job must be a BatchJob or job id") stac_link = job.get_results_metadata_url(full=True) - if canonical_link == "avoid": - return self.load_stac( - url=stac_link, - spatial_extent=spatial_extent, - temporal_extent=temporal_extent, - bands=bands, - properties=properties, - ) - - try: - job_results = job.get_results() - - canonical_links = [ - link["href"] - for link in job_results.get_metadata().get("links", []) - if link.get("rel") == "canonical" and "href" in link - ] - if len(canonical_links) == 0: - message = "No canonical link found in job results metadata." - if canonical_link == "require": - raise OpenEoClientException(message) - _log.warning(f"{message} Using job results URL instead.") - else: - if len(canonical_links) > 1: - _log.warning( - f"Multiple canonical links found in job results metadata: {canonical_links}. Picking first one." - ) - stac_link = canonical_links[0] - except OpenEoApiError as e: - if canonical_link == "require": - raise - _log.warning(f"Failed to get the canonical job results: {e!r}. Using job results URL instead.") + if use_canonical_link != "avoid": + try: + job_results = job.get_results() + + canonical_links = [ + link["href"] + for link in job_results.get_metadata().get("links", []) + if link.get("rel") == "canonical" and "href" in link + ] + if len(canonical_links) == 0: + message = "No canonical link found in job results metadata." + if use_canonical_link == "require": + raise OpenEoClientException(message) + _log.warning(f"{message} Using job results URL instead.") + else: + if len(canonical_links) > 1: + _log.warning( + f"Multiple canonical links found in job results metadata: {canonical_links}. Picking first one." + ) + stac_link = canonical_links[0] + except OpenEoApiError as e: + if use_canonical_link == "require": + raise + _log.warning(f"Failed to get the canonical job results: {e!r}. Using job results URL instead.") return self.load_stac( url=stac_link, diff --git a/tests/rest/test_connection.py b/tests/rest/test_connection.py index a710026d7..9c41eb8b9 100644 --- a/tests/rest/test_connection.py +++ b/tests/rest/test_connection.py @@ -3241,8 +3241,8 @@ def test_property_filtering(self, con120, properties, expected): } } - @pytest.mark.parametrize("canonical_link", ["auto", "require"]) - def test_load_stac_from_job_canonical(self, con120, requests_mock, canonical_link): + @pytest.mark.parametrize("use_canonical_link", ["auto", "require"]) + def test_load_stac_from_job_canonical(self, con120, requests_mock, use_canonical_link): requests_mock.get( API_URL + "jobs/j0bi6/results", json={ @@ -3259,7 +3259,7 @@ def test_load_stac_from_job_canonical(self, con120, requests_mock, canonical_lin }, ) job = con120.job("j0bi6") - cube = con120.load_stac_from_job(job, canonical_link=canonical_link) + cube = con120.load_stac_from_job(job, use_canonical_link=use_canonical_link) fg = cube.flat_graph() assert fg == { @@ -3303,7 +3303,7 @@ def test_load_stac_from_job_require_canonical_missing(self, con120, requests_moc ) with pytest.raises(OpenEoClientException, match="No canonical link found in job results metadata"): - con120.load_stac_from_job("j0bi6", canonical_link="require") + con120.load_stac_from_job("j0bi6", use_canonical_link="require") def test_load_stac_from_job_avoid_canonical(self, con120, requests_mock): results = requests_mock.get( @@ -3311,14 +3311,14 @@ def test_load_stac_from_job_avoid_canonical(self, con120, requests_mock): json={"links": [{"href": "https://stac.test", "rel": "canonical"}]}, ) - cube = con120.load_stac_from_job("j0bi6", canonical_link="avoid") + cube = con120.load_stac_from_job("j0bi6", use_canonical_link="avoid") assert results.called is False assert cube.flat_graph()["loadstac1"]["arguments"]["url"] == API_URL + "jobs/j0bi6/results" def test_load_stac_from_job_invalid_canonical_link_mode(self, con120): - with pytest.raises(ValueError, match="Invalid canonical_link mode 'sometimes'"): - con120.load_stac_from_job("j0bi6", canonical_link="sometimes") + with pytest.raises(ValueError, match="Invalid use_canonical_link mode 'sometimes'"): + con120.load_stac_from_job("j0bi6", use_canonical_link="sometimes") def test_load_stac_from_job_from_jobid(self, con120, requests_mock): requests_mock.get(