tractor/tests/test_lazy_imports.py

161 lines
4.3 KiB
Python

'''
Regression tests for the cold package import surface.
'''
import json
import os
from statistics import median
import subprocess
import sys
from typing import (
Any,
get_type_hints,
)
from tractor.discovery import (
_addr,
_multiaddr,
)
from tractor.ipc import (
_tcp,
_uds,
)
def run_cold_import(code: str) -> dict[str, object]:
result = subprocess.run(
[
sys.executable,
'-c',
code,
],
check=True,
capture_output=True,
text=True,
)
return json.loads(result.stdout)
def test_lazy_to_asyncio_package_api():
'''
Keep the public lazy submodule discoverable without eagerly
importing it.
Before the lazy conversion, package import side effects exposed
`to_asyncio` to `dir()` and wildcard imports. Exercise those APIs
in cold interpreters so this test proves normal `import tractor`
leaves `asyncio` unloaded, while discovery and wildcard access
still advertise and resolve the public submodule.
'''
cold = run_cold_import(
'import json, sys, tractor; '
'print(json.dumps({'
'"advertised": "to_asyncio" in dir(tractor), '
'"asyncio_loaded": "asyncio" in sys.modules}))'
)
assert cold == {
'advertised': True,
'asyncio_loaded': False,
}
wildcard = run_cold_import(
'import json; '
'from tractor import *; '
'print(json.dumps({'
'"module": to_asyncio.__name__}))'
)
assert wildcard == {
'module': 'tractor.to_asyncio',
}
def test_cold_import_budget():
'''
Keep cold package import below the pre-optimization regression.
The original `inspect.stack()` caller lookup made a fresh
`import tractor` take about 0.42s and dominate actor startup.
Run seven independent interpreters and gate their median at a
deliberately broad 0.35s: over twice the measured ~0.145s
baseline, but low enough to catch restoration of that hot path.
Taking the median absorbs process-start and shared-runner noise.
The child measures only its import, rather than parent-side
process creation. `TRACTOR_IMPORT_BUDGET_S` provides an explicit,
reviewable override for platforms that establish a different
baseline instead of silently weakening the project default.
Each child also reports the modules whose eager loading this PR
intentionally removes, proving a timing pass cannot hide a
dependency-import regression.
'''
budget_s = float(
os.environ.get(
'TRACTOR_IMPORT_BUDGET_S',
'0.35',
)
)
optional_mods = (
'asyncio',
'bidict',
'colorlog',
'multiaddr',
'wrapt',
)
code = (
'import json, sys, time; '
'started = time.perf_counter(); '
'import tractor; '
'elapsed = time.perf_counter() - started; '
f'optional = {optional_mods!r}; '
'print(json.dumps({'
'"elapsed": elapsed, '
'"loaded": [name for name in optional '
'if name in sys.modules]}))'
)
samples = [
run_cold_import(code)
for _ in range(7)
]
elapsed = [
float(sample['elapsed'])
for sample in samples
]
loaded = {
name
for sample in samples
for name in sample['loaded']
}
assert not loaded
assert median(elapsed) < budget_s, (
f'cold import median exceeded {budget_s:.3f}s budget: '
f'{elapsed!r}'
)
def test_lazy_annotation_names_resolve():
'''
Resolve annotations without importing optional dependencies.
Moving annotation-only third-party names under `TYPE_CHECKING`
left their runtime globals undefined, causing
`typing.get_type_hints()` to raise `NameError`. Resolve every
affected API and prove the lazy aliases retain import-free runtime
introspection.
'''
assert get_type_hints(_multiaddr.mk_maddr)['return'] is Any
assert get_type_hints(_tcp.MsgpackTCPStream.maddr.fget)[
'return'
] is Any
assert get_type_hints(_uds.MsgpackUDSStream.maddr.fget)[
'return'
] == Any|str
assert get_type_hints(_addr.Address.get_random)[
'current_actor'
] is Any
assert _addr.__annotations__['_address_types'].startswith('dict')