Skip to content

Commit 99e96e8

Browse files
LocalFileSystem storage client (#26)
1 parent ed8374b commit 99e96e8

5 files changed

Lines changed: 162 additions & 35 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
`tilebox-storage`: Added a `LocalFileSystemStorageClient` to access data on a local file system, a mounted network file
13+
system or a syncified directory with a remote file system (e.g. Dropbox, Google Drive, etc.).
14+
15+
### Changed
16+
17+
`tilebox-storage`: Renamed the existing `StorageClient` base class in `tilebox.storage.aio` to `CachingStorageClient`
18+
to accomodate the new `StorageClient` base class that does not provide caching, since `LocalFileSystemStorageClient` is
19+
the first client that does not cache data (since it's already on the local file system).
20+
1021
## [0.47.0] - 2026-01-28
1122

1223
### Added

tilebox-storage/tilebox/storage/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from tilebox.storage.aio import ASFStorageClient as _ASFStorageClient
44
from tilebox.storage.aio import CopernicusStorageClient as _CopernicusStorageClient
5+
from tilebox.storage.aio import LocalFileSystemStorageClient as _LocalFileSystemStorageClient
56
from tilebox.storage.aio import UmbraStorageClient as _UmbraStorageClient
67
from tilebox.storage.aio import USGSLandsatStorageClient as _USGSLandsatStorageClient
78

@@ -66,3 +67,14 @@ def __init__(self, cache_directory: Path | None = Path.home() / ".cache" / "tile
6667
"""
6768
super().__init__(cache_directory)
6869
self._syncify()
70+
71+
72+
class LocalFileSystemStorageClient(_LocalFileSystemStorageClient):
73+
def __init__(self, root: Path) -> None:
74+
"""A tilebox storage client for accessing data on a local file system, or a mounted network file system.
75+
76+
Args:
77+
root: The root directory of the file system to access.
78+
"""
79+
super().__init__(root)
80+
self._syncify()

tilebox-storage/tilebox/storage/aio.py

Lines changed: 86 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from tilebox.storage.granule import (
2525
ASFStorageGranule,
2626
CopernicusStorageGranule,
27+
LocationStorageGranule,
2728
UmbraStorageGranule,
2829
USGSLandsatStorageGranule,
2930
)
@@ -241,6 +242,10 @@ def _display_quicklook(image_data: bytes | Path, width: int, height: int, image_
241242

242243

243244
class StorageClient(Syncifiable):
245+
"""Base class for all storage clients."""
246+
247+
248+
class CachingStorageClient(StorageClient):
244249
def __init__(self, cache_directory: Path | None) -> None:
245250
self._cache = cache_directory
246251

@@ -323,7 +328,7 @@ async def _download_object(
323328
return output_path
324329

325330

326-
class ASFStorageClient(StorageClient):
331+
class ASFStorageClient(CachingStorageClient):
327332
def __init__(self, user: str, password: str, cache_directory: Path = Path.home() / ".cache" / "tilebox") -> None:
328333
"""A tilebox storage client that downloads data from the Alaska Satellite Facility.
329334
@@ -415,7 +420,7 @@ async def quicklook(self, datapoint: xr.Dataset | ASFStorageGranule, width: int
415420
"""
416421
granule = ASFStorageGranule.from_data(datapoint)
417422
if Image is None:
418-
raise ImportError("IPython is not available, please use download_preview instead.")
423+
raise ImportError("IPython is not available, please use download_quicklook instead.")
419424
quicklook = await self._download_quicklook(datapoint)
420425
_display_quicklook(quicklook, width, height, f"<code>Image {quicklook.name} © ASF {granule.time.year}</code>")
421426

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

441446

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

541546

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

752757

753-
class USGSLandsatStorageClient(StorageClient):
758+
class USGSLandsatStorageClient(CachingStorageClient):
754759
"""
755760
A client for downloading USGS Landsat data from the usgs-landsat and usgs-landsat-ard S3 bucket.
756761
@@ -883,7 +888,7 @@ async def quicklook(
883888
ValueError: If no quicklook is available for the given datapoint.
884889
"""
885890
if Image is None:
886-
raise ImportError("IPython is not available, please use download_preview instead.")
891+
raise ImportError("IPython is not available, please use download_quicklook instead.")
887892
quicklook = await self._download_quicklook(datapoint)
888893
_display_quicklook(quicklook, width, height, f"<code>Image {quicklook.name} © USGS</code>")
889894

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

902907
await download_objects(self._store, prefix, [granule.thumbnail], output_folder, show_progress=False)
903908
return output_folder / granule.thumbnail
909+
910+
911+
class LocalFileSystemStorageClient(StorageClient):
912+
def __init__(self, root: Path) -> None:
913+
"""A tilebox storage client for accessing data on a local file system, or a mounted network file system.
914+
915+
Args:
916+
root: The root directory of the file system to access.
917+
"""
918+
super().__init__()
919+
self._root = Path(root)
920+
921+
async def list_objects(self, datapoint: xr.Dataset | LocationStorageGranule) -> list[str]:
922+
"""List all available objects for a given datapoint."""
923+
granule = LocationStorageGranule.from_data(datapoint)
924+
granule_path = self._root / granule.location
925+
return [p.relative_to(granule_path).as_posix() for p in granule_path.rglob("**/*") if p.is_file()]
926+
927+
async def download(
928+
self,
929+
datapoint: xr.Dataset | LocationStorageGranule,
930+
) -> Path:
931+
"""No-op download method, as the data is already on the local file system.
932+
933+
Args:
934+
datapoint: The datapoint to locate the data for in the local file system.
935+
936+
Returns:
937+
The path to the data on the local file system.
938+
"""
939+
granule = LocationStorageGranule.from_data(datapoint)
940+
granule_path = self._root / granule.location
941+
if not granule_path.exists():
942+
raise ValueError(f"Data not found on the local file system: {granule_path}")
943+
return granule_path
944+
945+
async def _download_quicklook(self, datapoint: xr.Dataset | LocationStorageGranule) -> Path:
946+
granule = LocationStorageGranule.from_data(datapoint)
947+
if granule.thumbnail is None:
948+
raise ValueError(f"No quicklook available for {granule.location}")
949+
quicklook_path = self._root / granule.thumbnail
950+
if not quicklook_path.exists():
951+
raise ValueError(f"Quicklook not found on the local file system: {quicklook_path}")
952+
return quicklook_path
953+
954+
async def download_quicklook(self, datapoint: xr.Dataset | LocationStorageGranule) -> Path:
955+
"""No-op download_quicklook method, as the quicklook image is already on the local file system.
956+
957+
Args:
958+
datapoint: The datapoint to locate the quicklook image for in the local file system.
959+
960+
Returns:
961+
The path to the data on the local file system.
962+
963+
Raises:
964+
ValueError: If no quicklook image is available for the given datapoint, or if the quicklook image is not
965+
found on the local file system.
966+
"""
967+
return await self._download_quicklook(datapoint)
968+
969+
async def quicklook(
970+
self, datapoint: xr.Dataset | LocationStorageGranule, width: int = 600, height: int = 600
971+
) -> None:
972+
"""Display the quicklook image for a given datapoint.
973+
974+
Args:
975+
datapoint: The datapoint to display the quicklook for.
976+
width: Display width of the image in pixels. Defaults to 600.
977+
height: Display height of the image in pixels. Defaults to 600.
978+
"""
979+
quicklook_path = await self._download_quicklook(datapoint)
980+
if Image is None:
981+
raise ImportError("IPython is not available, please use download_quicklook instead.")
982+
_display_quicklook(quicklook_path, width, height, None)

tilebox-storage/tilebox/storage/granule.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,3 +183,28 @@ def from_data(cls, dataset: "xr.Dataset | USGSLandsatStorageGranule") -> "USGSLa
183183
dataset.location.item().replace("s3://usgs-landsat-ard/", "s3://usgs-landsat/"),
184184
thumbnail,
185185
)
186+
187+
188+
@dataclass
189+
class LocationStorageGranule:
190+
location: str
191+
thumbnail: str | None = None
192+
193+
@classmethod
194+
def from_data(cls, dataset: "xr.Dataset | LocationStorageGranule") -> "LocationStorageGranule":
195+
"""Extract the granule information from a datapoint given as xarray dataset."""
196+
if isinstance(dataset, LocationStorageGranule):
197+
return dataset
198+
199+
if "location" not in dataset:
200+
raise ValueError("The given dataset has no location information.")
201+
202+
thumbnail = None
203+
if "thumbnail" in dataset:
204+
thumbnail = dataset.thumbnail.item()
205+
elif "overview" in dataset:
206+
thumbnail = dataset.overview.item()
207+
elif "quicklook" in dataset:
208+
thumbnail = dataset.quicklook.item()
209+
210+
return cls(dataset.location.item(), thumbnail)

0 commit comments

Comments
 (0)