diff --git a/CHANGELOG.md b/CHANGELOG.md index 6727f19..96aa5e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.51.0] - 2026-04-07 + ### Changed - `tilebox-storage`: Replaced `httpx` with `niquests` for ASF HTTP downloads. +### Fixed + +- `tilebox-storage`: Fixed an issue with the Copernicus storage client that prevented downloading granules pointing to the Copernicus OData thumbnail endpoint. (All granules ingested from March 2026 onwards). + ## [0.50.1] - 2026-04-01 ### Added @@ -351,7 +357,8 @@ the first client that does not cache data (since it's already on the local file - Released under the [MIT](https://opensource.org/license/mit) license. - Released packages: `tilebox-datasets`, `tilebox-workflows`, `tilebox-storage`, `tilebox-grpc` -[Unreleased]: https://github.com/tilebox/tilebox-python/compare/v0.50.0...HEAD +[Unreleased]: https://github.com/tilebox/tilebox-python/compare/v0.51.0...HEAD +[0.51.0]: https://github.com/tilebox/tilebox-python/compare/v0.50.1...v0.51.0 [0.50.1]: https://github.com/tilebox/tilebox-python/compare/v0.50.0...v0.50.1 [0.50.0]: https://github.com/tilebox/tilebox-python/compare/v0.49.0...v0.50.0 [0.49.0]: https://github.com/tilebox/tilebox-python/compare/v0.48.0...v0.49.0 diff --git a/tilebox-storage/tilebox/storage/aio.py b/tilebox-storage/tilebox/storage/aio.py index 561616e..f6043b9 100644 --- a/tilebox-storage/tilebox/storage/aio.py +++ b/tilebox-storage/tilebox/storage/aio.py @@ -27,6 +27,7 @@ LocationStorageGranule, UmbraStorageGranule, USGSLandsatStorageGranule, + _is_copernicus_odata_url, ) from tilebox.storage.providers import login @@ -750,6 +751,22 @@ async def _download_quicklook(self, datapoint: xr.Dataset | CopernicusStorageGra else Path.cwd() / self._STORAGE_PROVIDER ) + if _is_copernicus_odata_url(granule.thumbnail): + # the thumbnail is not stored in the S3 bucket, but is accessible via a public URL. So download it + # directly. + response = await niquests.aget( + granule.thumbnail, allow_redirects=True + ) # to check if the thumbnail is accessible, raises if not + response.raise_for_status() + content = response.content + if content is None: + raise ValueError("Received empty content when downloading quicklook.") + + download_location = (output_folder / granule.granule_name).with_suffix(".jpg") + download_location.parent.mkdir(parents=True, exist_ok=True) + download_location.write_bytes(content) + return download_location + await download_objects(self._store, prefix, [granule.thumbnail], output_folder, show_progress=False) return output_folder / granule.thumbnail diff --git a/tilebox-storage/tilebox/storage/granule.py b/tilebox-storage/tilebox/storage/granule.py index dfa86bf..03441d5 100644 --- a/tilebox-storage/tilebox/storage/granule.py +++ b/tilebox-storage/tilebox/storage/granule.py @@ -99,11 +99,34 @@ def _thumbnail_relative_to_eodata_location(thumbnail_url: str, location: str) -> >>> ) "preview/thumbnail.png" """ - url_path = thumbnail_url.rsplit("?path=", maxsplit=1)[-1] url_path = url_path.removeprefix("/") location = location.removeprefix("/eodata/") - return str(ObjectPath(url_path).relative_to(location)) + try: + return str(ObjectPath(url_path).relative_to(location)) + except ValueError: + # in case the path couldn't be properly parsed, relative_to will fail. Fall back to the default value then + return thumbnail_url + + +def _is_copernicus_odata_url(url: str) -> bool: + """ + Checks whether a thumbnail path is an URL pointing to the Copernicus OData API + + Those URLs don't encode the actual filename/location, so we cannot easily convert them to the S3 Paths. + Therefore those thumbnails we'll always download via HTTP + + Example: + >>> _is_copernicus_odata_url("https://catalogue.dataspace.copernicus.eu/odata/v1/Assets(822e7592-0a66-41b1-b87d-27eec64c377b)/$value") + True + + Args: + url: The granule thumbnail URL to check + + Returns: + bool: True if the URL is a Copernicus OData API URL, False otherwise + """ + return url.startswith("https://catalogue.dataspace.copernicus.eu/odata/v1/Assets") and url.endswith("/$value") @dataclass @@ -133,11 +156,9 @@ def from_data(cls, dataset: "xr.Dataset | CopernicusStorageGranule") -> "Coperni if "thumbnail" in dataset: thumbnail_path = dataset.thumbnail.item().strip() - thumbnail = ( - _thumbnail_relative_to_eodata_location(thumbnail_path, location) - if isinstance(thumbnail_path, str) and len(thumbnail_path) > 0 - else None - ) + thumbnail = thumbnail_path if isinstance(thumbnail_path, str) and len(thumbnail_path) > 0 else None + if thumbnail is not None and not _is_copernicus_odata_url(thumbnail): + thumbnail = _thumbnail_relative_to_eodata_location(thumbnail, location) return cls( time,