From f81efa19eaaaa1f32f84d8b53e0250442e3e17b5 Mon Sep 17 00:00:00 2001 From: Bernd Verst Date: Sun, 26 Jul 2026 23:15:41 -0700 Subject: [PATCH 1/2] Lazily load sandbox runtime API exports Importing durabletask.azuremanaged.preview.sandboxes eagerly imported both the sandbox client and the sandbox worker. The worker module pulls in ManagedIdentityCredential from azure-identity plus the core worker graph, so merely touching the sandboxes namespace paid that cost at startup. Resolve the package's public names through a PEP 562 module __getattr__ instead, with TYPE_CHECKING imports so type checkers and IDEs still see them. __all__, dir(), star-imports, submodule attributes, and every import path are unchanged. Fixes #197 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9df32033-0d12-4be2-ad8c-3fea7890376f --- durabletask-azuremanaged/CHANGELOG.md | 4 + .../preview/sandboxes/__init__.py | 60 ++++++++++-- .../test_sandboxes_extension.py | 98 +++++++++++++++++++ 3 files changed, 156 insertions(+), 6 deletions(-) diff --git a/durabletask-azuremanaged/CHANGELOG.md b/durabletask-azuremanaged/CHANGELOG.md index 300d855b..f42a62fe 100644 --- a/durabletask-azuremanaged/CHANGELOG.md +++ b/durabletask-azuremanaged/CHANGELOG.md @@ -7,6 +7,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased +- Importing `durabletask.azuremanaged.preview.sandboxes` no longer loads the sandbox worker + runtime or `azure-identity` up front. The package's public names are now resolved on first + use, which roughly halves import cost for callers that only declare sandbox worker profiles + or use the sandbox client. Exported names, `__all__`, and import paths are unchanged. - `FailureDetails.error_type` now carries the fully-qualified type name (e.g. `durabletask.task.TaskFailedError`) instead of the bare class name, and the new `FailureDetails.is_caused_by()` helper is available (both inherited from durabletask). See the core `durabletask` changelog for details, including the breaking-change notes. ## v1.8.0 diff --git a/durabletask-azuremanaged/durabletask/azuremanaged/preview/sandboxes/__init__.py b/durabletask-azuremanaged/durabletask/azuremanaged/preview/sandboxes/__init__.py index 280f138b..ca7783d5 100644 --- a/durabletask-azuremanaged/durabletask/azuremanaged/preview/sandboxes/__init__.py +++ b/durabletask-azuremanaged/durabletask/azuremanaged/preview/sandboxes/__init__.py @@ -13,14 +13,22 @@ SandboxWorker, SandboxActivitiesClient, ) + +The exports below are resolved lazily on first attribute access, so importing +this package does not load the sandbox worker runtime (and its Azure Identity +and gRPC dependencies) unless those APIs are actually used. """ -from durabletask.azuremanaged.preview.sandboxes.client import SandboxActivitiesClient -from durabletask.azuremanaged.preview.sandboxes.helpers import SandboxActivity -from durabletask.azuremanaged.preview.sandboxes.worker_profiles import SandboxWorkerProfile -from durabletask.azuremanaged.preview.sandboxes.worker_profiles import SandboxWorkerProfileOptions -from durabletask.azuremanaged.preview.sandboxes.worker_profiles import sandbox_worker_profile -from durabletask.azuremanaged.preview.sandboxes.worker import SandboxWorker +from importlib import import_module +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from durabletask.azuremanaged.preview.sandboxes.client import SandboxActivitiesClient + from durabletask.azuremanaged.preview.sandboxes.helpers import SandboxActivity + from durabletask.azuremanaged.preview.sandboxes.worker_profiles import SandboxWorkerProfile + from durabletask.azuremanaged.preview.sandboxes.worker_profiles import SandboxWorkerProfileOptions + from durabletask.azuremanaged.preview.sandboxes.worker_profiles import sandbox_worker_profile + from durabletask.azuremanaged.preview.sandboxes.worker import SandboxWorker __all__ = [ "SandboxWorker", @@ -30,3 +38,43 @@ "SandboxActivitiesClient", "sandbox_worker_profile", ] + +# Public export name -> submodule of this package that defines it. +_LAZY_EXPORTS: dict[str, str] = { + "SandboxWorker": "worker", + "SandboxActivity": "helpers", + "SandboxWorkerProfile": "worker_profiles", + "SandboxWorkerProfileOptions": "worker_profiles", + "SandboxActivitiesClient": "client", + "sandbox_worker_profile": "worker_profiles", +} + +# Submodules that eager imports previously bound as attributes of this package. +# They remain reachable through attribute access without an explicit import. +_LAZY_SUBMODULES: frozenset[str] = frozenset({ + "client", + "helpers", + "profile_builder", + "transport", + "worker", + "worker_messages", + "worker_profiles", +}) + + +def __getattr__(name: str) -> Any: + """Import public sandbox exports on first access (PEP 562).""" + submodule = _LAZY_EXPORTS.get(name) + if submodule is not None: + value = getattr(import_module(f".{submodule}", __name__), name) + elif name in _LAZY_SUBMODULES: + value = import_module(f".{name}", __name__) + else: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + globals()[name] = value + return value + + +def __dir__() -> list[str]: + return sorted(set(globals()) | set(__all__) | _LAZY_SUBMODULES) diff --git a/tests/durabletask-azuremanaged/test_sandboxes_extension.py b/tests/durabletask-azuremanaged/test_sandboxes_extension.py index caf96571..51e7acae 100644 --- a/tests/durabletask-azuremanaged/test_sandboxes_extension.py +++ b/tests/durabletask-azuremanaged/test_sandboxes_extension.py @@ -2,8 +2,11 @@ # Licensed under the MIT License. import inspect +import subprocess +import sys import grpc +import pytest from azure.core.credentials import AccessToken import durabletask.azuremanaged.preview.sandboxes as sandbox @@ -65,6 +68,101 @@ def test_public_sandbox_package_exports_customer_entrypoints_only() -> None: assert not hasattr(SandboxWorker, f"_configure_{legacy_prefix}_activity_filters") +# Runs in a subprocess because this test module imports the sandbox worker at +# module scope, which would otherwise hide the lazy-import behavior. +_LAZY_EXPORT_PROBE = """ +import sys + +import durabletask.azuremanaged.preview.sandboxes as sandbox + +eager = [ + name + for name in ( + "durabletask.azuremanaged.preview.sandboxes.client", + "durabletask.azuremanaged.preview.sandboxes.transport", + "durabletask.azuremanaged.preview.sandboxes.worker", + "azure.identity", + ) + if name in sys.modules +] +assert not eager, f"sandbox package eagerly imported: {eager}" + +expected = [ + "SandboxWorker", + "SandboxActivity", + "SandboxWorkerProfile", + "SandboxWorkerProfileOptions", + "SandboxActivitiesClient", + "sandbox_worker_profile", +] +assert sandbox.__all__ == expected, sandbox.__all__ + +listing = dir(sandbox) +assert not [name for name in expected if name not in listing], listing +assert not [name for name in ("client", "helpers", "worker", "worker_profiles") if name not in listing], listing + +from durabletask.azuremanaged.preview.sandboxes.client import SandboxActivitiesClient +from durabletask.azuremanaged.preview.sandboxes.helpers import SandboxActivity +from durabletask.azuremanaged.preview.sandboxes.worker import SandboxWorker +from durabletask.azuremanaged.preview.sandboxes.worker_profiles import SandboxWorkerProfile +from durabletask.azuremanaged.preview.sandboxes.worker_profiles import SandboxWorkerProfileOptions +from durabletask.azuremanaged.preview.sandboxes.worker_profiles import sandbox_worker_profile + +assert sandbox.SandboxWorker is SandboxWorker +assert sandbox.SandboxActivity is SandboxActivity +assert sandbox.SandboxWorkerProfile is SandboxWorkerProfile +assert sandbox.SandboxWorkerProfileOptions is SandboxWorkerProfileOptions +assert sandbox.SandboxActivitiesClient is SandboxActivitiesClient +assert sandbox.sandbox_worker_profile is sandbox_worker_profile + +# Submodules stay reachable as package attributes, as they were while the +# package imported them eagerly. +assert sandbox.worker is sys.modules["durabletask.azuremanaged.preview.sandboxes.worker"] +assert sandbox.helpers is sys.modules["durabletask.azuremanaged.preview.sandboxes.helpers"] +""" + + +_STAR_IMPORT_PROBE = """ +from durabletask.azuremanaged.preview.sandboxes import * # noqa: F403 + +import durabletask.azuremanaged.preview.sandboxes as sandbox + +star_names = {name: value for name, value in globals().items() if not name.startswith("_")} +for name in sandbox.__all__: + assert name in star_names, f"missing star export: {name}" + assert star_names[name] is getattr(sandbox, name), name + +# __all__ still gates the star import, so lazy-import plumbing stays private. +assert "import_module" not in star_names +assert "_LAZY_EXPORTS" not in globals() +""" + + +def _run_isolated(source: str) -> None: + result = subprocess.run( + [sys.executable, "-c", source], + capture_output=True, + text=True, + check=False) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_sandbox_package_defers_worker_runtime_imports() -> None: + _run_isolated(_LAZY_EXPORT_PROBE) + + +def test_sandbox_package_star_import_matches_all() -> None: + _run_isolated(_STAR_IMPORT_PROBE) + + +def test_sandbox_package_raises_attribute_error_for_unknown_attributes() -> None: + with pytest.raises(AttributeError, match="has no attribute 'NotARealSandboxExport'"): + getattr(sandbox, "NotARealSandboxExport") + + assert not hasattr(sandbox, "NotARealSandboxExport") + assert all(hasattr(sandbox, name) for name in sandbox.__all__) + + def _sandbox_image( image_ref: str, managed_identity_client_id: str = "image-pull-client-id", From 416a8ef498d1bd9260cc5492ff76318208291ab8 Mon Sep 17 00:00:00 2001 From: Bernd Verst Date: Mon, 27 Jul 2026 09:40:28 -0700 Subject: [PATCH 2/2] Bound the isolated import probe subprocess with a timeout The subprocess-isolated lazy-import probes ran with no timeout, so a hung child (import deadlock or environment issue) would stall CI indefinitely. Pass an explicit 60s bound - generous for an import-time probe - and turn subprocess.TimeoutExpired into a readable pytest failure that reports the bound and any partial output, rather than letting it propagate raw. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9df32033-0d12-4be2-ad8c-3fea7890376f --- .../test_sandboxes_extension.py | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/tests/durabletask-azuremanaged/test_sandboxes_extension.py b/tests/durabletask-azuremanaged/test_sandboxes_extension.py index 51e7acae..11d145cb 100644 --- a/tests/durabletask-azuremanaged/test_sandboxes_extension.py +++ b/tests/durabletask-azuremanaged/test_sandboxes_extension.py @@ -138,12 +138,24 @@ def test_public_sandbox_package_exports_customer_entrypoints_only() -> None: """ +_ISOLATED_PROBE_TIMEOUT_SECONDS = 60 + + def _run_isolated(source: str) -> None: - result = subprocess.run( - [sys.executable, "-c", source], - capture_output=True, - text=True, - check=False) + try: + result = subprocess.run( + [sys.executable, "-c", source], + capture_output=True, + text=True, + check=False, + timeout=_ISOLATED_PROBE_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired as exc: + pytest.fail( + f"isolated import probe did not complete within " + f"{_ISOLATED_PROBE_TIMEOUT_SECONDS}s and was terminated. " + f"This usually means the import deadlocked.\n" + f"stdout: {exc.stdout or ''}\n" + f"stderr: {exc.stderr or ''}") assert result.returncode == 0, result.stdout + result.stderr