Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down
146 changes: 146 additions & 0 deletions tests/unit/test_openai_agents_loader.py
Original file line number Diff line number Diff line change
@@ -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
Loading