From d2a7bd901cbf2e852b2e88ac3f9329da4ae77f1f Mon Sep 17 00:00:00 2001 From: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:25:22 -0700 Subject: [PATCH] fix(activator): install a bundle root's package only when a declared module lives there; fail by name Bundle.prepare() editable-installed the root Python package of the composed bundle AND of every included bundle whenever the root pyproject.toml carried a [project] table. The inference was wrong for a class of repos it never anticipated: a Python APPLICATION that ships a skills-only behavior. Its [project] table is the application, no declared module imports from it, and because --app bundles are an include of every session, one `amplifier bundle add` of such a repo made every session on the machine fail at preparation -- `uv pip install -e ` refused (requires-python >= 3.13 vs the environment's 3.12.3), attributed to whichever bundle happened to be loading. Reproduced end-to-end in a clean container; `bundle remove` was the only cure. - activate_bundle_package() gains keyword-only `module_sources`. When given, the package is installed only if at least one declared module source resolves INSIDE the bundle root (bundle_root_declares_module): local paths (relative sources are already absolute by prepare() time), or git+ sources whose repo+ref hash to the same cache directory as the root -- the git handler's own placement computation, so a same-repo #subdirectory=modules/x matches and any other repo does not. None preserves the historical rule. - Bundle.prepare() now collects modules_to_activate FIRST and passes the declared sources to every package install. The bundle's own root failing still propagates; an INCLUDED root failing honors `strict` exactly as module activation does -- raise under strict, otherwise skip with a warning naming the include, and let any module that truly needed the package fail on its own, by name, in activate_all(). - Failures are attributed: BundlePackageInstallError names the owning bundle root and package, and points at `bundle remove`. A requires-python that excludes the running interpreter is reported in one sentence before uv is spawned (best-effort via `packaging`, which is not a declared dependency). Verified: 20 new tests (inference, install decision, attribution, and the production call site including the exact field shape under strict=True); full suite 1893 passed. Clean-environment before/after evidence accompanies the field report. --- amplifier_foundation/bundle/_dataclass.py | 86 +++-- amplifier_foundation/modules/activator.py | 168 ++++++++- tests/test_activate_bundle_package.py | 404 ++++++++++++++++++++++ 3 files changed, 624 insertions(+), 34 deletions(-) create mode 100644 tests/test_activate_bundle_package.py diff --git a/amplifier_foundation/bundle/_dataclass.py b/amplifier_foundation/bundle/_dataclass.py index e4de9b25..a6308983 100644 --- a/amplifier_foundation/bundle/_dataclass.py +++ b/amplifier_foundation/bundle/_dataclass.py @@ -3,26 +3,29 @@ from __future__ import annotations import logging -from dataclasses import dataclass -from dataclasses import field +from collections.abc import Callable +from dataclasses import dataclass, field from pathlib import Path -from typing import TYPE_CHECKING -from typing import Any -from typing import Callable +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from amplifier_foundation.bundle._prepared import PreparedBundle from amplifier_foundation.bundle._provenance import ( _prov_add as _prov_add, # re-exported for backwards compatibility +) +from amplifier_foundation.bundle._provenance import ( build_initial_provenance, capture_existing_ids, - tag_container_provenance as tag_container_provenance, # re-exported for registry track_provenance, ) -from amplifier_foundation.configurator._types import Origin as Origin # noqa: F401 re-export -from amplifier_foundation.dicts.merge import deep_merge -from amplifier_foundation.dicts.merge import merge_module_lists +from amplifier_foundation.bundle._provenance import ( + tag_container_provenance as tag_container_provenance, # re-exported for registry +) +from amplifier_foundation.configurator._types import ( + Origin as Origin, +) +from amplifier_foundation.dicts.merge import deep_merge, merge_module_lists from amplifier_foundation.exceptions import BundleValidationError from amplifier_foundation.paths.construction import construct_context_path @@ -354,7 +357,10 @@ def resolve_with_overrides(module_id: str, source: str) -> str: BundleModuleResolver, PreparedBundle, ) - from amplifier_foundation.modules.activator import ModuleActivator + from amplifier_foundation.modules.activator import ( + BundlePackageInstallError, + ModuleActivator, + ) # Get mount plan mount_plan = self.to_mount_plan() @@ -365,24 +371,6 @@ def resolve_with_overrides(module_id: str, source: str) -> str: install_deps=install_deps, base_path=self.base_path, strict=strict ) - # CRITICAL: Install bundle packages BEFORE activating modules - # Modules may import from their parent bundle's package (e.g., a tool - # module importing helpers from `amplifier_bundle_`). These packages - # must be installed before modules can be activated. - if install_deps: - # Install this bundle's package (if it has pyproject.toml) - if self.base_path: - await activator.activate_bundle_package( - self.base_path, progress_callback=progress_callback - ) - - # Install packages from all included bundles (from source_base_paths) - for namespace, bundle_path in self.source_base_paths.items(): - if bundle_path and bundle_path != self.base_path: - await activator.activate_bundle_package( - bundle_path, progress_callback=progress_callback - ) - # Collect all modules that need activation modules_to_activate = [] @@ -446,6 +434,48 @@ def resolve_source(mod_spec: dict) -> dict: # Warnings are logged but do not fail prepare(). mode_warnings = self.validate_modes() + # CRITICAL: Install bundle packages BEFORE activating modules. + # Modules may import from their parent bundle's package (e.g., a tool + # module importing helpers from `amplifier_bundle_`), so the package + # must be present before activate_all(). The decision is made from the + # modules just collected: a root is installed only when one of ITS declared + # modules resolves inside it. A root pyproject alone is not a reason -- for + # an application repo shipping a skills-only behavior it would install the + # application into this environment, and fail every session when it can't. + if install_deps: + declared_sources = [ + m["source"] + for m in modules_to_activate + if isinstance(m.get("source"), str) + ] + # This bundle's own package: a failure here is a failure of the bundle + # being prepared, so it propagates (attributed to this root). + if self.base_path: + await activator.activate_bundle_package( + self.base_path, + progress_callback=progress_callback, + module_sources=declared_sources, + ) + # Included bundles' packages. Honor `strict` exactly as module + # activation does: strict raises; otherwise the include's package is + # skipped with a warning naming it, and any module that truly needed it + # fails on its own, by name, in activate_all(). + for _namespace, bundle_path in self.source_base_paths.items(): + if not bundle_path or bundle_path == self.base_path: + continue + try: + await activator.activate_bundle_package( + bundle_path, + progress_callback=progress_callback, + module_sources=declared_sources, + ) + except BundlePackageInstallError as exc: + if strict: + raise + logger.warning( + f"Included bundle '{_namespace}' package skipped: {exc}" + ) + # Activate all modules and get their paths module_paths = await activator.activate_all( modules_to_activate, progress_callback=progress_callback diff --git a/amplifier_foundation/modules/activator.py b/amplifier_foundation/modules/activator.py index b2074b1c..2936af99 100644 --- a/amplifier_foundation/modules/activator.py +++ b/amplifier_foundation/modules/activator.py @@ -13,15 +13,16 @@ import asyncio import importlib import logging +import platform import site import subprocess import sys +from collections.abc import Callable, Iterable from pathlib import Path -from typing import Callable from amplifier_foundation.exceptions import BundleError from amplifier_foundation.modules.install_state import InstallStateManager -from amplifier_foundation.paths.resolution import get_amplifier_home +from amplifier_foundation.paths.resolution import get_amplifier_home, parse_uri from amplifier_foundation.sources.resolver import SimpleSourceResolver logger = logging.getLogger(__name__) @@ -49,6 +50,98 @@ def _distribution_installed(pkg_name: str) -> bool: return False +class BundlePackageInstallError(BundleError): + """A bundle's own root Python package could not be installed. + + Raised by :meth:`ModuleActivator.activate_bundle_package` so the failure names + the bundle that OWNS the offending ``pyproject.toml`` -- not whichever bundle + happened to be preparing when the install ran. Without this attribution the + user sees ``Failed to load bundle 'foundation'`` for a package that belongs to + an unrelated ``--app`` bundle they added an hour ago. + """ + + def __init__(self, bundle_path: Path, package: str, reason: str) -> None: + self.bundle_path = bundle_path + self.package = package + self.reason = reason + super().__init__( + f"Could not install the root Python package '{package or bundle_path.name}' " + f"of bundle at {bundle_path}: {reason}\n" + f"That package is installed only because a module declared by the bundle " + f"resolves inside that directory. If this bundle was added with " + f"`amplifier bundle add`, `amplifier bundle remove ` restores sessions." + ) + + +def bundle_root_declares_module( + bundle_path: Path, module_sources: Iterable[str] +) -> bool: + """Does at least one declared module ``source`` resolve INSIDE ``bundle_path``? + + This is the question :meth:`ModuleActivator.activate_bundle_package` exists to + serve -- "modules that import from their parent bundle's package" -- asked of + the modules actually declared, rather than inferred from the mere presence of a + ``pyproject.toml`` with a ``[project]`` table. A skills-only behavior shipped + from a Python *application* repo has a ``[project]`` table (the application) but + declares no module that lives there; installing the application into the + Amplifier environment is never what its author meant, and when the package + cannot install (``requires-python`` above the running interpreter) every session + on the machine fails at bundle preparation. + + Two source shapes count as "inside": + + * Local paths. Relative ``./`` and ``../`` sources are rewritten to absolute + paths at load time (``_dataclass._resolve_relative_sources``), so a plain + ``Path(source).resolve().is_relative_to(bundle_path)`` is exact. + * ``git+`` sources whose repo AND ref hash to the same cache directory as + ``bundle_path`` -- the same pure computation the git handler uses to place + clones (``GitSourceHandler._get_cache_path``), evaluated against the cache + directory the bundle itself was fetched into (``bundle_path.parent``). A + ``#subdirectory=modules/x`` module of the same repo therefore matches; a + module fetched from any other repo does not. + + Anything unparseable is treated as "not inside" -- the conservative answer, + because the cost of a false positive here is a machine-wide outage while the + cost of a false negative is one module failing to import, loudly, by name. + """ + try: + root = bundle_path.resolve() + except OSError: + return False + git_handler = None + for source in module_sources: + if not isinstance(source, str) or not source: + continue + try: + parsed = parse_uri(source) + except Exception as exc: # noqa: BLE001 + # An unparseable source is simply "not ours" -- the conservative answer. + logger.debug(f"Ignoring unparseable module source {source!r}: {exc}") + continue + if parsed.is_git: + if git_handler is None: + from amplifier_foundation.sources.git import GitSourceHandler + + git_handler = GitSourceHandler() + try: + if git_handler._get_cache_path(parsed, root.parent).resolve() == root: + return True + except Exception as exc: # noqa: BLE001 + logger.debug( + f"Could not place git source {source!r} in the cache: {exc}" + ) + continue + if parsed.is_file: + raw = source.removeprefix("file://") + try: + candidate = Path(raw).expanduser().resolve() + except (OSError, RuntimeError): + continue + if candidate == root or candidate.is_relative_to(root): + return True + return False + + class ModuleActivator: """Activate modules by downloading and making them importable. @@ -210,6 +303,8 @@ async def activate_bundle_package( self, bundle_path: Path, progress_callback: Callable[[str, str], None] | None = None, + *, + module_sources: Iterable[str] | None = None, ) -> None: """Install a bundle's own Python package to enable internal imports. @@ -224,6 +319,18 @@ async def activate_bundle_package( Args: bundle_path: Path to bundle root directory containing pyproject.toml. + module_sources: The ``source`` strings of every module the bundle + declares. When given, the package is installed ONLY if at least + one of them resolves inside ``bundle_path`` (see + :func:`bundle_root_declares_module`) -- a root ``pyproject.toml`` + alone is not evidence that any module imports from it. ``None`` + preserves the historical behavior (install whenever the pyproject + declares a package) for callers that cannot supply the list. + + Raises: + BundlePackageInstallError: the package's ``requires-python`` excludes + the running interpreter, or the install itself failed. Either way + the error names THIS bundle root and package. Note: This is a no-op if the bundle has no pyproject.toml. @@ -254,6 +361,20 @@ async def activate_bundle_package( ) return + # A [project] table proves the repo ships a Python package. It does not + # prove any module in this bundle imports from it -- an application repo + # that ships a skills-only behavior has a [project] table for the + # application. Only install when a declared module actually lives here. + if module_sources is not None and not bundle_root_declares_module( + bundle_path, module_sources + ): + logger.info( + f"Skipping root package install for bundle at {bundle_path}: none of the " + f"bundle's declared modules resolve inside it, so its pyproject describes " + f"an application, not a module dependency." + ) + return + # Skip packages that are already installed in the current environment. # This prevents editable-installing packages (like amplifier-core) that were # already installed from PyPI as prebuilt wheels. Without this check, a repo @@ -270,10 +391,48 @@ async def activate_bundle_package( ) return + # Fail with a sentence, not a resolver transcript: if the package's own + # requires-python excludes the interpreter Amplifier runs on, uv will refuse + # anyway -- say so first, naming the bundle, before spawning it. + requires_python = str( + pyproject_data.get("project", {}).get("requires-python", "") + ).strip() + if requires_python: + try: + from packaging.specifiers import SpecifierSet + except ImportError: + # `packaging` is not a declared dependency; without it the check is + # skipped and uv's own resolver error is surfaced (attributed) below. + SpecifierSet = None # type: ignore[assignment] + if SpecifierSet is not None: + running = platform.python_version() + if not SpecifierSet(requires_python).contains( + running, prereleases=True + ): + raise BundlePackageInstallError( + bundle_path, + pkg_name, + f"it requires Python {requires_python} but this Amplifier " + f"environment runs Python {running}", + ) + if progress_callback: progress_callback("installing_package", pkg_name or bundle_path.name) logger.debug(f"Installing bundle package from {bundle_path}") - await self._install_dependencies(bundle_path) + try: + await self._install_dependencies(bundle_path) + except subprocess.CalledProcessError as e: + detail = (e.stderr or e.stdout or "").strip() + raise BundlePackageInstallError( + bundle_path, + pkg_name, + f"`uv pip install -e` exited {e.returncode}" + + (f"\n{detail}" if detail else ""), + ) from e + except FileNotFoundError as e: + raise BundlePackageInstallError( + bundle_path, pkg_name, "uv is not installed" + ) from e # CRITICAL: Also add bundle's src/ directory to sys.path explicitly. # Editable installs (uv pip install -e) create .pth files or importlib finders, @@ -315,7 +474,6 @@ def _build_git_dep_overrides(pyproject_path: Path) -> list[str]: Returns a list of ``"name==version"`` strings suitable for a uv overrides file. """ import importlib.metadata - import tomllib try: @@ -596,5 +754,3 @@ class ModuleActivationError(BundleError): preparation failures render this cleanly instead of letting it escape as an unhandled traceback. """ - - pass diff --git a/tests/test_activate_bundle_package.py b/tests/test_activate_bundle_package.py new file mode 100644 index 00000000..0e20ed4e --- /dev/null +++ b/tests/test_activate_bundle_package.py @@ -0,0 +1,404 @@ +"""ModuleActivator.activate_bundle_package -- install a bundle root's Python +package only when a declared module actually lives there, and fail by name. + +Field-reported shape this guards: an APPLICATION repo (root ``pyproject.toml`` +with ``[project]`` + ``requires-python >= 3.13``) ships a skills-only behavior +whose single module is ``tool-skills`` fetched from ANOTHER repo. Adding that +behavior with ``amplifier bundle add ... --app`` used to editable-install the +application into the Amplifier environment on every session start; on a Python +3.12 host the install fails and every bundle load on the machine fails with it, +attributed to whichever bundle happened to be preparing. +""" + +from __future__ import annotations + +import logging +import platform +import subprocess +from pathlib import Path +from unittest.mock import AsyncMock + +import pytest + +from amplifier_foundation.bundle import Bundle +from amplifier_foundation.modules.activator import ( + BundlePackageInstallError, + ModuleActivator, + bundle_root_declares_module, +) +from amplifier_foundation.paths.resolution import parse_uri +from amplifier_foundation.sources.git import GitSourceHandler + +OTHER_REPO_MODULE = "git+https://github.com/microsoft/amplifier-bundle-skills@main#subdirectory=modules/tool-skills" +SAME_REPO = "git+https://github.com/example-org/example-bundle@main" +SAME_REPO_MODULE = SAME_REPO + "#subdirectory=modules/tool-example" +UNSATISFIABLE = ">=3.99" + + +def _write_root( + root: Path, *, requires_python: str | None = None, name: str = "app-pkg" +) -> Path: + root.mkdir(parents=True, exist_ok=True) + rp = f'requires-python = "{requires_python}"\n' if requires_python else "" + (root / "pyproject.toml").write_text( + f'[project]\nname = "{name}"\nversion = "0.1.0"\n{rp}' + '\n[build-system]\nrequires = ["hatchling"]\nbuild-backend = "hatchling.build"\n' + ) + return root + + +def _cache_root_for(cache_dir: Path, uri: str) -> Path: + """The directory the git handler would clone ``uri`` into -- the real placement + computation, not a re-implementation of it.""" + return GitSourceHandler()._get_cache_path(parse_uri(uri), cache_dir) + + +def _activator(tmp_path: Path) -> ModuleActivator: + return ModuleActivator(cache_dir=tmp_path / "amplifier-home", install_deps=True) + + +# --------------------------------------------------------------------------- +# bundle_root_declares_module -- the inference, in isolation +# --------------------------------------------------------------------------- + + +class TestBundleRootDeclaresModule: + def test_other_repo_git_module_is_not_ours(self, tmp_path: Path) -> None: + root = _write_root(tmp_path / "cache" / "app-abc") + assert bundle_root_declares_module(root, [OTHER_REPO_MODULE]) is False + + def test_local_path_inside_root_is_ours(self, tmp_path: Path) -> None: + root = _write_root(tmp_path / "bundle") + # Relative ./ and ../ sources are rewritten to absolute paths at load + # time, so this is the shape prepare() actually sees. + assert ( + bundle_root_declares_module(root, [str(root / "modules" / "tool-x")]) + is True + ) + + def test_local_path_outside_root_is_not_ours(self, tmp_path: Path) -> None: + root = _write_root(tmp_path / "bundle") + other = tmp_path / "elsewhere" / "modules" / "tool-x" + assert bundle_root_declares_module(root, [str(other)]) is False + + def test_same_repo_git_module_is_ours(self, tmp_path: Path) -> None: + cache = tmp_path / "cache" + root = _write_root(_cache_root_for(cache, SAME_REPO)) + assert bundle_root_declares_module(root, [SAME_REPO_MODULE]) is True + + def test_same_repo_different_ref_is_not_ours(self, tmp_path: Path) -> None: + cache = tmp_path / "cache" + root = _write_root(_cache_root_for(cache, SAME_REPO)) + other_ref = ( + SAME_REPO.replace("@main", "@v9") + "#subdirectory=modules/tool-example" + ) + assert bundle_root_declares_module(root, [other_ref]) is False + + def test_garbage_sources_are_conservatively_not_ours(self, tmp_path: Path) -> None: + root = _write_root(tmp_path / "bundle") + assert ( + bundle_root_declares_module(root, ["", None, "@ns:thing", "::not a uri::"]) + is False + ) # type: ignore[list-item] + + def test_empty_source_list_is_not_ours(self, tmp_path: Path) -> None: + root = _write_root(tmp_path / "bundle") + assert bundle_root_declares_module(root, []) is False + + +# --------------------------------------------------------------------------- +# activate_bundle_package -- the install decision + attribution +# --------------------------------------------------------------------------- + + +class TestActivateBundlePackage: + @pytest.mark.asyncio + async def test_app_repo_shape_skips_install_entirely( + self, tmp_path: Path, monkeypatch + ) -> None: + """The field-reported shape: unsatisfiable requires-python, and the only + declared module comes from another repo. Nothing may be installed and + nothing may raise -- the application is not a module dependency.""" + root = _write_root( + tmp_path / "cache" / "app-abc", requires_python=UNSATISFIABLE + ) + act = _activator(tmp_path) + install = AsyncMock() + monkeypatch.setattr(act, "_install_dependencies", install) + + await act.activate_bundle_package(root, module_sources=[OTHER_REPO_MODULE]) + + install.assert_not_called() + + @pytest.mark.asyncio + async def test_local_self_sourced_module_installs( + self, tmp_path: Path, monkeypatch + ) -> None: + root = _write_root(tmp_path / "bundle") + act = _activator(tmp_path) + install = AsyncMock() + monkeypatch.setattr(act, "_install_dependencies", install) + + await act.activate_bundle_package( + root, module_sources=[str(root / "modules" / "tool-x")] + ) + + install.assert_awaited_once_with(root) + + @pytest.mark.asyncio + async def test_same_repo_git_module_installs( + self, tmp_path: Path, monkeypatch + ) -> None: + cache = tmp_path / "cache" + root = _write_root(_cache_root_for(cache, SAME_REPO)) + act = _activator(tmp_path) + install = AsyncMock() + monkeypatch.setattr(act, "_install_dependencies", install) + + await act.activate_bundle_package(root, module_sources=[SAME_REPO_MODULE]) + + install.assert_awaited_once_with(root) + + @pytest.mark.asyncio + async def test_none_module_sources_keeps_legacy_behavior( + self, tmp_path: Path, monkeypatch + ) -> None: + """Callers that cannot supply the declared modules get the historical + rule: a pyproject with [project] is installed.""" + root = _write_root(tmp_path / "bundle") + act = _activator(tmp_path) + install = AsyncMock() + monkeypatch.setattr(act, "_install_dependencies", install) + + await act.activate_bundle_package(root) + + install.assert_awaited_once_with(root) + + @pytest.mark.asyncio + async def test_requires_python_mismatch_raises_by_name_before_uv( + self, tmp_path: Path, monkeypatch + ) -> None: + root = _write_root( + tmp_path / "bundle", requires_python=UNSATISFIABLE, name="needs-future" + ) + act = _activator(tmp_path) + install = AsyncMock() + monkeypatch.setattr(act, "_install_dependencies", install) + + with pytest.raises(BundlePackageInstallError) as excinfo: + await act.activate_bundle_package( + root, module_sources=[str(root / "modules" / "tool-x")] + ) + + err = excinfo.value + assert err.bundle_path == root + assert err.package == "needs-future" + msg = str(err) + assert str(root) in msg + assert "needs-future" in msg + assert UNSATISFIABLE in msg + assert platform.python_version() in msg + assert "bundle remove" in msg + install.assert_not_called() + + @pytest.mark.asyncio + async def test_install_failure_is_attributed_to_owning_bundle( + self, tmp_path: Path, monkeypatch + ) -> None: + root = _write_root(tmp_path / "bundle", name="owner-pkg") + act = _activator(tmp_path) + boom = subprocess.CalledProcessError( + 1, ["uv", "pip", "install"], output="", stderr="No solution found" + ) + monkeypatch.setattr(act, "_install_dependencies", AsyncMock(side_effect=boom)) + + with pytest.raises(BundlePackageInstallError) as excinfo: + await act.activate_bundle_package( + root, module_sources=[str(root / "modules" / "tool-x")] + ) + + err = excinfo.value + assert err.bundle_path == root + assert err.package == "owner-pkg" + assert "exited 1" in str(err) + assert "No solution found" in str(err) + assert isinstance(err.__cause__, subprocess.CalledProcessError) + + @pytest.mark.asyncio + async def test_no_pyproject_is_still_a_noop( + self, tmp_path: Path, monkeypatch + ) -> None: + root = tmp_path / "bundle" + root.mkdir() + act = _activator(tmp_path) + install = AsyncMock() + monkeypatch.setattr(act, "_install_dependencies", install) + + await act.activate_bundle_package(root, module_sources=[str(root / "m")]) + + install.assert_not_called() + + +# --------------------------------------------------------------------------- +# Bundle.prepare() -- the production call site, end to end +# --------------------------------------------------------------------------- + + +def _quiet_activation(monkeypatch) -> AsyncMock: + """Stub module activation + state persistence so prepare() exercises ONLY + the package-install decision. Returns the _install_dependencies mock.""" + install = AsyncMock() + monkeypatch.setattr(ModuleActivator, "_install_dependencies", install) + monkeypatch.setattr(ModuleActivator, "activate_all", AsyncMock(return_value={})) + monkeypatch.setattr(ModuleActivator, "finalize", lambda self: None) + return install + + +class TestPrepareCallSite: + @pytest.mark.asyncio + async def test_included_app_repo_package_is_never_installed( + self, tmp_path: Path, monkeypatch + ) -> None: + """Exact production shape: the user's bundle declares tool-skills from the + skills repo; an --app include contributes an application repo root whose + package cannot install here. Even under strict=True, prepare() must + complete without touching uv.""" + install = _quiet_activation(monkeypatch) + user_root = tmp_path / "user" + user_root.mkdir() + app_root = _write_root( + tmp_path / "cache" / "app-abc", + requires_python=UNSATISFIABLE, + name="the-app", + ) + bundle = Bundle( + name="user", + base_path=user_root, + tools=[{"module": "tool-skills", "source": OTHER_REPO_MODULE}], + source_base_paths={"user": user_root, "the-app": app_root}, + ) + + prepared = await bundle.prepare(install_deps=True, strict=True) + + assert prepared is not None + install.assert_not_called() + + @pytest.mark.asyncio + async def test_included_self_sourced_package_is_installed( + self, tmp_path: Path, monkeypatch + ) -> None: + """A genuine bundle repo (module declared inside its own root) still gets + its package installed -- the behavior the heuristic exists for.""" + install = _quiet_activation(monkeypatch) + user_root = tmp_path / "user" + user_root.mkdir() + lib_root = _write_root(tmp_path / "cache" / "lib-abc", name="lib-pkg") + bundle = Bundle( + name="user", + base_path=user_root, + tools=[ + {"module": "tool-lib", "source": str(lib_root / "modules" / "tool-lib")} + ], + source_base_paths={"user": user_root, "lib": lib_root}, + ) + + await bundle.prepare(install_deps=True, strict=True) + + install.assert_awaited_once_with(lib_root) + + @pytest.mark.asyncio + async def test_included_package_failure_raises_under_strict( + self, tmp_path: Path, monkeypatch + ) -> None: + _quiet_activation(monkeypatch) + user_root = tmp_path / "user" + user_root.mkdir() + lib_root = _write_root( + tmp_path / "cache" / "lib-abc", + requires_python=UNSATISFIABLE, + name="lib-pkg", + ) + bundle = Bundle( + name="user", + base_path=user_root, + tools=[ + {"module": "tool-lib", "source": str(lib_root / "modules" / "tool-lib")} + ], + source_base_paths={"user": user_root, "lib": lib_root}, + ) + + with pytest.raises(BundlePackageInstallError) as excinfo: + await bundle.prepare(install_deps=True, strict=True) + assert excinfo.value.bundle_path == lib_root + + @pytest.mark.asyncio + async def test_included_package_failure_is_skipped_with_warning_when_not_strict( + self, tmp_path: Path, monkeypatch, caplog + ) -> None: + _quiet_activation(monkeypatch) + user_root = tmp_path / "user" + user_root.mkdir() + lib_root = _write_root( + tmp_path / "cache" / "lib-abc", + requires_python=UNSATISFIABLE, + name="lib-pkg", + ) + bundle = Bundle( + name="user", + base_path=user_root, + tools=[ + {"module": "tool-lib", "source": str(lib_root / "modules" / "tool-lib")} + ], + source_base_paths={"user": user_root, "lib": lib_root}, + ) + + with caplog.at_level( + logging.WARNING, logger="amplifier_foundation.bundle._dataclass" + ): + prepared = await bundle.prepare(install_deps=True, strict=False) + + assert prepared is not None + assert any( + "Included bundle 'lib' package skipped" in r.getMessage() + and "lib-pkg" in r.getMessage() + for r in caplog.records + ), [r.getMessage() for r in caplog.records] + + @pytest.mark.asyncio + async def test_own_root_package_failure_propagates( + self, tmp_path: Path, monkeypatch + ) -> None: + """The bundle being prepared cannot have its own package quietly skipped.""" + _quiet_activation(monkeypatch) + own_root = _write_root( + tmp_path / "own", requires_python=UNSATISFIABLE, name="own-pkg" + ) + bundle = Bundle( + name="own", + base_path=own_root, + tools=[ + {"module": "tool-own", "source": str(own_root / "modules" / "tool-own")} + ], + ) + + with pytest.raises(BundlePackageInstallError) as excinfo: + await bundle.prepare(install_deps=True, strict=False) + assert excinfo.value.bundle_path == own_root + + @pytest.mark.asyncio + async def test_install_deps_false_never_installs( + self, tmp_path: Path, monkeypatch + ) -> None: + install = _quiet_activation(monkeypatch) + own_root = _write_root(tmp_path / "own") + bundle = Bundle( + name="own", + base_path=own_root, + tools=[ + {"module": "tool-own", "source": str(own_root / "modules" / "tool-own")} + ], + ) + + await bundle.prepare(install_deps=False) + + install.assert_not_called()