diff --git a/ai/prompt-io/claude/20260702T155626Z_65bf9df5_prompt_io.md b/ai/prompt-io/claude/20260702T155626Z_65bf9df5_prompt_io.md new file mode 100644 index 00000000..5628aa03 --- /dev/null +++ b/ai/prompt-io/claude/20260702T155626Z_65bf9df5_prompt_io.md @@ -0,0 +1,83 @@ +--- +model: claude-fable-5 +service: claude +session: b6b42e23-0454-4d9b-be1c-0a89d65aed1b +timestamp: 2026-07-02T15:56:26Z +git_ref: 65bf9df5 +scope: code +substantive: true +raw_file: 20260702T155626Z_65bf9df5_prompt_io.raw.md +--- + +## Prompt + +From `ai/prompt-io/prompts/issue_470.md`: + +> attempt to resolve +> https://github.com/goodboy/tractor/issues/470 +> do it with /open-wkt, ensure you /run-tests on all +> changes + +(plus a hard pause-for-human-review deadline of +12:50PM EST.) + +Issue #470 asks to trim the ~0.42s `import tractor` +cost — which dominates per-actor spawn latency on the +`trio` backend — by lazy-importing heavy/optional deps +(`pdbp`, `stackscope`, `multiaddr`, + audit of +`colorlog`/`bidict`/`wrapt`/`setproctitle`). + +## Response summary + +Profiling showed the issue's dep-list only accounted +for ~20ms; the dominant cost (~244ms) was +`log.get_logger()`'s `get_caller_mod()` calling +`inspect.stack()` at module level in ~39 modules — +each call walks every stack frame (deep during nested +imports) and scans `sys.modules` per frame via +`inspect.getmodule()`. + +Changes, in impact order: + +1. `get_caller_mod()` -> `sys._getframe()` + + `f_globals['__name__']` `sys.modules` lookup + (~240ms saved). +2. Issue's lazy-import checklist: `bidict`, + `multiaddr`, `colorlog`, `wrapt` moved to + `TYPE_CHECKING`/function-local imports; + `platformdirs` function-local; `asyncio` + + `.to_asyncio` deferred out of the `devx.debug` + + `spawn._entry` eager paths (~15ms saved). +3. PEP 562 `__getattr__` on `tractor/__init__.py` + preserving public `tractor.to_asyncio` attr access. + +Results: `import tractor` 0.42s -> ~0.145s (~65%); +sequential `start_actor` latency 0.40-0.44s -> +~0.179s/actor. `pdbp` (needs `_repl.py` class-base +restructure) + `platformdirs` (needs +`UDSAddress.def_bindspace` protocol rework) documented +as follow-ups. + +## Files changed + +- `tractor/log.py` — `get_caller_mod()` perf fix + + lazy `colorlog` +- `tractor/__init__.py` — PEP 562 lazy `to_asyncio` +- `tractor/discovery/_addr.py` — `bidict` -> + `TYPE_CHECKING` +- `tractor/discovery/_multiaddr.py` — lazy `multiaddr` +- `tractor/ipc/_tcp.py`, `tractor/ipc/_uds.py` — + `Multiaddr` -> `TYPE_CHECKING` +- `tractor/runtime/_state.py` — lazy `platformdirs` +- `tractor/devx/_frame_stack.py` — lazy `pdbp` + + `wrapt` +- `tractor/devx/debug/_trace.py`, + `tractor/devx/debug/_tty_lock.py` — lazy `asyncio` + + `.to_asyncio` +- `tractor/spawn/_entry.py` — lazy + `run_as_asyncio_guest` + +## Human edits + +None yet — pending user review at the 12:50PM EST +pause gate (test-suite results reported in-session). diff --git a/ai/prompt-io/claude/20260702T155626Z_65bf9df5_prompt_io.raw.md b/ai/prompt-io/claude/20260702T155626Z_65bf9df5_prompt_io.raw.md new file mode 100644 index 00000000..e58ee408 --- /dev/null +++ b/ai/prompt-io/claude/20260702T155626Z_65bf9df5_prompt_io.raw.md @@ -0,0 +1,122 @@ +--- +model: claude-fable-5 +service: claude +timestamp: 2026-07-02T15:56:26Z +git_ref: 65bf9df5 +diff_cmd: git diff main..wkt/boot_latency_470 +--- + +# Raw AI output — gh #470 `import tractor` latency trim + +All generated code is committed on the +`wkt/boot_latency_470` branch; per diff-ref mode each +file's content is referenced via its diff instead of +copied verbatim. + +## Profiling findings (verbatim analysis output) + +Baseline: `import tractor` ~0.39-0.42s wall. + +`python -X importtime` + `cProfile` traced the cost NOT +primarily to third-party deps (the issue's hypothesis) +but to `tractor/log.py:get_logger()` calling +`get_caller_mod()` -> `inspect.stack()` at module level +in ~39 tractor modules: + +- `inspect.stack()` builds `FrameInfo` (incl. src-file + and line-context resolution) for EVERY frame on the + stack; during nested imports the stack is dozens of + importlib frames deep. +- each `FrameInfo` resolution calls + `inspect.getmodule()` which scans all of + `sys.modules` per frame (1.4M `ismodule()` calls in + one profiled import). +- aggregate: ~244ms of tractor-own module "self" time + vs ~20ms for ALL the issue-listed third-party deps + (`pdbp` ~10ms, `bidict` ~4.5ms, `multiaddr` ~3.5ms, + `wrapt`/`colorlog` ~1ms each); `trio` itself is + ~70-100ms and unavoidable. + +## Generated changes + +> `git diff main..wkt/boot_latency_470 -- tractor/log.py` + +`get_caller_mod()` rewritten from `inspect.stack()` + +`inspect.getmodule()` to `sys._getframe(frames_up)` + +`frame.f_globals['__name__']` -> `sys.modules` lookup +(O(1) vs O(stack x sys.modules)). Unused `inspect` +imports dropped; `FrameType` imported from `types`. +Also `colorlog` lazy-imported inside +`get_console_log()`. + +> `git diff main..wkt/boot_latency_470 -- tractor/discovery/_addr.py` + +`bidict` import moved under `TYPE_CHECKING` +(annotation-only use; `_address_types` is a plain dict +literal). + +> `git diff main..wkt/boot_latency_470 -- tractor/discovery/_multiaddr.py` + +`from __future__ import annotations` added; `multiaddr` +import moved under `TYPE_CHECKING` + function-local +imports in `mk_maddr()`/`parse_maddr()`. + +> `git diff main..wkt/boot_latency_470 -- tractor/ipc/_tcp.py tractor/ipc/_uds.py` + +`Multiaddr` imports moved under `TYPE_CHECKING` +(annotation-only in both transports). + +> `git diff main..wkt/boot_latency_470 -- tractor/runtime/_state.py` + +`platformdirs` lazy-imported inside `get_rt_dir()` +(NOTE: still imported eagerly via +`UDSAddress.def_bindspace` class-var eval; see +follow-ups). + +> `git diff main..wkt/boot_latency_470 -- tractor/devx/_frame_stack.py` + +`pdbp` + `wrapt` lazy-imported inside +`hide_runtime_frames()` / `api_frame()` respectively. + +> `git diff main..wkt/boot_latency_470 -- tractor/devx/debug/_trace.py tractor/devx/debug/_tty_lock.py` + +`asyncio` moved to `TYPE_CHECKING` + call-site local +imports (`asyncio.current_task()` sites); +`tractor.to_asyncio.run_trio_task_in_future` imports +moved into the infected-aio runtime branches. + +> `git diff main..wkt/boot_latency_470 -- tractor/spawn/_entry.py` + +`run_as_asyncio_guest` import moved into the +`infect_asyncio=True` branches of `_mp_main()` / +`_trio_main()`. + +> `git diff main..wkt/boot_latency_470 -- tractor/__init__.py` + +PEP 562 module `__getattr__` added so +`tractor.to_asyncio` attr-access still works (required +by `tests/test_child_manages_service_nursery.py` and +any downstream user) while keeping `asyncio` off the +eager import path. + +## Measured results (verbatim) + +- `import tractor`: 0.39-0.42s -> ~0.145s (~65% cut) +- `start_actor` spawn+boot+reg+cancel: ~0.40-0.44s -> + ~0.179s/actor (n=5 sequential, warm parent) +- post-change eager-module check: only `pdbp` + + `platformdirs` of the issue's list remain eager. + +## Known follow-ups (not implemented, deadline-bound) + +- `pdbp` (~10ms): still eager via + `devx/debug/_repl.py` class bases + (`class PdbREPL(pdbp.Pdb)`) + `_tty_lock.py` + module-level `@pdbp.hideframe`; needs `_repl` + restructure + PEP 562 in `devx.debug.__init__`. +- `platformdirs` (~1.5ms): eager via + `UDSAddress.def_bindspace: ClassVar = get_rt_dir()` + class-body call; needs `Address`-protocol rework of + `def_bindspace` to a lazy accessor. +- `stackscope` + `setproctitle`: already lazy/absent — + no change needed. diff --git a/examples/parallelism/we_are_processes.py b/examples/parallelism/we_are_processes.py index cce503b6..45485512 100644 --- a/examples/parallelism/we_are_processes.py +++ b/examples/parallelism/we_are_processes.py @@ -23,18 +23,10 @@ async def endpoint( await trio.sleep_forever() -async def spawn_and_open_ep( - an: tractor.ActorNursery, +async def open_ep( + ptl: tractor.Portal, i: int, ) -> None: - ''' - Spawn a subactor, start a remote `endpoint()`-task in it. - - ''' - ptl: tractor.Portal = await an.start_actor( - name=f'worker_{i}', - enable_modules=[__name__], - ) ctx: tractor.Context async with ptl.open_context(endpoint) as ( ctx, @@ -47,7 +39,33 @@ async def spawn_and_open_ep( await ctx.wait_for_result() -async def main(): +async def spawn_and_open_ep( + an: tractor.ActorNursery, + i: int, + maybe_ptl: tractor.Portal|None = None, +) -> None: + ''' + Spawn a subactor, start a remote `endpoint()`-task in it. + + ''' + if maybe_ptl is None: + maybe_ptl: tractor.Portal = await an.start_actor( + name=f'worker_{i}', + enable_modules=[__name__], + ) + await open_ep( + ptl=maybe_ptl, + i=i, + ) + + +async def main( + # spawn subs concurrently (in bg `trio.Task`s) so each + # actor's cold `import tractor` (~0.4s, see #470) overlaps + # instead of stacking; once forkserver (#463) lands, spawn + # is cheap enough to just loop sequentially. + spawn_subs_in_bg_tasks: bool = True, +): ''' Spawn a subactor-per-CPU then self-destruct the cluster. @@ -60,17 +78,21 @@ async def main(): # https://github.com/goodboy/tractor/pull/463 # start_method='main_thread_forkserver', ) as an, - # spawn subs concurrently (in bg `trio.Task`s) so each - # actor's cold `import tractor` (~0.4s, see #470) overlaps - # instead of stacking; once forkserver (#463) lands, spawn - # is cheap enough to just loop sequentially. trio.open_nursery() as tn, ): for i in range(cpu_count()): + + maybe_ptl: tractor.Portal|None = None + if not spawn_subs_in_bg_tasks: + maybe_ptl: tractor.Portal = await an.start_actor( + name=f'worker_{i}', + enable_modules=[__name__], + ) tn.start_soon( spawn_and_open_ep, an, i, + maybe_ptl, ) destruct_in: int = 2 print( diff --git a/tests/test_lazy_imports.py b/tests/test_lazy_imports.py new file mode 100644 index 00000000..75c238bf --- /dev/null +++ b/tests/test_lazy_imports.py @@ -0,0 +1,160 @@ +''' +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') diff --git a/tests/test_log_sys.py b/tests/test_log_sys.py index 1894bd1f..f4fcee70 100644 --- a/tests/test_log_sys.py +++ b/tests/test_log_sys.py @@ -2,9 +2,11 @@ `tractor.log`-wrapping unit tests. ''' +import importlib import logging from pathlib import Path import shutil +import sys from types import ModuleType import pytest @@ -165,6 +167,53 @@ def test_implicit_mod_name_applied_for_child( assert submod.log.logger in sub_logs +def test_implicit_mod_name_from_unregistered_namespace( + tmp_path: Path, +): + ''' + Preserve implicit logger naming for dynamic module namespaces. + + The fast `sys.modules` caller lookup cannot resolve `runpy`, + plugin-loader, or `exec()` namespaces that are not registered. + Compile a real package file under an unregistered module name so + the rare filename fallback must recover its imported package and + retain the same package-level logger name. + + ''' + pkg_name = 'dynamic_logger_pkg' + pkg_dir = tmp_path / pkg_name + pkg_dir.mkdir() + init_path = pkg_dir / '__init__.py' + init_path.write_text('') + mod_path = pkg_dir / 'plugin.py' + mod_path.write_text('') + + sys.path.insert(0, str(tmp_path)) + try: + importlib.import_module(pkg_name) + namespace = { + '__name__': f'{pkg_name}.unregistered', + '__package__': pkg_name, + 'tractor': tractor, + } + exec( + compile( + 'log = tractor.log.get_logger(' + f'pkg_name={pkg_name!r})', + str(mod_path), + 'exec', + ), + namespace, + ) + dynamic_log = namespace.get('log') + finally: + sys.path.remove(str(tmp_path)) + sys.modules.pop(pkg_name, None) + + assert dynamic_log is not None + assert dynamic_log.name == pkg_name + + def test_io_custom_level_registered(): ''' The `IO`(21) level (registered via `add_log_level()` at diff --git a/tractor/__init__.py b/tractor/__init__.py index 287ac940..13689c06 100644 --- a/tractor/__init__.py +++ b/tractor/__init__.py @@ -74,3 +74,39 @@ from .discovery._registry import ( Arbiter as Arbiter, ) # from . import hilevel as hilevel + + +__all__: tuple[str, ...] = tuple( + name + for name in globals() + if not name.startswith('_') +) + ( + 'to_asyncio', +) + + +def __dir__() -> list[str]: + return sorted(set(globals()) | set(__all__)) + + +def __getattr__(name: str): + ''' + PEP 562 lazy sub-module loading, presently only for + `.to_asyncio` which (transitively) imports `asyncio` + itself: a non-trivial multi-ms chunk of the eager + `import tractor` cost (gh #470) unneeded by + `trio`-only apps. + + Any `tractor.to_asyncio.` access (or a + `from tractor import to_asyncio`) still works, the + sub-mod is simply imported on first-access instead + of at pkg-import time. + + ''' + if name == 'to_asyncio': + from importlib import import_module + return import_module('.to_asyncio', __name__) + + raise AttributeError( + f'module {__name__!r} has no attribute {name!r}' + ) diff --git a/tractor/devx/_frame_stack.py b/tractor/devx/_frame_stack.py index 9c722def..cf067a5a 100644 --- a/tractor/devx/_frame_stack.py +++ b/tractor/devx/_frame_stack.py @@ -39,14 +39,15 @@ from typing import ( Type, ) -import pdbp +# NOTE, `pdbp` + `wrapt` are lazy-imported at their +# single use-sites below to keep them off the eager +# `import tractor` path (gh #470). from tractor.log import get_logger import trio from tractor.msg import ( pretty_struct, NamespacePath, ) -import wrapt log = get_logger() @@ -257,6 +258,7 @@ def api_frame( caller_frames_up: int = 1, ) -> Callable: + import wrapt # handle the decorator called WITHOUT () case, # i.e. just @api_frame, NOT @api_frame(extra=) @@ -320,6 +322,8 @@ def hide_runtime_frames() -> dict[FunctionType, CodeType]: as possible, particularly from inside a `PdbREPL`. ''' + import pdbp + # XXX HACKZONE XXX # hide exit stack frames on nurseries and cancel-scopes! # |_ so avoid seeing it when the `pdbp` REPL is first engaged from diff --git a/tractor/devx/debug/_trace.py b/tractor/devx/debug/_trace.py index 30c2b3c1..5bac9d6c 100644 --- a/tractor/devx/debug/_trace.py +++ b/tractor/devx/debug/_trace.py @@ -24,7 +24,6 @@ mult-process support within a single actor tree. ''' from __future__ import annotations -import asyncio import bdb from contextlib import ( AbstractContextManager, @@ -53,7 +52,6 @@ from trio import ( ) import tractor from tractor.log import get_logger -from tractor.to_asyncio import run_trio_task_in_future from tractor._context import Context from tractor.runtime import _state from tractor._exceptions import ( @@ -85,6 +83,10 @@ from ..pformat import ( ) if TYPE_CHECKING: + # NOTE, `asyncio` (and `.to_asyncio`) are + # lazy-imported at their use-sites to keep them off + # the eager `import tractor` path (gh #470). + import asyncio from trio.lowlevel import Task from threading import Thread from tractor.runtime._runtime import ( @@ -164,6 +166,7 @@ async def _pause( 'An `asyncio` task should not be calling this!?' ) from rte else: + import asyncio task = asyncio.current_task() if debug_func is not None: @@ -946,6 +949,7 @@ def pause_from_sync( asyncio_task: asyncio.Task|None = None if is_infected_aio: + import asyncio asyncio_task = asyncio.current_task() # TODO: we could also check for a non-`.to_thread` context @@ -1059,6 +1063,9 @@ def pause_from_sync( greenback: ModuleType = maybe_import_greenback() if greenback.has_portal(): + from tractor.to_asyncio import ( + run_trio_task_in_future, + ) DebugStatus.shield_sigint() fute: asyncio.Future = run_trio_task_in_future( partial( diff --git a/tractor/devx/debug/_tty_lock.py b/tractor/devx/debug/_tty_lock.py index 0016b61e..0b63798d 100644 --- a/tractor/devx/debug/_tty_lock.py +++ b/tractor/devx/debug/_tty_lock.py @@ -20,7 +20,6 @@ Root-actor TTY mutex-locking machinery. ''' from __future__ import annotations -import asyncio from contextlib import ( AbstractContextManager, asynccontextmanager as acm, @@ -52,7 +51,6 @@ from trio import ( TaskStatus, ) import tractor -from tractor.to_asyncio import run_trio_task_in_future from tractor.log import get_logger from tractor._context import Context from tractor.runtime import _state @@ -66,6 +64,10 @@ from tractor.runtime._state import ( ) if TYPE_CHECKING: + # NOTE, `asyncio` (and `.to_asyncio`) are + # lazy-imported at their use-sites to keep them off + # the eager `import tractor` path (gh #470). + import asyncio from trio.lowlevel import Task from threading import Thread from tractor.ipc import ( @@ -910,6 +912,9 @@ class DebugStatus: async def _set_repl_release(): repl_release.set() + from tractor.to_asyncio import ( + run_trio_task_in_future, + ) fute: asyncio.Future = run_trio_task_in_future( _set_repl_release ) diff --git a/tractor/discovery/_addr.py b/tractor/discovery/_addr.py index 2697c5c9..8d440bb3 100644 --- a/tractor/discovery/_addr.py +++ b/tractor/discovery/_addr.py @@ -16,13 +16,13 @@ from __future__ import annotations from uuid import uuid4 from typing import ( + Any, Protocol, ClassVar, Type, TYPE_CHECKING, ) -from bidict import bidict from trio import ( SocketListener, ) @@ -35,7 +35,11 @@ from ..ipc._tcp import TCPAddress from ..ipc._uds import UDSAddress if TYPE_CHECKING: + # ONLY type-annots, the eager import costs ~4.5ms + # of `import tractor` wall-time (gh #470). from ..runtime._runtime import Actor +else: + Actor = Any log = get_logger() @@ -170,7 +174,7 @@ class Address(Protocol): ... -_address_types: bidict[str, Type[Address]] = { +_address_types: dict[str, Type[Address]] = { 'tcp': TCPAddress, 'uds': UDSAddress } diff --git a/tractor/discovery/_multiaddr.py b/tractor/discovery/_multiaddr.py index 74076fd4..d3afdb28 100644 --- a/tractor/discovery/_multiaddr.py +++ b/tractor/discovery/_multiaddr.py @@ -24,14 +24,23 @@ Multiaddress support using the upstream `py-multiaddr` lib - https://github.com/multiformats/multiaddr/blob/master/protocols/unix.md ''' +from __future__ import annotations import ipaddress from pathlib import Path -from typing import TYPE_CHECKING - -from multiaddr import Multiaddr +from typing import ( + Any, + TYPE_CHECKING, +) if TYPE_CHECKING: + # NOTE, `multiaddr` is lazy-imported at first use + # (in the fns below) to keep it off the eager + # `import tractor` path (gh #470). + from multiaddr import Multiaddr from tractor.discovery._addr import Address +else: + Multiaddr = Any + Address = Any # map from tractor-internal `proto_key` identifiers # to the standard multiaddr protocol name strings. @@ -56,6 +65,8 @@ def mk_maddr( multiaddr-spec-compliant protocol path. ''' + from multiaddr import Multiaddr + proto_key: str = addr.proto_key maddr_proto: str|None = _tpt_proto_to_maddr.get(proto_key) if maddr_proto is None: @@ -98,6 +109,7 @@ def parse_maddr( ''' # lazy imports to avoid circular deps + from multiaddr import Multiaddr from tractor.ipc._tcp import TCPAddress from tractor.ipc._uds import UDSAddress diff --git a/tractor/ipc/_tcp.py b/tractor/ipc/_tcp.py index 293ae4be..421afb08 100644 --- a/tractor/ipc/_tcp.py +++ b/tractor/ipc/_tcp.py @@ -20,7 +20,9 @@ TCP implementation of tractor.ipc._transport.MsgTransport protocol from __future__ import annotations import ipaddress from typing import ( + Any, ClassVar, + TYPE_CHECKING, ) # from contextlib import ( # asynccontextmanager as acm, @@ -33,7 +35,6 @@ from trio import ( open_tcp_listeners, ) -from multiaddr import Multiaddr from tractor.msg import MsgCodec from tractor.log import get_logger from tractor.discovery._multiaddr import mk_maddr @@ -42,6 +43,13 @@ from tractor.ipc._transport import ( MsgpackTransport, ) +if TYPE_CHECKING: + # ONLY type-annots, the eager import costs + # `import tractor` wall-time (gh #470). + from multiaddr import Multiaddr +else: + Multiaddr = Any + log = get_logger() diff --git a/tractor/ipc/_uds.py b/tractor/ipc/_uds.py index d1e9d2f6..6dae10ec 100644 --- a/tractor/ipc/_uds.py +++ b/tractor/ipc/_uds.py @@ -32,6 +32,7 @@ from socket import ( ) import struct from typing import ( + Any, Type, TYPE_CHECKING, ClassVar, @@ -49,7 +50,6 @@ from trio._highlevel_open_unix_stream import ( has_unix, ) -from multiaddr import Multiaddr from tractor.msg import MsgCodec from tractor.log import get_logger from tractor.discovery._multiaddr import mk_maddr @@ -63,7 +63,13 @@ from tractor.runtime._state import ( ) if TYPE_CHECKING: + # ONLY type-annots, the eager import costs + # `import tractor` wall-time (gh #470). + from multiaddr import Multiaddr from tractor.runtime._runtime import Actor +else: + Multiaddr = Any + Actor = Any # Platform-specific credential passing constants diff --git a/tractor/log.py b/tractor/log.py index a87dd7c1..7cab2b99 100644 --- a/tractor/log.py +++ b/tractor/log.py @@ -26,11 +26,6 @@ built on `tractor`. ''' from collections.abc import Mapping from functools import partial -from inspect import ( - FrameInfo, - getmodule, - stack, -) import sys import logging from logging import ( @@ -38,10 +33,16 @@ from logging import ( Logger, StreamHandler, ) -from types import ModuleType +from types import ( + FrameType, + ModuleType, +) import warnings -import colorlog # type: ignore +# NOTE, `colorlog` is lazy-imported in +# `get_console_log()` to keep it off the eager +# `import tractor` path (gh #470). +# # ?TODO, some other (modern) alt libs? # import coloredlogs # import colored_traceback.auto # ?TODO, need better config? @@ -436,16 +437,40 @@ def get_logger( pkg_name: str = _root_name def get_caller_mod( - frames_up:int = 2 - ): + frames_up: int = 2, + ) -> ModuleType|None: ''' - Attempt to get the module which called `tractor.get_logger()`. + Attempt to get the module which called + `tractor.get_logger()`. + + Resolve the caller's frame with `sys._getframe()` and + map its `__name__` through `sys.modules`; `inspect.stack()` + (the previous impl) builds src-file info for EVERY frame + on the stack, scanning all of `sys.modules` per frame via + `inspect.getmodule()`, which made module-level + `get_logger()` calls dominate `import tractor` time + (see gh #470). ''' - callstack: list[FrameInfo] = stack() - caller_fi: FrameInfo = callstack[frames_up] - caller_mod: ModuleType = getmodule(caller_fi.frame) - return caller_mod + try: + caller_frame: FrameType = sys._getframe(frames_up) + except ValueError: + return None + + mod_name: str|None = caller_frame.f_globals.get( + '__name__', + ) + if mod_name is None: + return None + + if caller_mod := sys.modules.get(mod_name): + return caller_mod + + # Preserve caller discovery for `runpy`, plugin loaders, + # and `exec()` namespaces not registered in `sys.modules`. + # Import `inspect` only on this rare fallback path. + from inspect import getmodule + return getmodule(caller_frame) # --- Auto--naming-CASE --- # ------------------------- @@ -780,6 +805,10 @@ def get_console_log( None, ) ): + # lazy-imported to keep it off the eager + # `import tractor` path (gh #470). + import colorlog # type: ignore + fmt: str = LOG_FORMAT # always apply our format? handler = StreamHandler() formatter = colorlog.ColoredFormatter( diff --git a/tractor/runtime/_state.py b/tractor/runtime/_state.py index 0d9a4435..0020fd8d 100644 --- a/tractor/runtime/_state.py +++ b/tractor/runtime/_state.py @@ -30,7 +30,6 @@ from typing import ( TYPE_CHECKING, ) -import platformdirs from trio.lowlevel import current_task from msgspec import ( @@ -332,6 +331,10 @@ def get_rt_dir( the lovely `platformdirs` lib. ''' + # lazy-imported to keep it off the eager + # `import tractor` path (gh #470). + import platformdirs + rt_dir: Path = Path( platformdirs.user_runtime_dir( appname=appname, diff --git a/tractor/spawn/_entry.py b/tractor/spawn/_entry.py index 3143d9c4..83f1d3ab 100644 --- a/tractor/spawn/_entry.py +++ b/tractor/spawn/_entry.py @@ -38,7 +38,11 @@ from ..devx import ( pformat, ) # from ..msg import pretty_struct -from ..to_asyncio import run_as_asyncio_guest +# +# NOTE, `.to_asyncio` (and thus `asyncio` itself) is +# lazy-imported at the `infect_asyncio=True` use-sites +# below to keep it off the eager `import tractor` path +# (gh #470). from ..discovery._addr import UnwrappedAddress from ..runtime._runtime import ( async_main, @@ -96,6 +100,7 @@ def _mp_main( ) try: if infect_asyncio: + from ..to_asyncio import run_as_asyncio_guest actor._infected_aio = True run_as_asyncio_guest(trio_main) else: @@ -156,6 +161,7 @@ def _trio_main( ) try: if infect_asyncio: + from ..to_asyncio import run_as_asyncio_guest actor._infected_aio = True run_as_asyncio_guest(trio_main) else: