Lokasi ngalangkungan proxy:   [ UP ]  
[Ngawartoskeun bug]   [Panyetelan cookie]                
Skip to content

Commit 8ada146

Browse files
committed
feat: lazy package init to keep import python_utils light
Defer all submodule/function imports via PEP 562 (`__getattr__`/`__dir__`) so `import python_utils` no longer eagerly imports every submodule, and move `asyncio` (and `python_utils.aio`) imports inside `python_utils.time`'s async helpers. Consumers that only need the synchronous utilities no longer pay the cost of importing `asyncio`. - __init__.py: PEP 562 lazy loading; `__version__` kept eager (cheap metadata). Public `__all__` is unchanged and `dir()` still lists the lazy `containers`/`exceptions` submodules so `import_global()` keeps working. - time.py: `aio_timeout_generator(iterable=...)` now defaults to `None` and resolves to `aio.acount` lazily (the default cannot reference `aio.acount` without importing asyncio at module load); `asyncio` is imported inside the two async helpers. - tests: regression tests proving a bare `import python_utils` and `import python_utils.time` pull in neither `asyncio` nor `typing_extensions`. Public API verified against the 4.0.0 base via an export/signature diff: the only differences are the two inherent consequences of deferring asyncio -- `aio_timeout_generator`'s default iterable is `None` instead of `aio.acount`, and `python_utils.time` no longer re-exposes the `aio`/`asyncio` modules.
1 parent 63aa6cf commit 8ada146

3 files changed

Lines changed: 243 additions & 36 deletions

File tree

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"""Tests for the lazy-import machinery that keeps `import python_utils` light
2+
(PEP 562 `__getattr__`/`__dir__` in the package, deferred ``asyncio`` in
3+
``python_utils.time``).
4+
"""
5+
6+
import collections.abc
7+
import os
8+
import subprocess
9+
import sys
10+
11+
import pytest
12+
13+
import python_utils
14+
15+
16+
def _run_clean(code: str) -> subprocess.CompletedProcess[str]:
17+
# Run in a fresh interpreter: the test session itself has long since
18+
# imported asyncio/typing_extensions, so in-process checks are useless.
19+
env = {**os.environ, 'PYTHONPATH': os.pathsep.join(sys.path)}
20+
return subprocess.run(
21+
[sys.executable, '-c', code],
22+
capture_output=True,
23+
text=True,
24+
env=env,
25+
)
26+
27+
28+
def test_package_lazy_attribute_access() -> None:
29+
# Submodule access and exported-name access both resolve via __getattr__.
30+
aio = python_utils.aio
31+
assert python_utils.aio is aio # repeated access returns the cached module
32+
assert callable(python_utils.acount)
33+
assert isinstance(python_utils.__version__, str)
34+
missing = 'definitely_not_a_real_attribute'
35+
with pytest.raises(AttributeError):
36+
getattr(python_utils, missing)
37+
38+
39+
def test_bare_import_stays_light() -> None:
40+
# Importing the package must not eagerly pull in heavy/optional deps.
41+
result = _run_clean(
42+
'import sys, python_utils\n'
43+
"assert 'asyncio' not in sys.modules, "
44+
"sorted(m for m in sys.modules if m.startswith('asyncio'))\n"
45+
"assert 'typing_extensions' not in sys.modules\n"
46+
)
47+
assert result.returncode == 0, result.stderr
48+
49+
50+
def test_importing_time_submodule_avoids_asyncio() -> None:
51+
# Importing python_utils.time for its synchronous helpers must not import
52+
# asyncio; the async helpers import it lazily inside their own bodies.
53+
result = _run_clean(
54+
'import sys, python_utils.time\n'
55+
"assert 'asyncio' not in sys.modules, "
56+
"sorted(m for m in sys.modules if m.startswith('asyncio'))\n"
57+
)
58+
assert result.returncode == 0, result.stderr
59+
60+
61+
def test_first_access_caches_into_module_dict() -> None:
62+
# PEP 562 __getattr__ runs once: the resolved object is cached in the
63+
# module namespace so subsequent lookups skip __getattr__ entirely.
64+
module = python_utils.time
65+
assert python_utils.__dict__['time'] is module
66+
67+
func = python_utils.format_time
68+
assert python_utils.__dict__['format_time'] is func
69+
70+
71+
def test_dir_lists_lazy_submodules() -> None:
72+
# Lazy submodules that are not in __all__ (e.g. ``containers`` and
73+
# ``exceptions``) must still be discoverable via ``dir``; tools such as
74+
# ``import_global`` intersect requested names with ``dir(module)``.
75+
names = set(dir(python_utils))
76+
assert {'containers', 'exceptions'} <= names
77+
assert set(python_utils.__all__) <= names
78+
79+
80+
@pytest.mark.asyncio
81+
async def test_aio_timeout_generator_default_iterable() -> None:
82+
# With no iterable the generator defaults to ``aio.acount`` -- exercising
83+
# the lazy ``aio``/``asyncio`` import and the None-resolution branch.
84+
count = 0
85+
generator: collections.abc.AsyncGenerator[object, None] = (
86+
python_utils.aio_timeout_generator(timeout=0.05, interval=0.0)
87+
)
88+
async for _ in generator:
89+
count += 1
90+
if count >= 2:
91+
break
92+
93+
assert count == 2
94+
95+
# Sanity: the re-exported type alias still resolves through the package.
96+
assert python_utils.types.AsyncGenerator is not None

python_utils/__init__.py

Lines changed: 129 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
713
aio
@@ -52,40 +58,132 @@
5258
LoggerBase
5359
"""
5460

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

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

python_utils/time.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
"""
1717

1818
# pyright: reportUnnecessaryIsInstance=false
19-
import asyncio
2019
import collections.abc
2120
import datetime
2221
import functools
@@ -25,7 +24,7 @@
2524
import typing
2625

2726
import python_utils
28-
from python_utils import aio, exceptions, types
27+
from python_utils import exceptions, types
2928

3029
_T = typing.TypeVar('_T')
3130
_P = typing.ParamSpec('_P')
@@ -256,9 +255,8 @@ async def aio_timeout_generator(
256255
timeout: types.delta_type, # noqa: ASYNC109
257256
interval: types.delta_type = datetime.timedelta(seconds=1),
258257
iterable: collections.abc.AsyncIterable[_T]
259-
| collections.abc.Callable[
260-
..., collections.abc.AsyncIterable[_T]
261-
] = aio.acount,
258+
| collections.abc.Callable[..., collections.abc.AsyncIterable[_T]]
259+
| None = None,
262260
interval_multiplier: float = 1.0,
263261
maximum_interval: types.delta_type | None = None,
264262
) -> collections.abc.AsyncGenerator[_T, None]:
@@ -276,6 +274,18 @@ async def aio_timeout_generator(
276274
effectively the same as the `timeout_generator` but it uses `async for`
277275
instead.
278276
"""
277+
# Imported lazily so that importing `python_utils.time` for its
278+
# synchronous helpers (e.g. ``format_time``) does not pull in ``asyncio``.
279+
import asyncio
280+
281+
from python_utils import aio
282+
283+
if iterable is None:
284+
iterable = typing.cast(
285+
collections.abc.Callable[[], collections.abc.AsyncIterable[_T]],
286+
aio.acount,
287+
)
288+
279289
float_interval: float = delta_to_seconds(interval)
280290
float_maximum_interval: float | None = delta_to_seconds_or_none(
281291
maximum_interval
@@ -323,6 +333,9 @@ async def aio_generator_timeout_detector(
323333
If `on_timeout` is `None`, the exception is silently ignored and the
324334
generator will finish as normal.
325335
"""
336+
# Imported lazily so importing `python_utils.time` stays asyncio-free.
337+
import asyncio
338+
326339
if total_timeout is None:
327340
total_timeout_end = None
328341
else:

0 commit comments

Comments
 (0)