From 8c5a474efaabb6aae0e34a17afd108d059983353 Mon Sep 17 00:00:00 2001 From: Basil Zafar Date: Sun, 11 Oct 2026 14:42:44 +0530 Subject: [PATCH 1/2] fix(adapters): clear stale imports when switching projects --- .../src/mutiny_openai_agents/loader.py | 38 ++++++- tests/unit/test_openai_agents_loader.py | 103 ++++++++++++++++++ 2 files changed, 136 insertions(+), 5 deletions(-) create mode 100644 tests/unit/test_openai_agents_loader.py diff --git a/packages/mutiny_openai_agents/src/mutiny_openai_agents/loader.py b/packages/mutiny_openai_agents/src/mutiny_openai_agents/loader.py index 8d6d790..b217a50 100644 --- a/packages/mutiny_openai_agents/src/mutiny_openai_agents/loader.py +++ b/packages/mutiny_openai_agents/src/mutiny_openai_agents/loader.py @@ -8,12 +8,40 @@ from pathlib import Path from typing import Any +_project_root: Path | None = None + def ensure_project_on_path(project_root: str | Path) -> None: - """Prepend a customer project root to ``sys.path`` for local imports.""" - root = str(Path(project_root).resolve()) - if root not in sys.path: - sys.path.insert(0, root) + """Select a project for subsequent local imports in this process. + + Switching projects clears modules from the previous root, including local + helper packages. Existing object references remain valid, but deferred + imports use the currently selected project. + """ + global _project_root + root = Path(project_root).resolve() + if _project_root is not None and _project_root != root: + names = [] + for name, module in list(sys.modules.items()): + paths = list(getattr(module, "__path__", ())) + module_file = getattr(module, "__file__", None) + if module_file: + paths.append(module_file) + if any(Path(path).resolve().is_relative_to(_project_root) for path in paths): + names.append(name) + # Collect first: namespace package paths can depend on cached parents. + for name in names: + sys.modules.pop(name, None) + previous = str(_project_root) + if previous in sys.path: + sys.path.remove(previous) + + root_path = str(root) + if root_path in sys.path: + sys.path.remove(root_path) + sys.path.insert(0, root_path) + _project_root = root + importlib.invalidate_caches() def load_callable(ref: str) -> Any: @@ -72,7 +100,7 @@ def load_adapter_factory( f"missing {adapter_file}; run `mutiny init` first" ) - # Load as a unique module from file path so `.mutiny` need not be a package + # Load directly from the file so `.mutiny` need not be a package. spec_name = "mutiny_project_adapter" import importlib.util diff --git a/tests/unit/test_openai_agents_loader.py b/tests/unit/test_openai_agents_loader.py new file mode 100644 index 0000000..2773d9a --- /dev/null +++ b/tests/unit/test_openai_agents_loader.py @@ -0,0 +1,103 @@ +"""Project-local imports must follow the currently selected project.""" + +from __future__ import annotations + +import sys +from collections.abc import Iterator +from pathlib import Path + +import pytest + +from mutiny_openai_agents import loader + + +@pytest.fixture(autouse=True) +def isolate_project_imports(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + monkeypatch.setattr(sys, "path", sys.path.copy()) + monkeypatch.setattr(loader, "_project_root", None, raising=False) + names = {"agent", "project_helpers", "project_helpers.values", "mutiny_project_adapter"} + saved = {name: sys.modules[name] for name in names if name in sys.modules} + for name in names: + sys.modules.pop(name, None) + try: + yield + finally: + for name in names: + sys.modules.pop(name, None) + sys.modules.update(saved) + + +def make_project(root: Path, marker: str, *, deferred: bool = False) -> Path: + (root / ".mutiny").mkdir(parents=True) + (root / "agent.py").write_text(f"VALUE = {marker!r}\n", encoding="utf-8") + source = ( + "def create_adapter():\n from agent import VALUE\n return VALUE\n" + if deferred + else "from agent import VALUE\ndef create_adapter():\n return VALUE\n" + ) + (root / ".mutiny" / "adapter.py").write_text(source, encoding="utf-8") + return root + + +@pytest.mark.parametrize("deferred", [False, True]) +def test_sequential_projects_load_their_own_agent(tmp_path: Path, deferred: bool) -> None: + first = make_project(tmp_path / "first", "A", deferred=deferred) + second = make_project(tmp_path / "second", "B", deferred=deferred) + assert loader.load_adapter_factory(first)() == "A" + assert loader.load_adapter_factory(second)() == "B" + assert loader.load_adapter_factory(first)() == "A" + assert sys.path[0] == str(first.resolve()) + assert sys.modules["pytest"] is pytest + + +@pytest.mark.parametrize("namespace", [False, True]) +def test_project_packages_are_not_reused(tmp_path: Path, namespace: bool) -> None: + for marker in ("A", "B"): + project = make_project(tmp_path / marker, marker) + package = project / "project_helpers" + package.mkdir() + if not namespace: + (package / "__init__.py").write_text("", encoding="utf-8") + (package / "values.py").write_text(f"VALUE = {marker!r}\n", encoding="utf-8") + (project / "agent.py").write_text( + "from project_helpers.values import VALUE\n", encoding="utf-8" + ) + assert loader.load_adapter_factory(project)() == marker + + +def test_same_project_preserves_cached_imports(tmp_path: Path) -> None: + project = make_project(tmp_path, "A") + assert loader.load_adapter_factory(project)() == "A" + agent = sys.modules["agent"] + sys.path.insert(0, str(project / "other")) + loader.ensure_project_on_path(project) + assert sys.modules["agent"] is agent + assert sys.modules["pytest"] is pytest + assert sys.path[0] == str(project.resolve()) + assert sys.path.count(str(project.resolve())) == 1 + + +def test_missing_agent_does_not_fall_back_to_previous_project(tmp_path: Path) -> None: + first = make_project(tmp_path / "first", "A") + second = make_project(tmp_path / "second", "B") + (second / "agent.py").unlink() + assert loader.load_adapter_factory(first)() == "A" + # Restrict lookup so the checkout's sample agent cannot mask the missing file. + sys.path[:] = [str(first.resolve())] + with pytest.raises(ModuleNotFoundError, match="agent"): + loader.load_adapter_factory(second) + + +def test_module_attribute_refs_follow_selected_project(tmp_path: Path) -> None: + for marker in ("A", "B", "A"): + project = tmp_path / marker + if not project.exists(): + make_project(project, marker) + (project / "agent.py").write_text( + f"from types import SimpleNamespace\n" + f"def support_agent():\n" + f" return SimpleNamespace(name={marker!r}, tools=[])\n", + encoding="utf-8", + ) + loader.ensure_project_on_path(project) + assert loader.load_agent_from_ref("agent:support_agent").name == marker From 3740c8000e8a1bfe3072669a9be890f7c0e8c68e Mon Sep 17 00:00:00 2001 From: Basil Zafar Date: Mon, 12 Oct 2026 03:28:38 +0530 Subject: [PATCH 2/2] fix(adapters): preserve installed dependencies when switching projects --- .../src/mutiny_openai_agents/loader.py | 16 ++++--- tests/unit/test_openai_agents_loader.py | 45 ++++++++++++++++++- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/packages/mutiny_openai_agents/src/mutiny_openai_agents/loader.py b/packages/mutiny_openai_agents/src/mutiny_openai_agents/loader.py index b217a50..8ce2a74 100644 --- a/packages/mutiny_openai_agents/src/mutiny_openai_agents/loader.py +++ b/packages/mutiny_openai_agents/src/mutiny_openai_agents/loader.py @@ -14,9 +14,9 @@ def ensure_project_on_path(project_root: str | Path) -> None: """Select a project for subsequent local imports in this process. - Switching projects clears modules from the previous root, including local - helper packages. Existing object references remain valid, but deferred - imports use the currently selected project. + Switching projects clears modules imported directly from the previous root, + including local helper packages. Dependencies on separate import paths keep + their identity. Deferred imports use the currently selected project. """ global _project_root root = Path(project_root).resolve() @@ -27,8 +27,14 @@ def ensure_project_on_path(project_root: str | Path) -> None: module_file = getattr(module, "__file__", None) if module_file: paths.append(module_file) - if any(Path(path).resolve().is_relative_to(_project_root) for path in paths): - names.append(name) + top = name.partition(".")[0] + for path in paths: + resolved = Path(path).resolve() + if resolved.is_relative_to(_project_root): + first = resolved.relative_to(_project_root).parts[:1] + if first in ((top,), (f"{top}.py",)): + names.append(name) + break # Collect first: namespace package paths can depend on cached parents. for name in names: sys.modules.pop(name, None) diff --git a/tests/unit/test_openai_agents_loader.py b/tests/unit/test_openai_agents_loader.py index 2773d9a..f558bcf 100644 --- a/tests/unit/test_openai_agents_loader.py +++ b/tests/unit/test_openai_agents_loader.py @@ -2,6 +2,7 @@ from __future__ import annotations +import importlib import sys from collections.abc import Iterator from pathlib import Path @@ -15,7 +16,10 @@ def isolate_project_imports(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: monkeypatch.setattr(sys, "path", sys.path.copy()) monkeypatch.setattr(loader, "_project_root", None, raising=False) - names = {"agent", "project_helpers", "project_helpers.values", "mutiny_project_adapter"} + names = { + "agent", "project_helpers", "project_helpers.values", + "mutiny_project_adapter", "fakesdk", + } saved = {name: sys.modules[name] for name in names if name in sys.modules} for name in names: sys.modules.pop(name, None) @@ -101,3 +105,42 @@ def test_module_attribute_refs_follow_selected_project(tmp_path: Path) -> None: ) loader.ensure_project_on_path(project) assert loader.load_agent_from_ref("agent:support_agent").name == marker + + +@pytest.mark.parametrize( + "dependency_path", + [".venv/lib/python3.12/site-packages", "packages/fakesdk/src"], +) +def test_switch_preserves_dependencies_on_separate_import_paths( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, dependency_path: str +) -> None: + first = make_project(tmp_path / "first", "A") + second = make_project(tmp_path / "second", "B") + dependency_root = first / dependency_path + package = dependency_root / "fakesdk" + package.mkdir(parents=True) + (package / "__init__.py").write_text("class Agent: pass\n", encoding="utf-8") + monkeypatch.syspath_prepend(str(dependency_root)) + + assert loader.load_adapter_factory(first)() == "A" + sdk = importlib.import_module("fakesdk") + agent = sdk.Agent() + assert loader.load_adapter_factory(second)() == "B" + + assert sys.modules.get("fakesdk") is sdk + assert importlib.import_module("fakesdk") is sdk + assert isinstance(agent, sdk.Agent) + + +def test_switch_from_checkout_preserves_mutiny_and_pydantic(tmp_path: Path) -> None: + import mutiny_core + import pydantic + + checkout = Path(__file__).resolve().parents[2] + loader.ensure_project_on_path(checkout) + loader.ensure_project_on_path(tmp_path) + + assert sys.modules.get("mutiny_core") is mutiny_core + assert sys.modules.get("pydantic") is pydantic + assert importlib.import_module("mutiny_core") is mutiny_core + assert importlib.import_module("pydantic") is pydantic