diff --git a/ai/prompt-io/opencode/20260830T025201Z_b1f6ade8_prompt_io.md b/ai/prompt-io/opencode/20260830T025201Z_b1f6ade8_prompt_io.md new file mode 100644 index 00000000..1f2488f4 --- /dev/null +++ b/ai/prompt-io/opencode/20260830T025201Z_b1f6ade8_prompt_io.md @@ -0,0 +1,47 @@ +--- +model: gpt-5.6-sol +service: opencode +session: tractor-net-api-extraction +timestamp: 2026-08-30T02:52:01Z +git_ref: b1f6ade8 +scope: code +substantive: true +raw_file: 20260830T025201Z_b1f6ade8_prompt_io.raw.md +--- + +## Prompt + +After rebasing the WireGuard Layer C stack, the human questioned the +placement of bindspace APIs under `tractor.discovery`, considered new +public package names, selected `tractor.net`, and authorized the +implementation. + +## Response summary + +Extracted bindspace and tunnel implementations into a lazy +`tractor.net` package. Moved public multiaddr, bindspace, tunnel, and +WireGuard symbols out of `tractor.discovery`, updated internal imports, +tests, examples, and documentation, and added cold-import regressions +that keep optional networking dependencies off the root import path. +Current-netns attachment pins `/proc/thread-self/ns/net` so calls from +non-leader threads retain the caller's namespace rather than the process +leader's. + +## Files changed + +- `tractor/net/` - lazy public network API and implementation modules. +- `tractor/discovery/` - retain actor discovery and internal address parsing. +- `tractor/__init__.py` - expose `tractor.net` lazily. +- `tests/net/` - network declaration and lifecycle coverage. +- `tests/test_lazy_imports.py` - enforce the lazy package boundary. +- `examples/multihost/wg_lan/` - use the public network package. +- `docs/` - document network APIs separately from actor discovery. + +## Human edits + +The human rejected `tractor.discovery` as the long-term public home, +considered tunnel- and namespace-specific alternatives, and selected +the broader `tractor.net` boundary because bindspaces may include plain +netns, WireGuard, VRF, veth, and later network resources. The agent +applied the resulting source changes; no direct manual edits were +observed. diff --git a/ai/prompt-io/opencode/20260830T025201Z_b1f6ade8_prompt_io.raw.md b/ai/prompt-io/opencode/20260830T025201Z_b1f6ade8_prompt_io.raw.md new file mode 100644 index 00000000..cf4c01aa --- /dev/null +++ b/ai/prompt-io/opencode/20260830T025201Z_b1f6ade8_prompt_io.raw.md @@ -0,0 +1,29 @@ +--- +model: gpt-5.6-sol +service: opencode +timestamp: 2026-08-30T02:52:01Z +git_ref: b1f6ade8 +diff_cmd: git diff HEAD~1..HEAD +--- + +# Raw output - extract public network APIs + +The human questioned whether bindspace and WireGuard lifecycle APIs +belonged under actor discovery, selected the proposed `tractor.net` +boundary, and authorized implementation. + +## Generated code + +> `git diff HEAD~1..HEAD -- tractor/net tractor/discovery tractor/__init__.py` + +Move bindspace and tunnel implementations into a lazy public network +package. Keep actor discovery focused on registry and lookup behavior, +while exposing bindspace, tunnel, WireGuard, and multiaddr declarations +through `tractor.net` without loading optional networking dependencies +during `import tractor`. + +> `git diff HEAD~1..HEAD -- tests/net tests/test_lazy_imports.py tests/ipc examples/multihost/wg_lan docs` + +Move network-focused tests to `tests/net`, update internal and public +imports, verify lazy symbol resolution and removed discovery exports, +and document the new package boundary. diff --git a/ai/prompt-io/prompts/multiaddr_declare_eps.md b/ai/prompt-io/prompts/multiaddr_declare_eps.md index dcd07cb8..ae1b6287 100644 --- a/ai/prompt-io/prompts/multiaddr_declare_eps.md +++ b/ai/prompt-io/prompts/multiaddr_declare_eps.md @@ -47,7 +47,7 @@ Here is a small example from piker, We should take whatever common API is needed to support this and distill it into a ```python -tractor.discovery.parse_endpoints( +tractor.net.parse_endpoints( ) -> dict[ str, list[Address] diff --git a/ai/tpt-backends/03_wg_tunnel_bindspace.md b/ai/tpt-backends/03_wg_tunnel_bindspace.md index be0b9fba..930bdf3e 100644 --- a/ai/tpt-backends/03_wg_tunnel_bindspace.md +++ b/ai/tpt-backends/03_wg_tunnel_bindspace.md @@ -209,7 +209,7 @@ Observed protocol-name lists, for writing the `match`: ### 3.3 pure codecs + explicit verification Port #482 §2's pure helpers into -`tractor/discovery/_tunnel.py`, keeping the impure probe cleanly +`tractor/net/_tunnel.py`, keeping the impure probe cleanly separated until layer B: ```python diff --git a/docs/api/index.rst b/docs/api/index.rst index 5782a8fe..e152c63a 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -53,6 +53,7 @@ Most-used names at a glance: core context discovery + net errors msg trionics diff --git a/docs/api/ipc.rst b/docs/api/ipc.rst index a9a36f80..8212b065 100644 --- a/docs/api/ipc.rst +++ b/docs/api/ipc.rst @@ -84,6 +84,7 @@ already distributed-system aware. .. seealso:: :doc:`/explain/architecture` for the transport/server - internals, :doc:`/api/discovery` for how channel addresses - get registered and found, and :doc:`/api/msg` for the codec - layer every channel speaks. + internals, :doc:`/api/net` for network declarations, + :doc:`/api/discovery` for how channel addresses get registered + and found, and :doc:`/api/msg` for the codec layer every channel + speaks. diff --git a/docs/api/net.rst b/docs/api/net.rst new file mode 100644 index 00000000..04e7faff --- /dev/null +++ b/docs/api/net.rst @@ -0,0 +1,62 @@ +Network declarations and lifecycles +=================================== + +``tractor.net`` provides address composition, bindspace declarations, +and tunnel configuration. The package is lazy: importing +``tractor`` or ``tractor.net`` does not load multiaddr, WireGuard, or +pyroute2 implementation modules until a public symbol is used. + +Multiaddr helpers +----------------- + +.. currentmodule:: tractor.net + +.. autofunction:: mk_maddr + +.. autofunction:: parse_maddr + +.. autofunction:: parse_endpoints + +Bindspaces +---------- + +.. autoclass:: BindspaceSpec + +.. autoclass:: BindspaceRef + +.. autoclass:: Bindspace + +.. autofunction:: attach_netns + +.. autofunction:: open_netns + +.. autofunction:: open_bindspace + +Tunnels and WireGuard +--------------------- + +.. autoclass:: TunnelledAddress + +.. autoclass:: WGTunnelSpec + +.. autoclass:: WGInterfaceConfig + +.. autoclass:: WGPeerConfig + +.. autofunction:: parse_wg_maddr + +.. autofunction:: mk_wg_maddr + +.. autofunction:: strip_tunnels + +.. autofunction:: tunnels_of + +.. autofunction:: open_wg_iface + +.. autofunction:: open_wg_bindspace + +.. autofunction:: read_wg_pubkey + +.. autofunction:: read_wg_peers + +.. autofunction:: verify_wg_peer diff --git a/docs/guide/discovery.rst b/docs/guide/discovery.rst index a14967e0..cd025f9d 100644 --- a/docs/guide/discovery.rst +++ b/docs/guide/discovery.rst @@ -264,7 +264,7 @@ terminology is retired: it's *registrar*/*registry* everywhere now substitute "registrar" and you're up to date. .. note:: - Multihoming nerds: ``tractor.discovery`` also ships + Multihoming nerds: ``tractor.net`` ships libp2p-style *multiaddr* helpers — ``mk_maddr()`` and ``parse_maddr()`` — for describing transport endpoints as structured strings. diff --git a/examples/multihost/wg_lan/README.md b/examples/multihost/wg_lan/README.md index 2f57c368..222e86b7 100644 --- a/examples/multihost/wg_lan/README.md +++ b/examples/multihost/wg_lan/README.md @@ -116,7 +116,7 @@ ping -c1 10.0.11.1 # from B ```bash python -c " - from tractor.discovery import mb_pubkey + from tractor.net import mb_pubkey key = open('wg_pub.key').read().strip() print(mb_pubkey(key)) " @@ -166,7 +166,7 @@ Four corrections, all from ## next -Layer A's `TunnelledAddress` and native maddr parser plus Layer B's -explicit pyroute2 verification now live in `tractor.discovery`. Next, -add `open_bindspace()` `@acm`s which create/tear down the iface and -netns. +The `TunnelledAddress`, native maddr parser, bindspace lifecycle, and +explicit pyroute2 verification APIs live in `tractor.net`. Root actor +bindspace integration remains future work; callers compose these +lifecycles explicitly for now. diff --git a/examples/multihost/wg_lan/host_a_srv.py b/examples/multihost/wg_lan/host_a_srv.py index cbde4ab8..ffbff81d 100644 --- a/examples/multihost/wg_lan/host_a_srv.py +++ b/examples/multihost/wg_lan/host_a_srv.py @@ -10,7 +10,7 @@ from __future__ import annotations import tractor import trio -from tractor.discovery import ( +from tractor.net import ( TunnelledAddress, mk_maddr, parse_wg_maddr, diff --git a/examples/multihost/wg_lan/host_b_client.py b/examples/multihost/wg_lan/host_b_client.py index 98b867e8..cdb3688a 100644 --- a/examples/multihost/wg_lan/host_b_client.py +++ b/examples/multihost/wg_lan/host_b_client.py @@ -8,7 +8,7 @@ from __future__ import annotations import tractor import trio -from tractor.discovery import ( +from tractor.net import ( TunnelledAddress, parse_wg_maddr, verify_wg_peer, diff --git a/tests/discovery/test_registrar.py b/tests/discovery/test_registrar.py index e384c22b..8fa9e730 100644 --- a/tests/discovery/test_registrar.py +++ b/tests/discovery/test_registrar.py @@ -18,7 +18,7 @@ from tractor.devx import dump_on_hang from tractor.trionics import collapse_eg from tractor._testing import tractor_test from tractor.discovery._addr import wrap_address -from tractor.discovery._multiaddr import mk_maddr +from tractor.net import mk_maddr import trio diff --git a/tests/discovery/test_tpt_bind_addrs.py b/tests/discovery/test_tpt_bind_addrs.py index 1de45a36..64e57942 100644 --- a/tests/discovery/test_tpt_bind_addrs.py +++ b/tests/discovery/test_tpt_bind_addrs.py @@ -24,7 +24,7 @@ from tractor.discovery import _api from tractor.discovery._addr import ( wrap_address, ) -from tractor.discovery._multiaddr import mk_maddr +from tractor.net import mk_maddr from tractor.ipc import _connect_chan from tractor._testing.addr import get_rando_addr diff --git a/tests/ipc/test_channel_tunnel_boundary.py b/tests/ipc/test_channel_tunnel_boundary.py index 0c04b32d..ccc92205 100644 --- a/tests/ipc/test_channel_tunnel_boundary.py +++ b/tests/ipc/test_channel_tunnel_boundary.py @@ -7,7 +7,7 @@ from __future__ import annotations import pytest import trio -from tractor.discovery import ( +from tractor.net import ( TunnelledAddress, WGTunnelSpec, tunnels_of, diff --git a/tests/ipc/test_server_tunnel_boundary.py b/tests/ipc/test_server_tunnel_boundary.py index f54e8dca..b98cc233 100644 --- a/tests/ipc/test_server_tunnel_boundary.py +++ b/tests/ipc/test_server_tunnel_boundary.py @@ -6,7 +6,7 @@ from __future__ import annotations import trio -from tractor.discovery import ( +from tractor.net import ( BindspaceRef, TunnelledAddress, WGTunnelSpec, diff --git a/tests/net/__init__.py b/tests/net/__init__.py new file mode 100644 index 00000000..fabca2e8 --- /dev/null +++ b/tests/net/__init__.py @@ -0,0 +1 @@ +'''Network declaration and lifecycle tests.''' diff --git a/tests/discovery/test_bindspace.py b/tests/net/test_bindspace.py similarity index 96% rename from tests/discovery/test_bindspace.py rename to tests/net/test_bindspace.py index be03ba75..c6423dd8 100644 --- a/tests/discovery/test_bindspace.py +++ b/tests/net/test_bindspace.py @@ -13,7 +13,7 @@ import msgspec import pytest import trio -from tractor.discovery import ( +from tractor.net import ( Bindspace, BindspaceOwnership, BindspaceRef, @@ -23,7 +23,7 @@ from tractor.discovery import ( open_bindspace, open_netns, ) -from tractor.discovery import _bindspace +from tractor.net import _bindspace from tractor.msg import ProcessLocal @@ -261,10 +261,11 @@ def test_open_bindspace_attaches_current_netns() -> None: ''' The unnamed spec must borrow and pin the caller's current netns. - Open `/proc/self/ns/net`, prove the yielded bindspace records its - stable inode and borrowed ownership, then exit the context and - prove the exact descriptor was closed without altering the - namespace itself. + Opening `/proc/self/ns/net` could pin the thread-group leader's + namespace when this context runs from another thread. Prove the + implementation selects `/proc/thread-self/ns/net`, records the + calling thread's stable inode and borrowed ownership, then closes + the exact descriptor without altering the namespace itself. ''' async def main() -> int: @@ -276,6 +277,9 @@ def test_open_bindspace_attaches_current_netns() -> None: kind='netns', ) assert spec.key is CURRENT_NETNS + assert _bindspace._THREAD_NETNS == Path( + '/proc/thread-self/ns/net' + ) async with open_bindspace(spec) as bindspace: namespace_fd: int|None = bindspace.namespace_fd assert namespace_fd is not None diff --git a/tests/discovery/test_multiaddr.py b/tests/net/test_multiaddr.py similarity index 99% rename from tests/discovery/test_multiaddr.py rename to tests/net/test_multiaddr.py index d9029d33..a9b16b6c 100644 --- a/tests/discovery/test_multiaddr.py +++ b/tests/net/test_multiaddr.py @@ -1,7 +1,6 @@ ''' Multiaddr construction, parsing, and round-trip tests for -`tractor.discovery._multiaddr.mk_maddr()` and -`tractor.discovery._multiaddr.parse_maddr()`. +`tractor.net.mk_maddr()` and `tractor.net.parse_maddr()`. ''' from pathlib import Path @@ -10,20 +9,20 @@ from types import SimpleNamespace import pytest from multiaddr import Multiaddr -from tractor.discovery import ( +from tractor.net import ( TunnelledAddress, WGTunnelSpec, mb_pubkey, mk_wg_maddr, + mk_maddr, + parse_endpoints, + parse_maddr, parse_wg_maddr, tunnels_of, ) from tractor.ipc._tcp import TCPAddress from tractor.ipc._uds import UDSAddress from tractor.discovery._multiaddr import ( - mk_maddr, - parse_maddr, - parse_endpoints, _tpt_proto_to_maddr, _maddr_to_tpt_proto, ) diff --git a/tests/discovery/test_tunnelled_addr.py b/tests/net/test_tunnelled_addr.py similarity index 99% rename from tests/discovery/test_tunnelled_addr.py rename to tests/net/test_tunnelled_addr.py index 38e32121..ed2408da 100644 --- a/tests/discovery/test_tunnelled_addr.py +++ b/tests/net/test_tunnelled_addr.py @@ -14,7 +14,7 @@ from __future__ import annotations import msgspec import pytest -from tractor.discovery import ( +from tractor.net import ( BindspaceRef, TunnelledAddress, WGTunnelSpec, diff --git a/tests/discovery/test_wg_config.py b/tests/net/test_wg_config.py similarity index 99% rename from tests/discovery/test_wg_config.py rename to tests/net/test_wg_config.py index fa712b92..fab3177c 100644 --- a/tests/discovery/test_wg_config.py +++ b/tests/net/test_wg_config.py @@ -7,7 +7,7 @@ from __future__ import annotations import msgspec import pytest -from tractor.discovery import ( +from tractor.net import ( WGInterfaceConfig, WGPeerConfig, ) diff --git a/tests/discovery/test_wg_iface_lifecycle.py b/tests/net/test_wg_iface_lifecycle.py similarity index 99% rename from tests/discovery/test_wg_iface_lifecycle.py rename to tests/net/test_wg_iface_lifecycle.py index c1e006ba..e7d018b8 100644 --- a/tests/discovery/test_wg_iface_lifecycle.py +++ b/tests/net/test_wg_iface_lifecycle.py @@ -13,7 +13,7 @@ from typing import BinaryIO import pytest import trio -from tractor.discovery import ( +from tractor.net import ( Bindspace, BindspaceRef, BindspaceSpec, @@ -23,7 +23,7 @@ from tractor.discovery import ( open_wg_bindspace, open_wg_iface, ) -from tractor.discovery import _tunnel +from tractor.net import _tunnel _LOCAL_KEY: str = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' diff --git a/tests/discovery/test_wg_inspection.py b/tests/net/test_wg_inspection.py similarity index 99% rename from tests/discovery/test_wg_inspection.py rename to tests/net/test_wg_inspection.py index 8e333517..7a883941 100644 --- a/tests/discovery/test_wg_inspection.py +++ b/tests/net/test_wg_inspection.py @@ -13,13 +13,13 @@ from typing import ( import pytest import trio -from tractor.discovery import ( +from tractor.net import ( read_wg_peers, read_wg_pubkey, verify_wg_peer, WGTunnelSpec, ) -from tractor.discovery import _tunnel +from tractor.net import _tunnel pyroute2: Any = pytest.importorskip('pyroute2') diff --git a/tests/test_lazy_imports.py b/tests/test_lazy_imports.py index 75c238bf..7ed691d3 100644 --- a/tests/test_lazy_imports.py +++ b/tests/test_lazy_imports.py @@ -44,8 +44,8 @@ def test_lazy_to_asyncio_package_api(): 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. + leaves `asyncio` unloaded, while introspection and wildcard + access still advertise and resolve the public submodule. ''' cold = run_cold_import( @@ -102,6 +102,12 @@ def test_cold_import_budget(): 'bidict', 'colorlog', 'multiaddr', + 'multibase', + 'pyroute2', + 'tractor.discovery._multiaddr', + 'tractor.net', + 'tractor.net._bindspace', + 'tractor.net._tunnel', 'wrapt', ) code = ( @@ -136,6 +142,123 @@ def test_cold_import_budget(): ) +def test_lazy_net_package_api(): + ''' + Keep the public network package cold until symbol access. + + The old discovery re-exports imported bindspace, tunnel, + multiaddr and optional dependencies while initializing a package. + Import `tractor.net` in a clean interpreter, inspect its public + surface, and prove no implementation or optional dependency was + loaded. Then resolve one symbol from each backing module and + prove the facade caches each value while preserving boundaries. + + ''' + modules: tuple[str, ...] = ( + 'tractor.net._bindspace', + 'tractor.net._tunnel', + 'tractor.discovery._multiaddr', + 'multiaddr', + 'multibase', + 'pyroute2', + ) + cold: dict[str, object] = run_cold_import( + 'import json, sys; import tractor.net as net; ' + f'names = {modules!r}; ' + 'print(json.dumps({' + '"public": all(name in dir(net) for name in net.__all__), ' + '"loaded": [name for name in names if name in sys.modules]' + '}))' + ) + assert cold == { + 'public': True, + 'loaded': [], + } + + resolved: dict[str, object] = run_cold_import( + 'import json, sys; import tractor.net as net; ' + 'bindspace = net.BindspaceSpec; ' + 'bindspace_cached = net.BindspaceSpec is bindspace; ' + 'maddr = net.mk_maddr; ' + 'maddr_cached = net.mk_maddr is maddr; ' + 'tunnel = net.WGTunnelSpec; ' + 'tunnel_cached = net.WGTunnelSpec is tunnel; ' + 'print(json.dumps({' + '"bindspace_cached": bindspace_cached, ' + '"maddr_cached": maddr_cached, ' + '"tunnel_cached": tunnel_cached, ' + '"bindspace_module": bindspace.__module__, ' + '"maddr_module": maddr.__module__, ' + '"tunnel_module": tunnel.__module__, ' + '"multiaddr_loaded": "multiaddr" in sys.modules, ' + '"pyroute2_loaded": "pyroute2" in sys.modules' + '}))' + ) + assert resolved == { + 'bindspace_cached': True, + 'maddr_cached': True, + 'tunnel_cached': True, + 'bindspace_module': 'tractor.net._bindspace', + 'maddr_module': 'tractor.discovery._multiaddr', + 'tunnel_module': 'tractor.net._tunnel', + 'multiaddr_loaded': False, + 'pyroute2_loaded': False, + } + + +def test_net_root_export_and_old_discovery_surface(): + ''' + Publish networking only from its approved namespace. + + Before extraction, unshipped network names and implementation + modules lived under `tractor.discovery`. Exercise root attribute + and wildcard access in clean interpreters, proving `tractor.net` + is discoverable and cached without loading implementations. Also + prove the old exports are absent and their modules no longer + resolve, preventing accidental compatibility aliases. + + ''' + root: dict[str, object] = run_cold_import( + 'import json, sys, tractor; ' + 'advertised = "net" in dir(tractor); ' + 'net = tractor.net; ' + 'print(json.dumps({' + '"advertised": advertised, ' + '"cached": tractor.net is net, ' + '"module": net.__name__, ' + '"bindspace_loaded": ' + '"tractor.net._bindspace" in sys.modules, ' + '"tunnel_loaded": "tractor.net._tunnel" in sys.modules' + '}))' + ) + assert root == { + 'advertised': True, + 'cached': True, + 'module': 'tractor.net', + 'bindspace_loaded': False, + 'tunnel_loaded': False, + } + + old: dict[str, object] = run_cold_import( + 'import importlib.util, json; ' + 'import tractor.discovery as discovery; ' + 'old_names = ("Bindspace", "TunnelledAddress", ' + '"mk_maddr", "parse_maddr", "parse_endpoints"); ' + 'old_modules = ("tractor.discovery._bindspace", ' + '"tractor.discovery._tunnel"); ' + 'print(json.dumps({' + '"exports": [name for name in old_names ' + 'if hasattr(discovery, name)], ' + '"modules": [name for name in old_modules ' + 'if importlib.util.find_spec(name) is not None]' + '}))' + ) + assert old == { + 'exports': [], + 'modules': [], + } + + def test_lazy_annotation_names_resolve(): ''' Resolve annotations without importing optional dependencies. diff --git a/tests/test_netns_spawn.py b/tests/test_netns_spawn.py index 3ad0ccd2..6e206d1b 100644 --- a/tests/test_netns_spawn.py +++ b/tests/test_netns_spawn.py @@ -24,7 +24,7 @@ import trio import tractor from tractor import _child from tractor.devx import _proctitle -from tractor.discovery._bindspace import ( +from tractor.net._bindspace import ( Bindspace, BindspaceRef, BindspaceSpec, diff --git a/tractor/__init__.py b/tractor/__init__.py index 3568d9bd..94fba96a 100644 --- a/tractor/__init__.py +++ b/tractor/__init__.py @@ -18,6 +18,7 @@ tractor: structured concurrent ``trio``-"actors". """ +from types import ModuleType as _ModuleType from ._clustering import ( open_actor_cluster as open_actor_cluster, @@ -82,6 +83,7 @@ __all__: tuple[str, ...] = tuple( for name in globals() if not name.startswith('_') ) + ( + 'net', 'to_asyncio', ) @@ -92,21 +94,18 @@ def __dir__() -> list[str]: 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. + PEP 562 lazy public sub-package loading. - 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. + `tractor.to_asyncio` transitively imports `asyncio`, while + `tractor.net` owns optional network dependencies. Neither is + needed by most applications merely importing the root package. ''' - if name == 'to_asyncio': + if name in ('net', 'to_asyncio'): from importlib import import_module - return import_module('.to_asyncio', __name__) + module: _ModuleType = import_module(f'.{name}', __name__) + globals()[name] = module + return module raise AttributeError( f'module {__name__!r} has no attribute {name!r}' diff --git a/tractor/_root.py b/tractor/_root.py index b0aa4052..c0524666 100644 --- a/tractor/_root.py +++ b/tractor/_root.py @@ -55,7 +55,6 @@ from .discovery._addr import ( mk_uuid, wrap_address, ) -from .discovery._tunnel import strip_tunnels from .trionics import ( is_multi_cancelled, collapse_eg, @@ -505,6 +504,8 @@ async def open_root_actor( # XXX INSTEAD, bind random addrs using the same tpt # proto if not already provided. if not tpt_bind_addrs: + from .net._tunnel import strip_tunnels + for addr in ponged_addrs: bindable_addr: Address = strip_tunnels(addr) tpt_bind_addrs.append( diff --git a/tractor/discovery/__init__.py b/tractor/discovery/__init__.py index 6fc9ad5c..dee99a5b 100644 --- a/tractor/discovery/__init__.py +++ b/tractor/discovery/__init__.py @@ -15,48 +15,8 @@ # along with this program. If not, see . ''' -Discovery (protocols) API for automatic addressing -and location management of (service) actors. +Actor discovery and registrar implementation package. -NOTE: this ``__init__`` only eagerly imports the lightweight -``._multiaddr`` and ``._tunnel`` submodules for public re-exports. -Heavier submodules like ``._addr`` and ``._api`` are NOT imported -here to avoid circular imports; use direct module paths for those. +Network declarations and helpers are public from `tractor.net`. ''' -from ._bindspace import ( - Bindspace as Bindspace, - BindspaceKind as BindspaceKind, - BindspaceLifecycle as BindspaceLifecycle, - BindspaceOwnership as BindspaceOwnership, - BindspaceRef as BindspaceRef, - BindspaceSpec as BindspaceSpec, - CURRENT_NETNS as CURRENT_NETNS, - attach_netns as attach_netns, - open_bindspace as open_bindspace, - open_netns as open_netns, -) -from ._multiaddr import ( - parse_endpoints as parse_endpoints, - parse_maddr as parse_maddr, - mk_maddr as mk_maddr, -) -from ._tunnel import ( - TunnelledAddress as TunnelledAddress, - TunnelSpec as TunnelSpec, - WGTunnelSpec as WGTunnelSpec, - WGInterfaceConfig as WGInterfaceConfig, - WGPeerConfig as WGPeerConfig, - WGRole as WGRole, - mb_pubkey as mb_pubkey, - mk_wg_maddr as mk_wg_maddr, - open_wg_bindspace as open_wg_bindspace, - open_wg_iface as open_wg_iface, - parse_wg_maddr as parse_wg_maddr, - read_wg_peers as read_wg_peers, - read_wg_pubkey as read_wg_pubkey, - strip_tunnels as strip_tunnels, - tunnels_of as tunnels_of, - verify_wg_peer as verify_wg_peer, - wg8_pubkey as wg8_pubkey, -) diff --git a/tractor/discovery/_addr.py b/tractor/discovery/_addr.py index 1b4e4ddd..ed080690 100644 --- a/tractor/discovery/_addr.py +++ b/tractor/discovery/_addr.py @@ -42,7 +42,7 @@ from ..ipc._uds import ( if TYPE_CHECKING: # ONLY type-annots, the eager import costs ~4.5ms # of `import tractor` wall-time (gh #470). - from ._tunnel import ( + from tractor.net._tunnel import ( TunnelledAddress, ) from ..runtime._runtime import Actor @@ -237,8 +237,9 @@ def is_wrapped_addr(addr: any) -> bool: # XXX NOTE, a `TunnelledAddress` is genuinely "wrapped" but is # deliberately NOT in `_address_types`: it has no # `MsgTransport` of its own (a tunnel is transparent to - # `socket(2)`), so it gets no proto-key entry. See `._tunnel`. - from ._tunnel import TunnelledAddress + # `socket(2)`), so it gets no proto-key entry. See + # `tractor.net._tunnel`. + from tractor.net._tunnel import TunnelledAddress return ( type(addr) in _address_types.values() or @@ -333,7 +334,7 @@ def wrap_address( # multiaddr-format string, e.g. # '/ip4/127.0.0.1/tcp/1616' case str() if addr.startswith('/'): - from tractor.discovery._multiaddr import ( + from tractor.net import ( parse_maddr, ) return parse_maddr(addr) diff --git a/tractor/discovery/_multiaddr.py b/tractor/discovery/_multiaddr.py index 27ba7903..2fd7899d 100644 --- a/tractor/discovery/_multiaddr.py +++ b/tractor/discovery/_multiaddr.py @@ -38,7 +38,7 @@ if TYPE_CHECKING: # `import tractor` path (gh #470). from multiaddr import Multiaddr from tractor.discovery._addr import Address - from tractor.discovery._tunnel import ( + from tractor.net._tunnel import ( TunnelledAddress, ) else: @@ -71,7 +71,7 @@ def mk_maddr( ''' from multiaddr import Multiaddr - from ._tunnel import ( + from tractor.net._tunnel import ( TunnelledAddress, mk_wg_maddr, ) @@ -130,7 +130,7 @@ def parse_maddr( # fails. Pre-checking the raw string would misclassify valid # values such as `/unix/tmp/wg/service.sock`. if '/wg/' in maddr_str: - from ._tunnel import _wg_proto_code + from tractor.net._tunnel import _wg_proto_code _wg_proto_code() raise proto_names: list[str] = [ @@ -156,7 +156,7 @@ def parse_maddr( ) case _ if 'wg' in proto_names: - from ._tunnel import parse_wg_maddr + from tractor.net._tunnel import parse_wg_maddr return parse_wg_maddr(maddr) case _: diff --git a/tractor/ipc/_chan.py b/tractor/ipc/_chan.py index 13188ca0..13aa0244 100644 --- a/tractor/ipc/_chan.py +++ b/tractor/ipc/_chan.py @@ -46,10 +46,6 @@ from tractor.discovery._addr import ( Address, UnwrappedAddress, ) -from tractor.discovery._tunnel import ( - TunnelledAddress, - strip_tunnels, -) from tractor.log import get_logger from tractor._exceptions import ( MsgTypeError, @@ -63,6 +59,9 @@ from tractor.msg import ( if TYPE_CHECKING: from ._transport import MsgTransport + from tractor.net._tunnel import TunnelledAddress +else: + TunnelledAddress = Any log = get_logger() @@ -190,6 +189,8 @@ class Channel: **kwargs ) -> Channel: + from tractor.net._tunnel import strip_tunnels + if not is_wrapped_addr(addr): addr = wrap_address(addr) diff --git a/tractor/ipc/_server.py b/tractor/ipc/_server.py index f298ea57..f75bcae2 100644 --- a/tractor/ipc/_server.py +++ b/tractor/ipc/_server.py @@ -68,7 +68,7 @@ from ._transport import MsgTransport if TYPE_CHECKING: - from ..discovery._tunnel import TunnelledAddress + from ..net._tunnel import TunnelledAddress from ..runtime._runtime import Actor from ..runtime._supervise import ActorNursery @@ -1091,7 +1091,7 @@ async def _serve_ipc_eps( `.cancel_server()` is called. ''' - from ..discovery._tunnel import strip_tunnels + from ..net._tunnel import strip_tunnels try: listen_tn: Nursery diff --git a/tractor/ipc/_tcp.py b/tractor/ipc/_tcp.py index 0caf3f22..40cb8e81 100644 --- a/tractor/ipc/_tcp.py +++ b/tractor/ipc/_tcp.py @@ -37,7 +37,6 @@ from trio import ( from tractor.msg import MsgCodec from tractor.log import get_logger -from tractor.discovery._multiaddr import mk_maddr from tractor.ipc._transport import ( MsgTransport, MsgpackTransport, @@ -235,6 +234,8 @@ class MsgpackTCPStream(MsgpackTransport): @property def maddr(self) -> Multiaddr: + from tractor.net import mk_maddr + return mk_maddr(self.raddr) def connected(self) -> bool: diff --git a/tractor/ipc/_uds.py b/tractor/ipc/_uds.py index 56e84e44..cfba9519 100644 --- a/tractor/ipc/_uds.py +++ b/tractor/ipc/_uds.py @@ -63,7 +63,6 @@ from trio._highlevel_open_unix_stream import ( from tractor.msg import MsgCodec from tractor.log import get_logger -from tractor.discovery._multiaddr import mk_maddr from tractor.ipc._transport import ( MsgpackTransport, ) @@ -611,6 +610,8 @@ class MsgpackUDSStream(MsgpackTransport): @property def maddr(self) -> Multiaddr|str: + from tractor.net import mk_maddr + if not self.raddr: return '' diff --git a/tractor/net/__init__.py b/tractor/net/__init__.py new file mode 100644 index 00000000..ed477a1e --- /dev/null +++ b/tractor/net/__init__.py @@ -0,0 +1,75 @@ +# tractor: structured concurrent "actors". +# Copyright 2018-eternity Tyler Goodlet. + +''' +Network declarations, bindspaces and tunnels. + +Public symbols are imported and cached on first access so importing +this package does not load optional network dependencies. + +''' +from importlib import import_module + + +_SYMBOL_MODULES: dict[str, str] = { + 'Bindspace': '._bindspace', + 'BindspaceKind': '._bindspace', + 'BindspaceLifecycle': '._bindspace', + 'BindspaceOwnership': '._bindspace', + 'BindspaceRef': '._bindspace', + 'BindspaceSpec': '._bindspace', + 'CURRENT_NETNS': '._bindspace', + 'attach_netns': '._bindspace', + 'open_bindspace': '._bindspace', + 'open_netns': '._bindspace', + 'TunnelledAddress': '._tunnel', + 'TunnelSpec': '._tunnel', + 'WGTunnelSpec': '._tunnel', + 'WGInterfaceConfig': '._tunnel', + 'WGPeerConfig': '._tunnel', + 'WGRole': '._tunnel', + 'mb_pubkey': '._tunnel', + 'mk_wg_maddr': '._tunnel', + 'open_wg_bindspace': '._tunnel', + 'open_wg_iface': '._tunnel', + 'parse_wg_maddr': '._tunnel', + 'read_wg_peers': '._tunnel', + 'read_wg_pubkey': '._tunnel', + 'strip_tunnels': '._tunnel', + 'tunnels_of': '._tunnel', + 'verify_wg_peer': '._tunnel', + 'wg8_pubkey': '._tunnel', + 'mk_maddr': '..discovery._multiaddr', + 'parse_maddr': '..discovery._multiaddr', + 'parse_endpoints': '..discovery._multiaddr', +} + +__all__: tuple[str, ...] = tuple(_SYMBOL_MODULES) + + +def __dir__() -> list[str]: + ''' + Advertise the complete lazy public API. + + ''' + return sorted(set(globals()) | set(__all__)) + + +def __getattr__(name: str) -> object: + ''' + Import and cache one public network symbol on first access. + + ''' + try: + module_name: str = _SYMBOL_MODULES[name] + except KeyError: + raise AttributeError( + f'module {__name__!r} has no attribute {name!r}' + ) from None + + value: object = getattr( + import_module(module_name, __name__), + name, + ) + globals()[name] = value + return value diff --git a/tractor/discovery/_bindspace.py b/tractor/net/_bindspace.py similarity index 98% rename from tractor/discovery/_bindspace.py rename to tractor/net/_bindspace.py index 2e2c788e..0c95613e 100644 --- a/tractor/discovery/_bindspace.py +++ b/tractor/net/_bindspace.py @@ -51,7 +51,7 @@ BindspaceOwnership: TypeAlias = Literal[ ] _NETNS_RUN_DIR: Path = Path('/var/run/netns') -_SELF_NETNS: Path = Path('/proc/self/ns/net') +_THREAD_NETNS: Path = Path('/proc/thread-self/ns/net') CURRENT_NETNS: Final[None] = None @@ -124,7 +124,7 @@ class BindspaceSpec( Serializable declaration of one requested bindspace. For a netns spec, `.key = CURRENT_NETNS` selects the calling - process's current namespace without a named-path lookup. + thread's current namespace without a named-path lookup. ''' kind: BindspaceKind @@ -277,7 +277,7 @@ async def _pin_netns( ''' key: str|None = spec.key namespace_path: Path = ( - _SELF_NETNS + _THREAD_NETNS if key is CURRENT_NETNS else _NETNS_RUN_DIR / key ) diff --git a/tractor/discovery/_tunnel.py b/tractor/net/_tunnel.py similarity index 99% rename from tractor/discovery/_tunnel.py rename to tractor/net/_tunnel.py index f31724f2..34dfe79a 100644 --- a/tractor/discovery/_tunnel.py +++ b/tractor/net/_tunnel.py @@ -19,7 +19,8 @@ Tunnelled addresses: an `Address` that rides *inside* a tunnel. A tunnel (`wg`, and later plain ip-in-udp, `veth`-in-netns, ..) is **not** a `MsgTransport`. Its data plane is transparent to the application's `socket(2)`, so it never gets its own entry in -`._addr._address_types` nor a `MsgpackTransport` impl. Instead it +`tractor.discovery._addr._address_types` nor a `MsgpackTransport` +impl. Instead it *annotates* an existing L4 addr, and this module carries that annotation beside it. @@ -102,7 +103,7 @@ from ._bindspace import ( if TYPE_CHECKING: from multiaddr import Multiaddr - from ._addr import ( + from ..discovery._addr import ( Address, UnwrappedAddress, ) @@ -1055,7 +1056,7 @@ def parse_wg_maddr( ] match overlay_names: case [('ip4' | 'ip6'), 'tcp']: - from ._multiaddr import parse_maddr + from ..discovery._multiaddr import parse_maddr overlay: Address|TunnelledAddress = parse_maddr( str(overlay_ma) ) @@ -1161,7 +1162,7 @@ def mk_wg_maddr( f'/wg/{mb_pubkey(addr.tunnel.peer_pubkey)}' ) - from ._multiaddr import mk_maddr + from ..discovery._multiaddr import mk_maddr overlay_ma: Multiaddr = mk_maddr(addr.overlay) return ( bearer_ma diff --git a/tractor/runtime/_supervise.py b/tractor/runtime/_supervise.py index 546a31c4..bf13fbda 100644 --- a/tractor/runtime/_supervise.py +++ b/tractor/runtime/_supervise.py @@ -65,7 +65,7 @@ from ..spawn import _spawn if TYPE_CHECKING: import multiprocessing as mp - from ..discovery._bindspace import Bindspace + from ..net._bindspace import Bindspace # from ..ipc._server import IPCServer from ..ipc import IPCServer from ..spawn._spawn import ProcessType diff --git a/tractor/spawn/_mp.py b/tractor/spawn/_mp.py index e1a62ceb..e0ffc434 100644 --- a/tractor/spawn/_mp.py +++ b/tractor/spawn/_mp.py @@ -54,7 +54,7 @@ from ._spawn import ( if TYPE_CHECKING: - from tractor.discovery._bindspace import Bindspace + from tractor.net._bindspace import Bindspace from tractor.ipc import ( _server, ) diff --git a/tractor/spawn/_spawn.py b/tractor/spawn/_spawn.py index 1a66e23f..93481d56 100644 --- a/tractor/spawn/_spawn.py +++ b/tractor/spawn/_spawn.py @@ -50,7 +50,7 @@ from tractor.msg import types as msgtypes if TYPE_CHECKING: - from tractor.discovery._bindspace import Bindspace + from tractor.net._bindspace import Bindspace from tractor.ipc import ( _server, Channel, diff --git a/tractor/spawn/_trio.py b/tractor/spawn/_trio.py index 956a8b8a..c69418b0 100644 --- a/tractor/spawn/_trio.py +++ b/tractor/spawn/_trio.py @@ -57,7 +57,7 @@ from ._spawn import ( if TYPE_CHECKING: - from tractor.discovery._bindspace import Bindspace + from tractor.net._bindspace import Bindspace from tractor.ipc import ( _server, ) diff --git a/tractor/to_actor/_api.py b/tractor/to_actor/_api.py index 02b54946..0b54e7aa 100644 --- a/tractor/to_actor/_api.py +++ b/tractor/to_actor/_api.py @@ -61,7 +61,7 @@ from ..runtime._supervise import ( if TYPE_CHECKING: from ..discovery._addr import UnwrappedAddress - from ..discovery._bindspace import Bindspace + from ..net._bindspace import Bindspace from ..runtime._portal import Portal