Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions tilebox-storage/tilebox/storage/aio.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
LocationStorageGranule,
UmbraStorageGranule,
USGSLandsatStorageGranule,
_is_copernicus_odata_url,
)
from tilebox.storage.providers import login

Expand Down Expand Up @@ -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

Expand Down
35 changes: 28 additions & 7 deletions tilebox-storage/tilebox/storage/granule.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading