Skip to content

Commit a4545d0

Browse files
committed
Defer asyncio and typing_extensions imports (lazy package init)
`import python_utils` now imports nothing eagerly: the package __init__ uses PEP 562 __getattr__ to load submodules and their exported names on first access (with a TYPE_CHECKING block so static typing is unchanged). This avoids pulling in asyncio for consumers that only need the synchronous utilities. time.py no longer imports asyncio/aio at module scope (moved into the two async generators; the `aio.acount` default is resolved lazily), so `from python_utils.time import format_time` stays asyncio-free. types.py imports typing_extensions eagerly only on Python < 3.11 (to preserve the backport overrides); on 3.11+ stdlib typing already provides the names used and any remaining typing_extensions-only name is served lazily via __getattr__. Net effect for a typical consumer (measured via python-progressbar): import drops ~43ms -> ~22ms, with asyncio and typing_extensions no longer loaded. Behaviour and public API are unchanged; full test suite passes.
1 parent d36802f commit a4545d0

5 files changed

Lines changed: 225 additions & 38 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""Tests for the lazy-import machinery added to keep `import python_utils`
2+
light (PEP 562 `__getattr__` in the package and in `types`).
3+
"""
4+
5+
import pytest
6+
7+
import python_utils
8+
from python_utils import types
9+
10+
11+
def test_package_lazy_attribute_access() -> None:
12+
# Submodule access and exported-name access both resolve via __getattr__.
13+
assert python_utils.aio is python_utils.aio
14+
assert callable(python_utils.acount)
15+
missing = 'definitely_not_a_real_attribute'
16+
with pytest.raises(AttributeError):
17+
getattr(python_utils, missing)
18+
19+
20+
def test_types_lazy_typing_extensions(monkeypatch: pytest.MonkeyPatch) -> None:
21+
# ``Protocol`` is normally present from ``from typing import *``; remove it
22+
# so attribute access falls through to ``types.__getattr__``, which
23+
# re-fetches it from typing_extensions (covering the lazy success path).
24+
monkeypatch.delattr(types, 'Protocol', raising=False)
25+
assert types.Protocol is not None
26+
missing = 'not_a_real_typing_name_xyz'
27+
with pytest.raises(AttributeError):
28+
getattr(types, missing)
29+
30+
31+
@pytest.mark.asyncio
32+
async def test_aio_timeout_generator_default_iterable() -> None:
33+
# With no iterable the generator defaults to ``aio.acount`` -- exercising
34+
# the lazy ``aio``/``asyncio`` import and the None-resolution branch.
35+
count = 0
36+
generator: types.AsyncGenerator[object, None] = (
37+
python_utils.aio_timeout_generator(timeout=0.05, interval=0.0)
38+
)
39+
async for _ in generator:
40+
count += 1
41+
if count >= 2:
42+
break
43+
44+
assert count == 2

python_utils/__init__.py

Lines changed: 124 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22
This module initializes the `python_utils` package by importing various
33
submodules and functions.
44
5+
Imports are performed lazily (PEP 562): nothing is imported when you ``import
6+
python_utils``; each submodule/function is loaded on first access. This keeps
7+
``import python_utils`` cheap and, in particular, avoids eagerly importing
8+
``asyncio`` (via the async helpers) for consumers that only need the
9+
synchronous utilities.
10+
511
Submodules:
612
aio
713
converters
@@ -49,39 +55,126 @@
4955
LoggerBase
5056
"""
5157

52-
from . import (
53-
aio,
54-
converters,
55-
decorators,
56-
formatters,
57-
generators,
58-
import_,
59-
logger,
60-
terminal,
61-
time,
62-
types,
63-
)
64-
from .aio import acount
65-
from .containers import CastedDict, LazyCastedDict, UniqueList
66-
from .converters import remap, scale_1024, to_float, to_int, to_str, to_unicode
67-
from .decorators import listify, set_attributes
68-
from .exceptions import raise_exception, reraise
69-
from .formatters import camel_to_underscore, timesince
70-
from .generators import abatcher, batcher
71-
from .import_ import import_global
72-
from .logger import Logged, LoggerBase
73-
from .terminal import get_terminal_size
74-
from .time import (
75-
aio_generator_timeout_detector,
76-
aio_generator_timeout_detector_decorator,
77-
aio_timeout_generator,
78-
delta_to_seconds,
79-
delta_to_seconds_or_none,
80-
format_time,
81-
timedelta_to_seconds,
82-
timeout_generator,
58+
import importlib
59+
import typing
60+
61+
if typing.TYPE_CHECKING:
62+
# Eager imports for type checkers only; the runtime equivalents are loaded
63+
# lazily by ``__getattr__`` below. Names appear in ``__all__`` so they are
64+
# treated as re-exports (not unused imports).
65+
from . import (
66+
aio,
67+
converters,
68+
decorators,
69+
formatters,
70+
generators,
71+
import_,
72+
logger,
73+
terminal,
74+
time,
75+
types,
76+
)
77+
from .aio import acount
78+
from .containers import CastedDict, LazyCastedDict, UniqueList
79+
from .converters import (
80+
remap,
81+
scale_1024,
82+
to_float,
83+
to_int,
84+
to_str,
85+
to_unicode,
86+
)
87+
from .decorators import listify, set_attributes
88+
from .exceptions import raise_exception, reraise
89+
from .formatters import camel_to_underscore, timesince
90+
from .generators import abatcher, batcher
91+
from .import_ import import_global
92+
from .logger import Logged, LoggerBase
93+
from .terminal import get_terminal_size
94+
from .time import (
95+
aio_generator_timeout_detector,
96+
aio_generator_timeout_detector_decorator,
97+
aio_timeout_generator,
98+
delta_to_seconds,
99+
delta_to_seconds_or_none,
100+
format_time,
101+
timedelta_to_seconds,
102+
timeout_generator,
103+
)
104+
105+
#: Submodules that can be accessed as ``python_utils.<name>``.
106+
_SUBMODULES: frozenset[str] = frozenset(
107+
{
108+
'aio',
109+
'containers',
110+
'converters',
111+
'decorators',
112+
'exceptions',
113+
'formatters',
114+
'generators',
115+
'import_',
116+
'logger',
117+
'terminal',
118+
'time',
119+
'types',
120+
}
83121
)
84122

123+
#: Exported name -> submodule it lives in.
124+
_NAME_TO_MODULE: dict[str, str] = {
125+
'acount': 'aio',
126+
'CastedDict': 'containers',
127+
'LazyCastedDict': 'containers',
128+
'UniqueList': 'containers',
129+
'remap': 'converters',
130+
'scale_1024': 'converters',
131+
'to_float': 'converters',
132+
'to_int': 'converters',
133+
'to_str': 'converters',
134+
'to_unicode': 'converters',
135+
'listify': 'decorators',
136+
'set_attributes': 'decorators',
137+
'raise_exception': 'exceptions',
138+
'reraise': 'exceptions',
139+
'camel_to_underscore': 'formatters',
140+
'timesince': 'formatters',
141+
'abatcher': 'generators',
142+
'batcher': 'generators',
143+
'import_global': 'import_',
144+
'Logged': 'logger',
145+
'LoggerBase': 'logger',
146+
'get_terminal_size': 'terminal',
147+
'aio_generator_timeout_detector': 'time',
148+
'aio_generator_timeout_detector_decorator': 'time',
149+
'aio_timeout_generator': 'time',
150+
'delta_to_seconds': 'time',
151+
'delta_to_seconds_or_none': 'time',
152+
'format_time': 'time',
153+
'timedelta_to_seconds': 'time',
154+
'timeout_generator': 'time',
155+
}
156+
157+
158+
def __getattr__(name: str) -> typing.Any:
159+
"""Lazily import submodules and their exported names on first access."""
160+
if name in _SUBMODULES:
161+
module = importlib.import_module(f'.{name}', __name__)
162+
elif name in _NAME_TO_MODULE:
163+
module = importlib.import_module(f'.{_NAME_TO_MODULE[name]}', __name__)
164+
value = getattr(module, name)
165+
globals()[name] = value # cache so __getattr__ runs only once
166+
return value
167+
else:
168+
raise AttributeError(f'module {__name__!r} has no attribute {name!r}')
169+
170+
globals()[name] = module
171+
return module
172+
173+
174+
def __dir__() -> list[str]:
175+
return sorted(set(globals()) | set(__all__))
176+
177+
85178
__all__ = [
86179
'CastedDict',
87180
'LazyCastedDict',

python_utils/converters.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,7 @@ def remap( # pyright: ignore[reportInconsistentOverload]
353353
new_max: _TN,
354354
) -> _TN:
355355
"""
356-
remap a value from one range into another.
356+
Remap a value from one range into another.
357357
358358
>>> remap(500, 0, 1000, 0, 100)
359359
50

python_utils/time.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,13 @@
1919
"""
2020

2121
# pyright: reportUnnecessaryIsInstance=false
22-
import asyncio
2322
import datetime
2423
import functools
2524
import itertools
2625
import time
2726

2827
import python_utils
29-
from python_utils import aio, exceptions, types
28+
from python_utils import exceptions, types
3029

3130
_T = types.TypeVar('_T')
3231
_P = types.ParamSpec('_P')
@@ -260,9 +259,12 @@ def timeout_generator(
260259
async def aio_timeout_generator(
261260
timeout: types.delta_type, # noqa: ASYNC109
262261
interval: types.delta_type = datetime.timedelta(seconds=1),
263-
iterable: types.Union[
264-
types.AsyncIterable[_T], types.Callable[..., types.AsyncIterable[_T]]
265-
] = aio.acount,
262+
iterable: types.Optional[
263+
types.Union[
264+
types.AsyncIterable[_T],
265+
types.Callable[..., types.AsyncIterable[_T]],
266+
]
267+
] = None,
266268
interval_multiplier: float = 1.0,
267269
maximum_interval: types.Optional[types.delta_type] = None,
268270
) -> types.AsyncGenerator[_T, None]:
@@ -280,6 +282,18 @@ async def aio_timeout_generator(
280282
effectively the same as the `timeout_generator` but it uses `async for`
281283
instead.
282284
"""
285+
# Imported lazily so that importing `python_utils.time` for its
286+
# synchronous helpers (e.g. ``format_time``) does not pull in ``asyncio``.
287+
import asyncio
288+
289+
from python_utils import aio
290+
291+
if iterable is None:
292+
iterable = types.cast(
293+
'types.AsyncIterable[_T]',
294+
aio.acount,
295+
)
296+
283297
float_interval: float = delta_to_seconds(interval)
284298
float_maximum_interval: types.Optional[float] = delta_to_seconds_or_none(
285299
maximum_interval
@@ -328,6 +342,9 @@ async def aio_generator_timeout_detector(
328342
If `on_timeout` is `None`, the exception is silently ignored and the
329343
generator will finish as normal.
330344
"""
345+
# Imported lazily so importing `python_utils.time` stays asyncio-free.
346+
import asyncio
347+
331348
if total_timeout is None:
332349
total_timeout_end = None
333350
else:

python_utils/types.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
import datetime
1616
import decimal
17+
import sys # noqa: F401 (used in version-gated typing_extensions import below)
1718
from re import Match, Pattern
1819
from types import * # pragma: no cover # noqa: F403
1920
from typing import * # pragma: no cover # noqa: F403
@@ -32,7 +33,15 @@
3233
Union as U, # noqa: N817
3334
)
3435

35-
from typing_extensions import * # type: ignore[no-redef,assignment] # noqa: F403
36+
if sys.version_info < (3, 11): # pragma: no cover
37+
# Older Pythons need typing_extensions' backports (names and behavioural
38+
# fixes) that stdlib typing lacks, so import it eagerly there. Type
39+
# checkers target the minimum version and so always see these names. On
40+
# 3.11+ stdlib typing already provides everything used here, and any
41+
# remaining typing_extensions-only name is served lazily by __getattr__
42+
# below -- so importing this module (and therefore `python_utils`) does
43+
# not eagerly pull in typing_extensions.
44+
from typing_extensions import * # type: ignore[no-redef,assignment] # noqa: F403
3645

3746
Scope = Dict[str, Any]
3847
OptionalScope = O[Scope]
@@ -53,6 +62,30 @@
5362
None,
5463
]
5564

65+
66+
def __getattr__(name: str) -> Any:
67+
"""Lazily resolve typing_extensions-only names on first access.
68+
69+
On Python 3.11+ typing_extensions is not imported eagerly (see above); any
70+
name not provided by stdlib ``typing``/``types`` is fetched from
71+
typing_extensions here, on demand.
72+
"""
73+
if name.startswith('__') and name.endswith('__'):
74+
raise AttributeError(name)
75+
76+
import typing_extensions
77+
78+
try:
79+
value = getattr(typing_extensions, name)
80+
except AttributeError:
81+
raise AttributeError(
82+
f'module {__name__!r} has no attribute {name!r}',
83+
) from None
84+
85+
globals()[name] = value # cache so __getattr__ runs only once per name
86+
return value
87+
88+
5689
__all__ = [
5790
'IO',
5891
'TYPE_CHECKING',

0 commit comments

Comments
 (0)