Skip to content

Commit d1e7b45

Browse files
Lazy import dependencies
1 parent 2bf9016 commit d1e7b45

18 files changed

Lines changed: 498 additions & 194 deletions

File tree

‎AGENTS.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,14 @@ Pre-commit hooks include YAML checks, EOF fixer, `sync-with-uv`, Ruff, and `ty`.
6969
- Logging uses `loguru` in several packages; workflows also supports explicit logger/tracer configuration.
7070
- Tests use `pytest`, with async coverage (`pytest-asyncio`) and property-based testing (`hypothesis`) in multiple packages.
7171

72+
### Import-Time Discipline
73+
74+
- Keep `tilebox.workflows` task-authoring imports light. Release runners create fresh virtual environments, so avoid
75+
importing heavy optional/runtime dependencies (`pandas`, `numpy`, `xarray`, cloud SDKs, `ipywidgets`, OpenTelemetry
76+
SDK/exporters, cache backends) from package `__init__` modules or core `Runner`/`Task` import paths.
77+
- Prefer lazy imports inside the methods that actually need those dependencies. Module-level `__getattr__` aliases are
78+
acceptable for public package aliases and are supported by Python 3.7+.
79+
7280
## Protobuf And Generated Code
7381

7482
Generated files live under paths such as:

‎CHANGELOG.md‎

Lines changed: 13 additions & 1 deletion
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+
## [0.55.1] - 2026-07-02
11+
12+
### Changed
13+
14+
- `tilebox-workflows`: Reduced import-time overhead for release runners by lazily loading optional heavy dependencies
15+
such as datasets, pandas, cloud SDKs, notebook widgets, and runtime/observability modules until they are needed.
16+
- `tilebox-datasets`: Reduced import-time overhead by lazily exporting the root and async client APIs and deferring
17+
pandas/xarray imports in time interval parsing until parsing requires them.
18+
- `tilebox-storage`: Reduced startup overhead by lazily exporting sync storage clients and deferring geospatial,
19+
notebook, object-store, cloud SDK, HTTP, and progress-display dependencies until storage operations require them.
20+
1021
## [0.55.0] - 2026-07-01
1122

1223
### Added
@@ -394,7 +405,8 @@ the first client that does not cache data (since it's already on the local file
394405
- Released under the [MIT](https://opensource.org/license/mit) license.
395406
- Released packages: `tilebox-datasets`, `tilebox-workflows`, `tilebox-storage`, `tilebox-grpc`
396407

397-
[Unreleased]: https://github.com/tilebox/tilebox-python/compare/v0.55.0...HEAD
408+
[Unreleased]: https://github.com/tilebox/tilebox-python/compare/v0.55.1...HEAD
409+
[0.55.1]: https://github.com/tilebox/tilebox-python/compare/v0.55.0...v0.55.1
398410
[0.55.0]: https://github.com/tilebox/tilebox-python/compare/v0.54.0...v0.55.0
399411
[0.54.0]: https://github.com/tilebox/tilebox-python/compare/v0.53.0...v0.54.0
400412
[0.53.0]: https://github.com/tilebox/tilebox-python/compare/v0.52.0...v0.53.0

‎tilebox-datasets/tilebox/datasets/__init__.py‎

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,46 @@
11
import os
22
import sys
3+
from typing import TYPE_CHECKING, Any
34

45
from loguru import logger
56

6-
# only here for backwards compatibility, to preserve backwards compatibility with older imports
7-
from tilebox.datasets.aio.timeseries import TimeseriesCollection, TimeseriesDataset
8-
from tilebox.datasets.sync.client import Client
9-
from tilebox.datasets.sync.dataset import CollectionClient, DatasetClient
7+
if TYPE_CHECKING:
8+
from tilebox.datasets.aio.timeseries import TimeseriesCollection, TimeseriesDataset
9+
from tilebox.datasets.sync.client import Client
10+
from tilebox.datasets.sync.dataset import CollectionClient, DatasetClient
1011

1112
__all__ = ["Client", "CollectionClient", "DatasetClient", "TimeseriesCollection", "TimeseriesDataset"]
1213

1314

15+
def __getattr__(name: str) -> Any:
16+
# PEP 562 module __getattr__ is supported since Python 3.7. Keep these aliases lazy so importing a focused
17+
# submodule like tilebox.datasets.query.id_interval does not also import the sync/aio clients and their data-model
18+
# dependencies.
19+
match name:
20+
case "Client":
21+
from tilebox.datasets.sync.client import Client # noqa: PLC0415
22+
23+
return Client
24+
case "CollectionClient":
25+
from tilebox.datasets.sync.dataset import CollectionClient # noqa: PLC0415
26+
27+
return CollectionClient
28+
case "DatasetClient":
29+
from tilebox.datasets.sync.dataset import DatasetClient # noqa: PLC0415
30+
31+
return DatasetClient
32+
case "TimeseriesCollection":
33+
from tilebox.datasets.aio.timeseries import TimeseriesCollection # noqa: PLC0415
34+
35+
return TimeseriesCollection
36+
case "TimeseriesDataset":
37+
from tilebox.datasets.aio.timeseries import TimeseriesDataset # noqa: PLC0415
38+
39+
return TimeseriesDataset
40+
case _:
41+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
42+
43+
1444
def _init_logging(level: str = "INFO") -> None:
1545
logger.remove()
1646
logger.add(sys.stdout, level=level, format="{message}", catch=True)
Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,36 @@
1-
from tilebox.datasets.aio.client import Client
2-
from tilebox.datasets.aio.dataset import CollectionClient, DatasetClient
1+
from typing import TYPE_CHECKING, Any
32

4-
# only here for backwards compatibility, to preserve backwards compatibility with older imports
5-
from tilebox.datasets.aio.timeseries import TimeseriesCollection, TimeseriesDataset
3+
if TYPE_CHECKING:
4+
from tilebox.datasets.aio.client import Client
5+
from tilebox.datasets.aio.dataset import CollectionClient, DatasetClient
6+
from tilebox.datasets.aio.timeseries import TimeseriesCollection, TimeseriesDataset
67

78
__all__ = ["Client", "CollectionClient", "DatasetClient", "TimeseriesCollection", "TimeseriesDataset"]
9+
10+
11+
def __getattr__(name: str) -> Any:
12+
# PEP 562 module __getattr__ is supported since Python 3.7. Keep these aliases lazy so importing
13+
# tilebox.datasets.aio does not also import xarray/pandas-backed dataset clients.
14+
match name:
15+
case "Client":
16+
from tilebox.datasets.aio.client import Client # noqa: PLC0415
17+
18+
return Client
19+
case "CollectionClient":
20+
from tilebox.datasets.aio.dataset import CollectionClient # noqa: PLC0415
21+
22+
return CollectionClient
23+
case "DatasetClient":
24+
from tilebox.datasets.aio.dataset import DatasetClient # noqa: PLC0415
25+
26+
return DatasetClient
27+
case "TimeseriesCollection":
28+
from tilebox.datasets.aio.timeseries import TimeseriesCollection # noqa: PLC0415
29+
30+
return TimeseriesCollection
31+
case "TimeseriesDataset":
32+
from tilebox.datasets.aio.timeseries import TimeseriesDataset # noqa: PLC0415
33+
34+
return TimeseriesDataset
35+
case _:
36+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

‎tilebox-datasets/tilebox/datasets/protobuf_conversion/protobuf_xarray.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@ def resize(self, buffer_size: int) -> None:
231231
elif buffer_size > len(self._data):
232232
# resize the data buffer to the new capacity, by just padding it with zeros at the end
233233
missing = buffer_size - len(self._data)
234-
self._data = np.pad( # ty: ignore[no-matching-overload]
234+
self._data = np.pad(
235235
self._data,
236236
((0, missing), (0, 0)),
237237
constant_values=self._type.fill_value,
@@ -309,7 +309,7 @@ def _resize(self) -> None:
309309
else: # resize the data buffer to the new capacity, by just padding it with zeros at the end
310310
missing_capacity = self._capacity - self._data.shape[0]
311311
missing_array_dim = self._array_dim - self._data.shape[1]
312-
self._data = np.pad( # ty: ignore[no-matching-overload]
312+
self._data = np.pad(
313313
self._data,
314314
((0, missing_capacity), (0, missing_array_dim), (0, 0)),
315315
constant_values=self._type.fill_value,

‎tilebox-datasets/tilebox/datasets/query/time_interval.py‎

Lines changed: 41 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,26 @@
11
from dataclasses import dataclass
22
from datetime import datetime, timedelta, timezone
3-
from typing import TypeAlias
3+
from typing import TYPE_CHECKING, Any, TypeAlias
44

5-
import numpy as np
6-
import xarray as xr
75
from google.protobuf.duration_pb2 import Duration
86
from google.protobuf.timestamp_pb2 import Timestamp
9-
from pandas.core.tools.datetimes import DatetimeScalar, to_datetime
107

118
from tilebox.datasets.tilebox.v1 import query_pb2
129

10+
if TYPE_CHECKING:
11+
from pandas.core.tools.datetimes import DatetimeScalar
12+
from xarray import DataArray, Dataset
13+
else:
14+
DataArray = Any
15+
Dataset = Any
16+
DatetimeScalar = Any
17+
1318
_SMALLEST_POSSIBLE_TIMEDELTA = timedelta(microseconds=1)
1419
_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
1520

1621
# A type alias for the different types that can be used to specify a time interval
1722
TimeIntervalLike: TypeAlias = (
18-
"DatetimeScalar | tuple[DatetimeScalar, DatetimeScalar] | xr.DataArray | xr.Dataset | TimeInterval"
23+
"DatetimeScalar | tuple[DatetimeScalar, DatetimeScalar] | list[DatetimeScalar] | DataArray | Dataset | TimeInterval"
1924
)
2025
# once we require python >= 3.12 we can replace this with a type statement, which doesn't require a string at all
2126
# type TimeIntervalLike = DatetimeScalar | tuple[DatetimeScalar ... | TimeInterval
@@ -133,30 +138,34 @@ def parse(cls, arg: TimeIntervalLike) -> "TimeInterval":
133138
TimeInterval: The parsed time interval
134139
"""
135140

136-
match arg:
137-
case TimeInterval(_, _, _, _):
138-
return arg
139-
case (start, end):
140-
return TimeInterval(start=_convert_to_datetime(start), end=_convert_to_datetime(end))
141-
case point_in_time if isinstance(point_in_time, DatetimeScalar | int):
142-
dt = _convert_to_datetime(point_in_time)
143-
return TimeInterval(start=dt, end=dt, start_exclusive=False, end_inclusive=True)
144-
case arr if (
145-
isinstance(arr, xr.DataArray)
146-
and arr.ndim == 1
147-
and arr.size > 0
148-
and arr.dtype == np.dtype("datetime64[ns]")
149-
):
150-
start = arr.data[0]
151-
end = arr.data[-1]
152-
return TimeInterval(
153-
start=_convert_to_datetime(start),
154-
end=_convert_to_datetime(end),
155-
start_exclusive=False,
156-
end_inclusive=True,
157-
)
158-
case ds if isinstance(ds, xr.Dataset) and "time" in ds.coords:
159-
return cls.parse(ds.time)
141+
if isinstance(arg, TimeInterval):
142+
return arg
143+
144+
if isinstance(arg, list | tuple) and len(arg) == 2:
145+
start, end = arg
146+
return TimeInterval(start=_convert_to_datetime(start), end=_convert_to_datetime(end))
147+
148+
from pandas.core.tools.datetimes import DatetimeScalar # noqa: PLC0415
149+
150+
if isinstance(arg, DatetimeScalar | int):
151+
dt = _convert_to_datetime(arg)
152+
return TimeInterval(start=dt, end=dt, start_exclusive=False, end_inclusive=True)
153+
154+
import numpy as np # noqa: PLC0415
155+
import xarray as xr # noqa: PLC0415
156+
157+
if isinstance(arg, xr.DataArray) and arg.ndim == 1 and arg.size > 0 and arg.dtype == np.dtype("datetime64[ns]"):
158+
start = arg.data[0]
159+
end = arg.data[-1]
160+
return TimeInterval(
161+
start=_convert_to_datetime(start),
162+
end=_convert_to_datetime(end),
163+
start_exclusive=False,
164+
end_inclusive=True,
165+
)
166+
167+
if isinstance(arg, xr.Dataset) and "time" in arg.coords:
168+
return cls.parse(arg.time)
160169

161170
raise ValueError(f"Failed to convert {arg} ({type(arg)}) to TimeInterval)")
162171

@@ -192,8 +201,10 @@ def to_message(self) -> query_pb2.TimeInterval:
192201
_EMPTY_TIME_INTERVAL = TimeInterval(_EPOCH, _EPOCH, start_exclusive=True, end_inclusive=False)
193202

194203

195-
def _convert_to_datetime(arg: DatetimeScalar) -> datetime:
204+
def _convert_to_datetime(arg: Any) -> datetime:
196205
"""Convert the given datetime scalar to a datetime object in the UTC timezone"""
206+
from pandas.core.tools.datetimes import to_datetime # noqa: PLC0415
207+
197208
dt: datetime = to_datetime(arg, utc=True).to_pydatetime()
198209
if dt.tzinfo is None:
199210
dt = dt.replace(tzinfo=timezone.utc)

‎tilebox-storage/tests/test_storage_client.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from hypothesis import HealthCheck, given, settings
1010
from obstore.store import LocalStore
1111

12+
import tilebox.storage.aio as storage_aio
1213
from tests.storage_data import ers_granules, landsat_granules, s5p_granules, umbra_granules
1314
from tilebox.storage.aio import (
1415
ASFStorageClient,
@@ -25,6 +26,11 @@
2526
USGSLandsatStorageGranule,
2627
)
2728

29+
# Warm lazy storage attributes used in Hypothesis-timed tests. Otherwise the first generated example pays the one-time
30+
# import cost and Hypothesis can report flaky deadline failures when replayed examples are much faster.
31+
_ = storage_aio.Boto3CredentialProvider
32+
_ = storage_aio.S3Store
33+
2834
pytestmark = pytest.mark.usefixtures("responses_mock")
2935

3036
ASF_LOGIN_URL = "https://urs.earthdata.nasa.gov/oauth/authorize"

0 commit comments

Comments
 (0)