From c053ae236bfcf867bb3a0c56d9116e8514d391d3 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Thu, 21 May 2026 16:11:55 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=90=9B=20(agent=5Fassembly):=20Lazy-l?= =?UTF-8?q?oad=20top-level=20exports=20via=20PEP=20562=20=5F=5Fgetattr=5F?= =?UTF-8?q?=5F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `agent_assembly/__init__.py` previously imported the full SDK surface eagerly: `from agent_assembly.core import …` → `agent_assembly.client.gateway` → `import httpx`. That defeated `agent_assembly/runtime`'s design intent of being stdlib-only and broke aa-integration-tests::e2e_sdk_runtime_lifecycle::python_binary_in_path_returns_resolved_path with `ModuleNotFoundError: No module named 'httpx'` whenever the SDK was used via PYTHONPATH without installing third-party deps (agent-assembly CI run 26211782822). Convert the package to lazy attribute resolution using PEP 562's `__getattr__` so accessing `agent_assembly.init_assembly` still works, but `from agent_assembly.runtime import …` no longer drags httpx/pydantic into sys.modules. `__all__` and `__version__` remain static; the optional native `_core` exports are still gated on availability (now via sys.modules + importlib.util.find_spec to preserve the existing test_init_exports.py behaviour). TYPE_CHECKING re-exports keep mypy and IDE completion identical to before. --- agent_assembly/__init__.py | 130 ++++++++++++++++++++++++++++--------- 1 file changed, 98 insertions(+), 32 deletions(-) diff --git a/agent_assembly/__init__.py b/agent_assembly/__init__.py index 7d901a9..ce82221 100644 --- a/agent_assembly/__init__.py +++ b/agent_assembly/__init__.py @@ -1,32 +1,42 @@ """Agent Assembly Python SDK.""" -from agent_assembly.adapters import FrameworkAdapter, GovernanceInterceptor -from agent_assembly.core import AssemblyContext, init_assembly -from agent_assembly.exceptions import ( - AdapterValidationError, - AgentError, - AssemblyError, - ConfigurationError, - GatewayError, - MCPToolBlockedError, - PolicyError, - ToolExecutionBlockedError, -) -from agent_assembly.types import AuditEvent, CallStackNode, CallStackNodeKind - -try: - from agent_assembly._core import ( - GovernanceEvent, - PolicyResult, - PolicyTimeoutError, - RuntimeClient, - ) -except ImportError: - pass +from __future__ import annotations + +import contextlib +import importlib +import importlib.util +import sys +from typing import TYPE_CHECKING, Any __version__ = "0.0.0" -__all__ = [ +# AAASM-1696: top-level exports are resolved lazily so that lightweight +# submodules (e.g. `agent_assembly.runtime`, which is stdlib-only) can be +# imported without dragging in the SDK's third-party dependency surface +# (`httpx`, `pydantic`, …). See PEP 562. +_LAZY_EXPORTS: dict[str, str] = { + "init_assembly": "agent_assembly.core", + "AssemblyContext": "agent_assembly.core", + "GovernanceInterceptor": "agent_assembly.adapters", + "FrameworkAdapter": "agent_assembly.adapters", + "AssemblyError": "agent_assembly.exceptions", + "AgentError": "agent_assembly.exceptions", + "PolicyError": "agent_assembly.exceptions", + "GatewayError": "agent_assembly.exceptions", + "ConfigurationError": "agent_assembly.exceptions", + "AdapterValidationError": "agent_assembly.exceptions", + "ToolExecutionBlockedError": "agent_assembly.exceptions", + "MCPToolBlockedError": "agent_assembly.exceptions", + "AuditEvent": "agent_assembly.types", + "CallStackNode": "agent_assembly.types", + "CallStackNodeKind": "agent_assembly.types", + "GovernanceEvent": "agent_assembly._core", + "PolicyResult": "agent_assembly._core", + "PolicyTimeoutError": "agent_assembly._core", + "RuntimeClient": "agent_assembly._core", +} + +_ALWAYS_EXPORTED: list[str] = [ "__version__", "init_assembly", "AssemblyContext", @@ -45,12 +55,68 @@ "CallStackNodeKind", ] -if "RuntimeClient" in globals(): - __all__.extend( - [ - "RuntimeClient", - "GovernanceEvent", - "PolicyResult", - "PolicyTimeoutError", - ] +_OPTIONAL_CORE: list[str] = [ + "RuntimeClient", + "GovernanceEvent", + "PolicyResult", + "PolicyTimeoutError", +] + + +def _core_available() -> bool: + if "agent_assembly._core" in sys.modules: + return True + try: + return importlib.util.find_spec("agent_assembly._core") is not None + except (ModuleNotFoundError, ValueError): + return False + + +__all__: list[str] = list(_ALWAYS_EXPORTED) +if _core_available(): + __all__.extend(_OPTIONAL_CORE) + + +def __getattr__(name: str) -> Any: + module_name = _LAZY_EXPORTS.get(name) + if module_name is None: + raise AttributeError(f"module 'agent_assembly' has no attribute {name!r}") + try: + module = importlib.import_module(module_name) + except ImportError: + if module_name == "agent_assembly._core": + raise AttributeError( + f"module 'agent_assembly' has no attribute {name!r}: the native '_core' extension is not built" + ) from None + raise + value = getattr(module, name) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + return sorted(set(__all__) | set(globals())) + + +if TYPE_CHECKING: + from agent_assembly.adapters import FrameworkAdapter, GovernanceInterceptor + from agent_assembly.core import AssemblyContext, init_assembly + from agent_assembly.exceptions import ( + AdapterValidationError, + AgentError, + AssemblyError, + ConfigurationError, + GatewayError, + MCPToolBlockedError, + PolicyError, + ToolExecutionBlockedError, ) + from agent_assembly.types import AuditEvent, CallStackNode, CallStackNodeKind + + with contextlib.suppress(ImportError): + from agent_assembly._core import ( + GovernanceEvent, + PolicyResult, + PolicyTimeoutError, + RuntimeClient, + ) From cf4d4c8cd364da7f190b665ca2589923abadf289 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Thu, 21 May 2026 16:12:02 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9C=85=20(test):=20Add=20regression=20te?= =?UTF-8?q?st=20for=20runtime=20import=20isolation=20(AAASM-1696)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spawns a child interpreter with a sys.meta_path finder that raises ModuleNotFoundError for `httpx` and `pydantic`, then asserts: * `from agent_assembly.runtime import find_aasm_binary, init_assembly, is_running` succeeds in that environment. * `import agent_assembly` resolves to the package without dragging in the gateway client's third-party deps. * Eager attribute access (`agent_assembly.init_assembly`, etc.) still resolves through the PEP 562 `__getattr__` loader. * Unknown attributes raise AttributeError with the offending name. Guards against the eager-import regression that broke agent-assembly CI run 26211782822. --- test/unit/test_runtime_import_isolation.py | 100 +++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 test/unit/test_runtime_import_isolation.py diff --git a/test/unit/test_runtime_import_isolation.py b/test/unit/test_runtime_import_isolation.py new file mode 100644 index 0000000..103ddaa --- /dev/null +++ b/test/unit/test_runtime_import_isolation.py @@ -0,0 +1,100 @@ +"""AAASM-1696: agent_assembly.runtime must be importable without httpx/pydantic. + +Regression test for the eager-import bug in `agent_assembly/__init__.py` that +broke aa-integration-tests::e2e_sdk_runtime_lifecycle::python_binary_in_path_returns_resolved_path +(agent-assembly run 26211782822, both ubuntu-latest and macos-latest jobs). +""" + +from __future__ import annotations + +import subprocess +import sys +import textwrap +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] + + +def _run_python_with_blocked_imports(blocked: list[str], code: str) -> subprocess.CompletedProcess[str]: + """Run ``code`` in a child interpreter where ``blocked`` modules raise on import.""" + block_literal = repr(blocked) + wrapper = ( + textwrap.dedent( + f""" + import sys + _BLOCKED = {block_literal} + + class _BlockingFinder: + def find_spec(self, name, path=None, target=None): + root = name.split(".", 1)[0] + if root in _BLOCKED: + raise ModuleNotFoundError(f"No module named {{name!r}} (blocked by test)") + return None + + sys.meta_path.insert(0, _BlockingFinder()) + """ + ).strip() + + "\n" + + code + ) + return subprocess.run( + [sys.executable, "-c", wrapper], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + check=False, + ) + + +def test_runtime_import_does_not_pull_in_httpx() -> None: + result = _run_python_with_blocked_imports( + ["httpx", "pydantic"], + "from agent_assembly.runtime import find_aasm_binary, init_assembly, is_running\nprint('ok')\n", + ) + assert ( + result.returncode == 0 + ), f"agent_assembly.runtime should not require httpx/pydantic.\nstdout: {result.stdout}\nstderr: {result.stderr}" + assert result.stdout.strip() == "ok" + + +def test_top_level_package_import_does_not_pull_in_httpx() -> None: + result = _run_python_with_blocked_imports( + ["httpx", "pydantic"], + "import agent_assembly\nprint(agent_assembly.__version__)\n", + ) + assert result.returncode == 0, ( + f"`import agent_assembly` must not eagerly import httpx/pydantic.\n" + f"stdout: {result.stdout}\nstderr: {result.stderr}" + ) + assert result.stdout.strip() == "0.0.0" + + +def test_eager_attribute_access_still_resolves_through_lazy_loader() -> None: + result = subprocess.run( + [ + sys.executable, + "-c", + "import agent_assembly\n" + "_ = agent_assembly.init_assembly\n" + "_ = agent_assembly.AssemblyError\n" + "_ = agent_assembly.AuditEvent\n" + "print('ok')\n", + ], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, f"stdout: {result.stdout}\nstderr: {result.stderr}" + assert result.stdout.strip() == "ok" + + +def test_unknown_attribute_raises_attribute_error() -> None: + import agent_assembly + + try: + agent_assembly.does_not_exist # noqa: B018 + except AttributeError as exc: + assert "does_not_exist" in str(exc) + else: + raise AssertionError("expected AttributeError for unknown attribute") From 40708761808649f534655e6d359534d5f0c43833 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Thu, 21 May 2026 16:18:39 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=9A=A8=20(test):=20Apply=20ruff=20for?= =?UTF-8?q?mat=20idiomatic=20assert-wrap=20shape?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ruff format` flagged the single-line failure-message assert as non-canonical when run across the whole tree (vs. per-file). Switch to the trailing-tuple form to match the rest of the file. No behavioural change. --- test/unit/test_runtime_import_isolation.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/unit/test_runtime_import_isolation.py b/test/unit/test_runtime_import_isolation.py index 103ddaa..3355559 100644 --- a/test/unit/test_runtime_import_isolation.py +++ b/test/unit/test_runtime_import_isolation.py @@ -51,9 +51,9 @@ def test_runtime_import_does_not_pull_in_httpx() -> None: ["httpx", "pydantic"], "from agent_assembly.runtime import find_aasm_binary, init_assembly, is_running\nprint('ok')\n", ) - assert ( - result.returncode == 0 - ), f"agent_assembly.runtime should not require httpx/pydantic.\nstdout: {result.stdout}\nstderr: {result.stderr}" + assert result.returncode == 0, ( + f"agent_assembly.runtime should not require httpx/pydantic.\nstdout: {result.stdout}\nstderr: {result.stderr}" + ) assert result.stdout.strip() == "ok"