Skip to content

Commit b59f06f

Browse files
Lazy import dependencies (#41)
1 parent b0d3ce8 commit b59f06f

21 files changed

Lines changed: 594 additions & 220 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.56.0] - 2026-07-16
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
### Fixed
1122

1223
- `tilebox-workflows`: Allow task `execute()` methods to use a `-> None` return annotation when postponed annotation
@@ -401,7 +412,8 @@ the first client that does not cache data (since it's already on the local file
401412
- Released under the [MIT](https://opensource.org/license/mit) license.
402413
- Released packages: `tilebox-datasets`, `tilebox-workflows`, `tilebox-storage`, `tilebox-grpc`
403414

404-
[Unreleased]: https://github.com/tilebox/tilebox-python/compare/v0.55.0...HEAD
415+
[Unreleased]: https://github.com/tilebox/tilebox-python/compare/v0.55.1...HEAD
416+
[0.55.1]: https://github.com/tilebox/tilebox-python/compare/v0.55.0...v0.55.1
405417
[0.55.0]: https://github.com/tilebox/tilebox-python/compare/v0.54.0...v0.55.0
406418
[0.54.0]: https://github.com/tilebox/tilebox-python/compare/v0.53.0...v0.54.0
407419
[0.53.0]: https://github.com/tilebox/tilebox-python/compare/v0.52.0...v0.53.0

‎tilebox-datasets/tests/test_client.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import os
2+
import subprocess
3+
import sys
24
from datetime import datetime, timezone
35
from pathlib import Path
46
from unittest.mock import MagicMock, patch
@@ -15,6 +17,24 @@
1517
from tilebox.datasets.query.time_interval import us_to_datetime
1618

1719

20+
def test_heavy_imports_are_lazy() -> None:
21+
code = (
22+
"import sys\n"
23+
"import tilebox.datasets as datasets\n"
24+
"assert not {'pandas', 'xarray'} & sys.modules.keys()\n"
25+
"assert set(datasets.__all__) <= set(dir(datasets))\n"
26+
"from tilebox.datasets.query.time_interval import TimeInterval\n"
27+
"assert not {'pandas', 'xarray'} & sys.modules.keys()\n"
28+
"TimeInterval.parse('2026-01-01')\n"
29+
"assert 'pandas' in sys.modules\n"
30+
"assert 'xarray' not in sys.modules\n"
31+
"client = datasets.Client\n"
32+
"assert datasets.Client is client\n"
33+
"assert 'Client' in vars(datasets)\n"
34+
)
35+
subprocess.run([sys.executable, "-c", code], check=True) # noqa: S603
36+
37+
1838
@pytest.mark.parametrize("url", [_TILEBOX_API_URL, _TILEBOX_DEV_API_URL, f"{_TILEBOX_API_URL}/"])
1939
@patch.dict(os.environ, {}, clear=True)
2040
@patch("tilebox.datasets.sync.client.open_channel")

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

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,55 @@
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+
value = Client
24+
case "CollectionClient":
25+
from tilebox.datasets.sync.dataset import CollectionClient # noqa: PLC0415
26+
27+
value = CollectionClient
28+
case "DatasetClient":
29+
from tilebox.datasets.sync.dataset import DatasetClient # noqa: PLC0415
30+
31+
value = DatasetClient
32+
case "TimeseriesCollection":
33+
from tilebox.datasets.aio.timeseries import TimeseriesCollection # noqa: PLC0415
34+
35+
value = TimeseriesCollection
36+
case "TimeseriesDataset":
37+
from tilebox.datasets.aio.timeseries import TimeseriesDataset # noqa: PLC0415
38+
39+
value = TimeseriesDataset
40+
case _:
41+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
42+
43+
# Cache the resolved export so subsequent access uses normal module lookup instead of calling __getattr__ again.
44+
globals()[name] = value
45+
return value
46+
47+
48+
def __dir__() -> list[str]:
49+
# Include public lazy exports in dir(module) before they have been loaded.
50+
return sorted(set(globals()) | set(__all__))
51+
52+
1453
def _init_logging(level: str = "INFO") -> None:
1554
logger.remove()
1655
logger.add(sys.stdout, level=level, format="{message}", catch=True)
Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,45 @@
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+
value = Client
19+
case "CollectionClient":
20+
from tilebox.datasets.aio.dataset import CollectionClient # noqa: PLC0415
21+
22+
value = CollectionClient
23+
case "DatasetClient":
24+
from tilebox.datasets.aio.dataset import DatasetClient # noqa: PLC0415
25+
26+
value = DatasetClient
27+
case "TimeseriesCollection":
28+
from tilebox.datasets.aio.timeseries import TimeseriesCollection # noqa: PLC0415
29+
30+
value = TimeseriesCollection
31+
case "TimeseriesDataset":
32+
from tilebox.datasets.aio.timeseries import TimeseriesDataset # noqa: PLC0415
33+
34+
value = TimeseriesDataset
35+
case _:
36+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
37+
38+
# Cache the resolved export so subsequent access uses normal module lookup instead of calling __getattr__ again.
39+
globals()[name] = value
40+
return value
41+
42+
43+
def __dir__() -> list[str]:
44+
# Include public lazy exports in dir(module) before they have been loaded.
45+
return sorted(set(globals()) | set(__all__))

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

Lines changed: 37 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,22 @@
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+
1314
_SMALLEST_POSSIBLE_TIMEDELTA = timedelta(microseconds=1)
1415
_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
1516

1617
# A type alias for the different types that can be used to specify a time interval
1718
TimeIntervalLike: TypeAlias = (
18-
"DatetimeScalar | tuple[DatetimeScalar, DatetimeScalar] | xr.DataArray | xr.Dataset | TimeInterval"
19+
"DatetimeScalar | tuple[DatetimeScalar, DatetimeScalar] | list[DatetimeScalar] | DataArray | Dataset | TimeInterval"
1920
)
2021
# once we require python >= 3.12 we can replace this with a type statement, which doesn't require a string at all
2122
# type TimeIntervalLike = DatetimeScalar | tuple[DatetimeScalar ... | TimeInterval
@@ -133,30 +134,34 @@ def parse(cls, arg: TimeIntervalLike) -> "TimeInterval":
133134
TimeInterval: The parsed time interval
134135
"""
135136

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

161166
raise ValueError(f"Failed to convert {arg} ({type(arg)}) to TimeInterval)")
162167

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

194199

195-
def _convert_to_datetime(arg: DatetimeScalar) -> datetime:
200+
def _convert_to_datetime(arg: Any) -> datetime:
196201
"""Convert the given datetime scalar to a datetime object in the UTC timezone"""
202+
from pandas.core.tools.datetimes import to_datetime # noqa: PLC0415
203+
197204
dt: datetime = to_datetime(arg, utc=True).to_pydatetime()
198205
if dt.tzinfo is None:
199206
dt = dt.replace(tzinfo=timezone.utc)

0 commit comments

Comments
 (0)