Skip to content

Commit c76b511

Browse files
committed
perf: defer typing_extensions and lazy __version__ to keep imports light
Builds on the lazy package init: removes typing_extensions from the submodule import path and defers __version__. - Add stdlib-only python_utils/_aliases.py holding the lightweight type aliases; python_utils.types re-exports them (explicit `X as X` idiom) and keeps its eager typing_extensions facade + runtime overrides unchanged. - Rewire time/converters/formatters/generators/import_ off python_utils.types onto _aliases, so importing them no longer pulls typing_extensions. - Move logger's only typing_extensions use (Logged.__new__ -> Self) into TYPE_CHECKING (Self is a new addition; runtime introspection of __new__'s return type is not a compat obligation). - Make python_utils.__version__ lazy via the PEP 562 __getattr__ so bare `import python_utils` no longer calls importlib.metadata.version() (~89 -> ~8 modules added to sys.modules). - Add a deterministic import-footprint regression gate + get_type_hints smoke. Public API verified byte-identical to develop via an export/signature manifest diff, except: (a) aio_timeout_generator's default iterable (from the lazy asyncio change) and (b) converters' raw __annotations__ strings now name the bare aliases (get_type_hints resolves identically). `from __future__ import annotations` is limited to where required (logger, containers) so inspect.signature keeps returning evaluated type objects elsewhere. The undocumented python_utils.<submodule>.types re-export attribute is removed.
1 parent 8ada146 commit c76b511

12 files changed

Lines changed: 278 additions & 69 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""The lightweight alias module must define the public type aliases without
2+
pulling in typing_extensions, so importers stay light.
3+
"""
4+
5+
import os
6+
import subprocess
7+
import sys
8+
9+
10+
def test_aliases_do_not_import_typing_extensions() -> None:
11+
result = subprocess.run(
12+
[
13+
sys.executable,
14+
'-c',
15+
'import sys, python_utils._aliases\n'
16+
"assert 'typing_extensions' not in sys.modules\n",
17+
],
18+
capture_output=True,
19+
text=True,
20+
env={**os.environ, 'PYTHONPATH': os.pathsep.join(sys.path)},
21+
)
22+
assert result.returncode == 0, result.stderr
23+
24+
25+
def test_aliases_values() -> None:
26+
from python_utils import _aliases
27+
28+
assert _aliases.Number == (int | float)
29+
assert _aliases.delta_type == (
30+
__import__('datetime').timedelta | int | float
31+
)
32+
assert set(_aliases.__all__) == {
33+
'Scope',
34+
'OptionalScope',
35+
'Number',
36+
'DecimalNumber',
37+
'ExceptionType',
38+
'ExceptionsType',
39+
'StringTypes',
40+
'delta_type',
41+
'timestamp_type',
42+
}
43+
44+
45+
def test_types_reexports_aliases_identically() -> None:
46+
from python_utils import _aliases, types
47+
48+
for name in _aliases.__all__:
49+
assert getattr(types, name) is getattr(_aliases, name), name
50+
51+
52+
def test_types_still_exposes_typing_extensions_surface() -> None:
53+
# The facade must keep re-exporting typing_extensions (e.g. Self).
54+
# ``hasattr`` (not ``types.Self``) avoids basedpyright's
55+
# reportUnknownMemberType, since the wildcard re-export has no static type.
56+
from python_utils import types
57+
58+
assert hasattr(types, 'Self')
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""Deterministic import-footprint regression gate.
2+
3+
Each target module is imported in a clean subprocess; a per-target denylist of
4+
heavy modules must be absent from sys.modules afterward. This is the CI
5+
performance guard: it fails the moment an eager heavy import is reintroduced.
6+
"""
7+
8+
import json
9+
import os
10+
import subprocess
11+
import sys
12+
13+
import pytest
14+
15+
# (import target, modules that must be ABSENT from sys.modules afterward)
16+
FOOTPRINT_CASES = [
17+
('python_utils', ('typing_extensions', 'asyncio')),
18+
('python_utils.time', ('typing_extensions', 'asyncio')),
19+
('python_utils.logger', ('typing_extensions',)),
20+
('python_utils.converters', ('typing_extensions', 'asyncio')),
21+
('python_utils.formatters', ('typing_extensions', 'asyncio')),
22+
('python_utils.import_', ('typing_extensions', 'asyncio')),
23+
('python_utils.terminal', ('typing_extensions',)),
24+
('python_utils.containers', ('typing_extensions', 'asyncio')),
25+
('python_utils.decorators', ('typing_extensions', 'asyncio')),
26+
('python_utils.exceptions', ('typing_extensions', 'asyncio')),
27+
# aio, generators legitimately use asyncio; only typing_extensions denied.
28+
('python_utils.aio', ('typing_extensions',)),
29+
('python_utils.generators', ('typing_extensions',)),
30+
]
31+
32+
33+
def _modules_after_import(target: str) -> set[str]:
34+
result = subprocess.run(
35+
[
36+
sys.executable,
37+
'-c',
38+
f'import sys, {target}\n'
39+
'import json\n'
40+
'print(json.dumps(sorted(sys.modules)))\n',
41+
],
42+
capture_output=True,
43+
text=True,
44+
env={**os.environ, 'PYTHONPATH': os.pathsep.join(sys.path)},
45+
)
46+
assert result.returncode == 0, result.stderr
47+
48+
return set(json.loads(result.stdout))
49+
50+
51+
@pytest.mark.parametrize(('target', 'denied'), FOOTPRINT_CASES)
52+
def test_import_footprint(target: str, denied: tuple[str, ...]) -> None:
53+
present = _modules_after_import(target)
54+
leaked = [m for m in denied if m in present]
55+
assert not leaked, f'{target} eagerly imported {leaked}'
56+
57+
58+
def test_bare_import_module_count_under_budget() -> None:
59+
# Coarse bloat tripwire (denylist above is the real guard). Cap tightened
60+
# now that __version__ is lazy (importlib.metadata no longer pulled on bare
61+
# import). Bump only if a new Python version legitimately adds startup
62+
# modules. Measures modules ADDED by importing python_utils.
63+
added = len(_modules_after_import('python_utils')) - len(
64+
_modules_after_import('sys')
65+
)
66+
assert added < 40, f'python_utils added {added} modules to sys.modules'
67+
68+
69+
def test_bare_import_does_not_pull_importlib_metadata() -> None:
70+
# __version__ is resolved lazily; bare import must not call
71+
# importlib.metadata.version() (which drags in email/zipfile/json/...).
72+
present = _modules_after_import('python_utils')
73+
assert 'importlib.metadata' not in present
74+
75+
76+
def test_version_resolves_correctly() -> None:
77+
import python_utils
78+
79+
assert isinstance(python_utils.__version__, str)
80+
assert python_utils.__version__ # non-empty
81+
82+
83+
PUBLIC_CALLABLES_TO_INTROSPECT = [
84+
('python_utils.time', 'timeout_generator'),
85+
('python_utils.time', 'aio_timeout_generator'),
86+
('python_utils.time', 'format_time'),
87+
('python_utils.converters', 'remap'),
88+
('python_utils.converters', 'to_int'),
89+
('python_utils.formatters', 'timesince'),
90+
('python_utils.import_', 'import_global'),
91+
]
92+
93+
94+
@pytest.mark.parametrize(('module', 'name'), PUBLIC_CALLABLES_TO_INTROSPECT)
95+
def test_get_type_hints_still_resolves(module: str, name: str) -> None:
96+
import importlib
97+
import typing
98+
99+
obj = getattr(importlib.import_module(module), name)
100+
# Must not raise NameError now that type imports moved/changed.
101+
typing.get_type_hints(obj)

python_utils/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,6 @@
6161
import importlib as _importlib
6262
import typing as _typing
6363

64-
from .__about__ import __version__
65-
6664
if _typing.TYPE_CHECKING: # pragma: no cover
6765
# Eager imports for type checkers only; the runtime equivalents are loaded
6866
# lazily by ``__getattr__`` below. Names appear in ``__all__`` so they are
@@ -79,6 +77,7 @@
7977
time,
8078
types,
8179
)
80+
from .__about__ import __version__
8281
from .aio import acount
8382
from .containers import CastedDict, LazyCastedDict, UniqueList
8483
from .converters import (
@@ -127,6 +126,7 @@
127126

128127
#: Exported name -> submodule it lives in.
129128
_NAME_TO_MODULE: dict[str, str] = {
129+
'__version__': '__about__',
130130
'acount': 'aio',
131131
'CastedDict': 'containers',
132132
'LazyCastedDict': 'containers',

python_utils/_aliases.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""Lightweight, stdlib-only type aliases shared across python_utils.
2+
3+
These live here (rather than in ``python_utils.types``) so internal modules can
4+
import them without dragging in ``typing_extensions``. ``python_utils.types``
5+
re-exports everything defined here, so the public names are unchanged.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import datetime
11+
import decimal
12+
from typing import Any
13+
14+
__all__ = [
15+
'DecimalNumber',
16+
'ExceptionType',
17+
'ExceptionsType',
18+
'Number',
19+
'OptionalScope',
20+
'Scope',
21+
'StringTypes',
22+
'delta_type',
23+
'timestamp_type',
24+
]
25+
26+
Scope = dict[str, Any]
27+
OptionalScope = Scope | None
28+
Number = int | float
29+
DecimalNumber = Number | decimal.Decimal
30+
ExceptionType = type[Exception]
31+
ExceptionsType = tuple[ExceptionType, ...] | ExceptionType
32+
StringTypes = str | bytes
33+
34+
delta_type = datetime.timedelta | int | float
35+
timestamp_type = (
36+
datetime.timedelta
37+
| datetime.date
38+
| datetime.datetime
39+
| str
40+
| int
41+
| float
42+
| None
43+
)

python_utils/containers.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@
5454
SliceableDeque([2, 3, 4])
5555
"""
5656

57+
from __future__ import annotations
58+
5759
# pyright: reportIncompatibleMethodOverride=false
5860
import abc
5961
import collections
@@ -534,11 +536,11 @@ class SliceableDeque(typing.Generic[T], collections.deque[T]):
534536
def __getitem__(self, index: typing.SupportsIndex) -> T: ...
535537

536538
@typing.overload
537-
def __getitem__(self, index: slice) -> 'SliceableDeque[T]': ...
539+
def __getitem__(self, index: slice) -> SliceableDeque[T]: ...
538540

539541
def __getitem__(
540542
self, index: typing.SupportsIndex | slice
541-
) -> T | 'SliceableDeque[T]':
543+
) -> T | SliceableDeque[T]:
542544
"""
543545
Return the item or slice at the given index.
544546

python_utils/converters.py

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,14 @@
2020
import re
2121
import typing
2222

23-
from . import types
23+
from python_utils._aliases import (
24+
DecimalNumber,
25+
ExceptionsType,
26+
Number,
27+
StringTypes,
28+
)
2429

25-
_TN = typing.TypeVar('_TN', bound=types.DecimalNumber)
30+
_TN = typing.TypeVar('_TN', bound=DecimalNumber)
2631

2732
_RegexpType: typing.TypeAlias = (
2833
re.Pattern[str] | str | typing.Literal[True] | None
@@ -32,7 +37,7 @@
3237
def to_int(
3338
input_: str | None = None,
3439
default: int = 0,
35-
exception: types.ExceptionsType = (ValueError, TypeError),
40+
exception: ExceptionsType = (ValueError, TypeError),
3641
regexp: _RegexpType = None,
3742
) -> int:
3843
r"""
@@ -120,9 +125,9 @@ def to_int(
120125
def to_float(
121126
input_: str,
122127
default: int = 0,
123-
exception: types.ExceptionsType = (ValueError, TypeError),
128+
exception: ExceptionsType = (ValueError, TypeError),
124129
regexp: _RegexpType = None,
125-
) -> types.Number:
130+
) -> Number:
126131
r"""
127132
Convert the given `input_` to an integer or return default.
128133
@@ -192,7 +197,7 @@ def to_float(
192197

193198

194199
def to_unicode(
195-
input_: types.StringTypes,
200+
input_: StringTypes,
196201
encoding: str = 'utf-8',
197202
errors: str = 'replace',
198203
) -> str:
@@ -222,7 +227,7 @@ def to_unicode(
222227

223228

224229
def to_str(
225-
input_: types.StringTypes,
230+
input_: StringTypes,
226231
encoding: str = 'utf-8',
227232
errors: str = 'replace',
228233
) -> bytes:
@@ -252,9 +257,9 @@ def to_str(
252257

253258

254259
def scale_1024(
255-
x: types.Number,
260+
x: Number,
256261
n_prefixes: int,
257-
) -> tuple[types.Number, types.Number]:
262+
) -> tuple[Number, Number]:
258263
"""Scale a number down to a suitable size, based on powers of 1024.
259264
260265
Returns the scaled number and the power of 1024 used.
@@ -414,7 +419,7 @@ def remap( # pyright: ignore[reportInconsistentOverload]
414419
passed parameters are a `float`, otherwise the returned type will be
415420
`int`.
416421
"""
417-
type_: type[types.DecimalNumber]
422+
type_: type[DecimalNumber]
418423
if (
419424
isinstance(value, decimal.Decimal)
420425
or isinstance(old_min, decimal.Decimal)

python_utils/formatters.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import datetime
1414
import typing
1515

16-
from python_utils import types
16+
from python_utils._aliases import OptionalScope
1717

1818

1919
def camel_to_underscore(name: str) -> str:
@@ -56,9 +56,9 @@ def camel_to_underscore(name: str) -> str:
5656

5757
def apply_recursive(
5858
function: collections.abc.Callable[[str], str],
59-
data: types.OptionalScope = None,
59+
data: OptionalScope = None,
6060
**kwargs: typing.Any,
61-
) -> types.OptionalScope:
61+
) -> OptionalScope:
6262
"""
6363
Apply a function to all keys in a scope recursively.
6464

0 commit comments

Comments
 (0)