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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

`tilebox-storage`: Added a `LocalFileSystemStorageClient` to access data on a local file system, a mounted network file
system or a syncified directory with a remote file system (e.g. Dropbox, Google Drive, etc.).

### Changed

`tilebox-storage`: Renamed the existing `StorageClient` base class in `tilebox.storage.aio` to `CachingStorageClient`
to accomodate the new `StorageClient` base class that does not provide caching, since `LocalFileSystemStorageClient` is
the first client that does not cache data (since it's already on the local file system).

## [0.47.0] - 2026-01-28

### Added
Expand Down
12 changes: 12 additions & 0 deletions tilebox-storage/tilebox/storage/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from tilebox.storage.aio import ASFStorageClient as _ASFStorageClient
from tilebox.storage.aio import CopernicusStorageClient as _CopernicusStorageClient
from tilebox.storage.aio import LocalFileSystemStorageClient as _LocalFileSystemStorageClient
from tilebox.storage.aio import UmbraStorageClient as _UmbraStorageClient
from tilebox.storage.aio import USGSLandsatStorageClient as _USGSLandsatStorageClient

Expand Down Expand Up @@ -66,3 +67,14 @@ def __init__(self, cache_directory: Path | None = Path.home() / ".cache" / "tile
"""
super().__init__(cache_directory)
self._syncify()


class LocalFileSystemStorageClient(_LocalFileSystemStorageClient):
def __init__(self, root: Path) -> None:
"""A tilebox storage client for accessing data on a local file system, or a mounted network file system.

Args:
root: The root directory of the file system to access.
"""
super().__init__(root)
self._syncify()
93 changes: 86 additions & 7 deletions tilebox-storage/tilebox/storage/aio.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from tilebox.storage.granule import (
ASFStorageGranule,
CopernicusStorageGranule,
LocationStorageGranule,
UmbraStorageGranule,
USGSLandsatStorageGranule,
)
Expand Down Expand Up @@ -241,6 +242,10 @@ def _display_quicklook(image_data: bytes | Path, width: int, height: int, image_


class StorageClient(Syncifiable):
"""Base class for all storage clients."""


class CachingStorageClient(StorageClient):
def __init__(self, cache_directory: Path | None) -> None:
self._cache = cache_directory

Expand Down Expand Up @@ -323,7 +328,7 @@ async def _download_object(
return output_path


class ASFStorageClient(StorageClient):
class ASFStorageClient(CachingStorageClient):
def __init__(self, user: str, password: str, cache_directory: Path = Path.home() / ".cache" / "tilebox") -> None:
"""A tilebox storage client that downloads data from the Alaska Satellite Facility.

Expand Down Expand Up @@ -415,7 +420,7 @@ async def quicklook(self, datapoint: xr.Dataset | ASFStorageGranule, width: int
"""
granule = ASFStorageGranule.from_data(datapoint)
if Image is None:
raise ImportError("IPython is not available, please use download_preview instead.")
raise ImportError("IPython is not available, please use download_quicklook instead.")
quicklook = await self._download_quicklook(datapoint)
_display_quicklook(quicklook, width, height, f"<code>Image {quicklook.name} © ASF {granule.time.year}</code>")

Expand All @@ -439,7 +444,7 @@ def _umbra_s3_prefix(datapoint: xr.Dataset | UmbraStorageGranule) -> str:
return f"sar-data/tasks/{granule.location}/"


class UmbraStorageClient(StorageClient):
class UmbraStorageClient(CachingStorageClient):
_STORAGE_PROVIDER = "Umbra"
_BUCKET = "umbra-open-data-catalog"
_REGION = "us-west-2"
Expand Down Expand Up @@ -539,7 +544,7 @@ def _copernicus_s3_prefix(datapoint: xr.Dataset | CopernicusStorageGranule) -> s
return granule.location.removeprefix("/eodata/")


class CopernicusStorageClient(StorageClient):
class CopernicusStorageClient(CachingStorageClient):
_STORAGE_PROVIDER = "CopernicusDataspace"
_BUCKET = "eodata"
_ENDPOINT_URL = "https://eodata.dataspace.copernicus.eu"
Expand Down Expand Up @@ -724,7 +729,7 @@ async def quicklook(
ValueError: If no quicklook is available for the given datapoint.
"""
if Image is None:
raise ImportError("IPython is not available, please use download_preview instead.")
raise ImportError("IPython is not available, please use download_quicklook instead.")
granule = CopernicusStorageGranule.from_data(datapoint)
quicklook = await self._download_quicklook(granule)
_display_quicklook(quicklook, width, height, f"<code>{granule.granule_name} © ESA {granule.time.year}</code>")
Expand All @@ -750,7 +755,7 @@ def _landsat_s3_prefix(datapoint: xr.Dataset | USGSLandsatStorageGranule) -> str
return granule.location.removeprefix("s3://usgs-landsat/")


class USGSLandsatStorageClient(StorageClient):
class USGSLandsatStorageClient(CachingStorageClient):
"""
A client for downloading USGS Landsat data from the usgs-landsat and usgs-landsat-ard S3 bucket.

Expand Down Expand Up @@ -883,7 +888,7 @@ async def quicklook(
ValueError: If no quicklook is available for the given datapoint.
"""
if Image is None:
raise ImportError("IPython is not available, please use download_preview instead.")
raise ImportError("IPython is not available, please use download_quicklook instead.")
quicklook = await self._download_quicklook(datapoint)
_display_quicklook(quicklook, width, height, f"<code>Image {quicklook.name} © USGS</code>")

Expand All @@ -901,3 +906,77 @@ async def _download_quicklook(self, datapoint: xr.Dataset | USGSLandsatStorageGr

await download_objects(self._store, prefix, [granule.thumbnail], output_folder, show_progress=False)
return output_folder / granule.thumbnail


class LocalFileSystemStorageClient(StorageClient):
def __init__(self, root: Path) -> None:
"""A tilebox storage client for accessing data on a local file system, or a mounted network file system.

Args:
root: The root directory of the file system to access.
"""
super().__init__()
self._root = Path(root)

async def list_objects(self, datapoint: xr.Dataset | LocationStorageGranule) -> list[str]:
"""List all available objects for a given datapoint."""
granule = LocationStorageGranule.from_data(datapoint)
granule_path = self._root / granule.location
return [p.relative_to(granule_path).as_posix() for p in granule_path.rglob("**/*") if p.is_file()]

async def download(
self,
datapoint: xr.Dataset | LocationStorageGranule,
) -> Path:
"""No-op download method, as the data is already on the local file system.

Args:
datapoint: The datapoint to locate the data for in the local file system.

Returns:
The path to the data on the local file system.
"""
granule = LocationStorageGranule.from_data(datapoint)
granule_path = self._root / granule.location
if not granule_path.exists():
raise ValueError(f"Data not found on the local file system: {granule_path}")
return granule_path

async def _download_quicklook(self, datapoint: xr.Dataset | LocationStorageGranule) -> Path:
granule = LocationStorageGranule.from_data(datapoint)
if granule.thumbnail is None:
raise ValueError(f"No quicklook available for {granule.location}")
quicklook_path = self._root / granule.thumbnail
if not quicklook_path.exists():
raise ValueError(f"Quicklook not found on the local file system: {quicklook_path}")
return quicklook_path

async def download_quicklook(self, datapoint: xr.Dataset | LocationStorageGranule) -> Path:
"""No-op download_quicklook method, as the quicklook image is already on the local file system.

Args:
datapoint: The datapoint to locate the quicklook image for in the local file system.

Returns:
The path to the data on the local file system.

Raises:
ValueError: If no quicklook image is available for the given datapoint, or if the quicklook image is not
found on the local file system.
"""
return await self._download_quicklook(datapoint)

async def quicklook(
self, datapoint: xr.Dataset | LocationStorageGranule, width: int = 600, height: int = 600
) -> None:
"""Display the quicklook image for a given datapoint.

Args:
datapoint: The datapoint to display the quicklook for.
width: Display width of the image in pixels. Defaults to 600.
height: Display height of the image in pixels. Defaults to 600.
"""
quicklook_path = await self._download_quicklook(datapoint)
if Image is None:
raise ImportError("IPython is not available, please use download_quicklook instead.")
_display_quicklook(quicklook_path, width, height, None)
25 changes: 25 additions & 0 deletions tilebox-storage/tilebox/storage/granule.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,3 +183,28 @@ def from_data(cls, dataset: "xr.Dataset | USGSLandsatStorageGranule") -> "USGSLa
dataset.location.item().replace("s3://usgs-landsat-ard/", "s3://usgs-landsat/"),
thumbnail,
)


@dataclass
class LocationStorageGranule:
location: str
thumbnail: str | None = None

@classmethod
def from_data(cls, dataset: "xr.Dataset | LocationStorageGranule") -> "LocationStorageGranule":
"""Extract the granule information from a datapoint given as xarray dataset."""
if isinstance(dataset, LocationStorageGranule):
return dataset

if "location" not in dataset:
raise ValueError("The given dataset has no location information.")

thumbnail = None
if "thumbnail" in dataset:
thumbnail = dataset.thumbnail.item()
elif "overview" in dataset:
thumbnail = dataset.overview.item()
elif "quicklook" in dataset:
thumbnail = dataset.quicklook.item()

return cls(dataset.location.item(), thumbnail)
Loading
Loading