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..8ce2a74 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,46 @@ 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 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() + 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) + 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) + 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 +106,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..f558bcf --- /dev/null +++ b/tests/unit/test_openai_agents_loader.py @@ -0,0 +1,146 @@ +"""Project-local imports must follow the currently selected project.""" + +from __future__ import annotations + +import importlib +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", "fakesdk", + } + 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 + + +@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