Compare commits

...

3 Commits

Author SHA1 Message Date
Gud Boi 66ac7863b5 Advertise the lazy `to_asyncio` API
Preserve the existing public wildcard surface while adding the lazy
`to_asyncio` submodule to `__all__` and `dir(tractor)`.

Exercise both APIs in cold interpreters and verify normal package
import still leaves `asyncio` unloaded.

Review: PR #478 (goodboy)
https://github.com/goodboy/tractor/pull/478#pullrequestreview-4922213201

(this patch was generated in some part by `opencode` using
`gpt-5.6-sol` (`openai`))
2026-08-12 20:55:41 -04:00
Gud Boi 089e158da9 Retain dynamic logger caller discovery
Keep the `sys.modules` lookup as the normal fast path, then use
`inspect.getmodule()` for unregistered `runpy`, plugin, and `exec()`
namespaces.

Cover an unregistered module name backed by a real package file and
verify implicit logger naming still resolves to that package.

Review: PR #478 (goodboy)
https://github.com/goodboy/tractor/pull/478#pullrequestreview-4922213201

(this patch was generated in some part by `opencode` using
`gpt-5.6-sol` (`openai`))
2026-08-12 20:52:47 -04:00
Gud Boi 7987f6b8d7 Keep lazy annotations runtime-resolvable
Provide import-free runtime aliases for annotation-only actor and
multiaddr types so `typing.get_type_hints()` remains usable without
eagerly loading optional dependencies.

Correct `_address_types` to its actual `dict` shape and cover the
affected discovery and transport APIs.

Caught-during: review remediation
Found-via: `/run-tests` test_lazy_annotation_names_resolve

Review: PR #478 (goodboy)
https://github.com/goodboy/tractor/pull/478#pullrequestreview-4922213201

(this patch was generated in some part by `opencode` using
`gpt-5.6-sol` (`openai`))
2026-08-12 20:52:15 -04:00
8 changed files with 180 additions and 4 deletions

View File

@ -0,0 +1,92 @@
'''
Regression tests for the cold package import surface.
'''
import json
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_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')

View File

@ -2,9 +2,11 @@
`tractor.log`-wrapping unit tests. `tractor.log`-wrapping unit tests.
''' '''
import importlib
import logging import logging
from pathlib import Path from pathlib import Path
import shutil import shutil
import sys
from types import ModuleType from types import ModuleType
import pytest import pytest
@ -165,6 +167,53 @@ def test_implicit_mod_name_applied_for_child(
assert submod.log.logger in sub_logs 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(): def test_io_custom_level_registered():
''' '''
The `IO`(21) level (registered via `add_log_level()` at The `IO`(21) level (registered via `add_log_level()` at

View File

@ -76,6 +76,19 @@ from .discovery._registry import (
# from . import hilevel as hilevel # 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): def __getattr__(name: str):
''' '''
PEP 562 lazy sub-module loading, presently only for PEP 562 lazy sub-module loading, presently only for

View File

@ -16,6 +16,7 @@
from __future__ import annotations from __future__ import annotations
from uuid import uuid4 from uuid import uuid4
from typing import ( from typing import (
Any,
Protocol, Protocol,
ClassVar, ClassVar,
Type, Type,
@ -36,8 +37,9 @@ from ..ipc._uds import UDSAddress
if TYPE_CHECKING: if TYPE_CHECKING:
# ONLY type-annots, the eager import costs ~4.5ms # ONLY type-annots, the eager import costs ~4.5ms
# of `import tractor` wall-time (gh #470). # of `import tractor` wall-time (gh #470).
from bidict import bidict
from ..runtime._runtime import Actor from ..runtime._runtime import Actor
else:
Actor = Any
log = get_logger() log = get_logger()
@ -172,7 +174,7 @@ class Address(Protocol):
... ...
_address_types: bidict[str, Type[Address]] = { _address_types: dict[str, Type[Address]] = {
'tcp': TCPAddress, 'tcp': TCPAddress,
'uds': UDSAddress 'uds': UDSAddress
} }

View File

@ -27,7 +27,10 @@ Multiaddress support using the upstream `py-multiaddr` lib
from __future__ import annotations from __future__ import annotations
import ipaddress import ipaddress
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import (
Any,
TYPE_CHECKING,
)
if TYPE_CHECKING: if TYPE_CHECKING:
# NOTE, `multiaddr` is lazy-imported at first use # NOTE, `multiaddr` is lazy-imported at first use
@ -35,6 +38,9 @@ if TYPE_CHECKING:
# `import tractor` path (gh #470). # `import tractor` path (gh #470).
from multiaddr import Multiaddr from multiaddr import Multiaddr
from tractor.discovery._addr import Address from tractor.discovery._addr import Address
else:
Multiaddr = Any
Address = Any
# map from tractor-internal `proto_key` identifiers # map from tractor-internal `proto_key` identifiers
# to the standard multiaddr protocol name strings. # to the standard multiaddr protocol name strings.

View File

@ -20,6 +20,7 @@ TCP implementation of tractor.ipc._transport.MsgTransport protocol
from __future__ import annotations from __future__ import annotations
import ipaddress import ipaddress
from typing import ( from typing import (
Any,
ClassVar, ClassVar,
TYPE_CHECKING, TYPE_CHECKING,
) )
@ -46,6 +47,8 @@ if TYPE_CHECKING:
# ONLY type-annots, the eager import costs # ONLY type-annots, the eager import costs
# `import tractor` wall-time (gh #470). # `import tractor` wall-time (gh #470).
from multiaddr import Multiaddr from multiaddr import Multiaddr
else:
Multiaddr = Any
log = get_logger() log = get_logger()

View File

@ -32,6 +32,7 @@ from socket import (
) )
import struct import struct
from typing import ( from typing import (
Any,
Type, Type,
TYPE_CHECKING, TYPE_CHECKING,
ClassVar, ClassVar,
@ -66,6 +67,9 @@ if TYPE_CHECKING:
# `import tractor` wall-time (gh #470). # `import tractor` wall-time (gh #470).
from multiaddr import Multiaddr from multiaddr import Multiaddr
from tractor.runtime._runtime import Actor from tractor.runtime._runtime import Actor
else:
Multiaddr = Any
Actor = Any
# Platform-specific credential passing constants # Platform-specific credential passing constants

View File

@ -463,7 +463,14 @@ def get_logger(
if mod_name is None: if mod_name is None:
return None return None
return sys.modules.get(mod_name) 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 --- # --- Auto--naming-CASE ---
# ------------------------- # -------------------------