375 lines
14 KiB
Python
375 lines
14 KiB
Python
"""pm.workspace: the generated uv-workspace root for plugin deps.
|
|
|
|
The workspace root is a pm-GENERATED project (never the committed
|
|
pyproject.toml — sealed installs are read-only and member lists are
|
|
machine-specific). Its pyproject = core's pyproject verbatim +
|
|
``[tool.uv.workspace] members`` pointing at each enabled plugin dir via
|
|
relative ``../``-escaping paths (proven to resolve). ``uv lock`` unions
|
|
core + plugin deps into ONE lock; conflict = loud refusal.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
import pm.workspace as ws
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def isolated_machine_home(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
|
|
|
|
|
@pytest.fixture
|
|
def layout(tmp_path, monkeypatch):
|
|
"""A fake install: core repo with pyproject, plugin dirs, store."""
|
|
core = tmp_path / "core"
|
|
core.mkdir()
|
|
(core / "pyproject.toml").write_text(
|
|
"[project]\n"
|
|
'name = "hermes-agent"\n'
|
|
'version = "0.1.0"\n'
|
|
'requires-python = ">=3.11"\n'
|
|
'dependencies = ["httpx==0.28.1"]\n',
|
|
encoding="utf-8",
|
|
)
|
|
plugins = tmp_path / "home" / "plugins"
|
|
plug_a = plugins / "plug-a"
|
|
plug_a.mkdir(parents=True)
|
|
(plug_a / "plugin.yaml").write_text("name: plug-a\n", encoding="utf-8")
|
|
(plug_a / "pyproject.toml").write_text(
|
|
"[project]\nname = \"plug-a\"\nversion = \"0.1.0\"\n"
|
|
'requires-python = ">=3.11"\ndependencies = ["rich==13.9.4"]\n',
|
|
encoding="utf-8",
|
|
)
|
|
store = tmp_path / "store"
|
|
store.mkdir()
|
|
monkeypatch.setattr(ws.paths, "repo_root", lambda: core)
|
|
monkeypatch.setattr(ws.paths, "store_root", lambda: store)
|
|
return tmp_path, core, plug_a, store
|
|
|
|
|
|
def test_workspace_root_is_per_install_not_in_the_store(layout):
|
|
from hermes_cli.runtime_paths import install_state_dir
|
|
_, core, _, store = layout
|
|
assert ws.workspace_root() == install_state_dir(core) / ".pm-workspace"
|
|
assert not ws.workspace_root().is_relative_to(store)
|
|
|
|
|
|
def test_build_writes_core_pyproject_verbatim(layout):
|
|
_, core, plug_a, _ = layout
|
|
root = ws.build_root([plug_a])
|
|
text = (root / "pyproject.toml").read_text(encoding="utf-8")
|
|
core_text = (core / "pyproject.toml").read_text(encoding="utf-8")
|
|
# core's project table is carried verbatim (name, deps, requires-python)
|
|
assert 'name = "hermes-agent"' in text
|
|
assert 'dependencies = ["httpx==0.28.1"]' in text
|
|
assert 'requires-python = ">=3.11"' in text
|
|
# nothing else was invented
|
|
for line in core_text.strip().splitlines():
|
|
assert line in text
|
|
|
|
|
|
def test_members_keep_their_source_with_the_generation(layout):
|
|
import tomllib
|
|
|
|
_, _, plug_a, _ = layout
|
|
root = ws.build_root([plug_a])
|
|
document = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8"))
|
|
[relative] = document["tool"]["uv"]["workspace"]["members"]
|
|
copied = root / relative / "pyproject.toml"
|
|
assert copied.resolve().is_relative_to(root.resolve())
|
|
before = copied.read_bytes()
|
|
assert before == (plug_a / "pyproject.toml").read_bytes()
|
|
(plug_a / "pyproject.toml").write_bytes(b"changed after publication")
|
|
assert copied.read_bytes() == before
|
|
|
|
|
|
def test_build_is_idempotent(layout):
|
|
_, _, plug_a, _ = layout
|
|
ws.build_root([plug_a])
|
|
first = (ws.workspace_root() / "pyproject.toml").read_text(encoding="utf-8")
|
|
ws.build_root([plug_a])
|
|
second = (ws.workspace_root() / "pyproject.toml").read_text(encoding="utf-8")
|
|
assert first == second
|
|
|
|
|
|
def test_zero_plugins_still_builds_a_root_with_no_members(layout):
|
|
_, _, _, _ = layout
|
|
root = ws.build_root([])
|
|
text = (root / "pyproject.toml").read_text(encoding="utf-8")
|
|
assert 'name = "hermes-agent"' in text
|
|
assert "[tool.uv.workspace]" not in text or "members = []" in text
|
|
|
|
|
|
def test_member_stamp_hash_changes_with_plugin_set(layout):
|
|
_, _, plug_a, _ = layout
|
|
stamp_empty = ws.members_stamp([])
|
|
stamp_a = ws.members_stamp([plug_a])
|
|
stamp_b = ws.members_stamp([plug_a, plug_a]) # dedupes to same set
|
|
assert stamp_empty != stamp_a
|
|
assert stamp_a == stamp_b
|
|
|
|
|
|
def test_member_stamp_changes_when_pyproject_content_changes(tmp_path):
|
|
"""Task 4 contract: a pulled plugin with changed pins must move the
|
|
stamp — path-only hashing left dep bumps invisible to the venv sync."""
|
|
plug = tmp_path / "plug"
|
|
plug.mkdir()
|
|
(plug / "pyproject.toml").write_text(
|
|
'dependencies = ["pkg==1.0.0"]\n', encoding="utf-8"
|
|
)
|
|
before = ws.members_stamp([plug])
|
|
# the plugin update: same dir, new pins
|
|
(plug / "pyproject.toml").write_text(
|
|
'dependencies = ["pkg==2.0.0"]\n', encoding="utf-8"
|
|
)
|
|
after = ws.members_stamp([plug])
|
|
assert before != after
|
|
# no pyproject at all: still hashable (the dir identity carries it)
|
|
bare = tmp_path / "bare"
|
|
bare.mkdir()
|
|
assert ws.members_stamp([bare]) != before
|
|
|
|
|
|
def test_member_stamp_missing_pyproject_does_not_crash(tmp_path):
|
|
"""A member dir whose pyproject vanished mid-scan hashes on path only."""
|
|
plug = tmp_path / "ghost"
|
|
plug.mkdir()
|
|
(plug / "pyproject.toml").write_text("x\n", encoding="utf-8")
|
|
first = ws.members_stamp([plug])
|
|
(plug / "pyproject.toml").unlink()
|
|
second = ws.members_stamp([plug])
|
|
assert first != second # content term dropped out, stamp moved
|
|
|
|
|
|
def test_enabled_member_dirs_finds_enabled_dep_plugins(tmp_path, monkeypatch):
|
|
plugins = tmp_path / "plugins"
|
|
plugins.mkdir(parents=True)
|
|
|
|
# modern plugin: pyproject.toml
|
|
modern = plugins / "modern-plug"
|
|
modern.mkdir()
|
|
(modern / "pyproject.toml").write_text("[project]\n", encoding="utf-8")
|
|
|
|
# legacy plugin: pip_dependencies in plugin.yaml, no pyproject
|
|
legacy = plugins / "legacy-plug"
|
|
legacy.mkdir()
|
|
(legacy / "plugin.yaml").write_text(
|
|
"name: legacy-plug\npip_dependencies:\n - \"requests>=2\"\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
# dep-less plugin: neither — not a member even when enabled
|
|
plain = plugins / "plain-plug"
|
|
plain.mkdir()
|
|
(plain / "plugin.yaml").write_text("name: plain-plug\n", encoding="utf-8")
|
|
|
|
# dep-carrying but NOT-ENABLED plugin — must not join the union
|
|
orphan = plugins / "orphan-plug"
|
|
orphan.mkdir()
|
|
(orphan / "pyproject.toml").write_text("[project]\n", encoding="utf-8")
|
|
|
|
# enabled order = enable recency (legacy enabled first/older, modern
|
|
# newest LAST) — order must carry through for the bisect tiebreak.
|
|
monkeypatch.setattr(
|
|
"pm.plugins_state.enabled_plugins_ordered",
|
|
lambda: {plugins: ["legacy-plug", "modern-plug", "plain-plug"]},
|
|
)
|
|
found = ws.enabled_member_dirs()
|
|
names = [p.name for p in found]
|
|
assert names == ["legacy-plug", "modern-plug"]
|
|
assert "orphan-plug" not in names
|
|
|
|
|
|
def test_enabled_member_dirs_empty_when_nothing_enabled(tmp_path, monkeypatch):
|
|
plugins = tmp_path / "plugins"
|
|
plugins.mkdir(parents=True)
|
|
member = plugins / "member"
|
|
member.mkdir()
|
|
(member / "pyproject.toml").write_text("[project]\n", encoding="utf-8")
|
|
|
|
monkeypatch.setattr(
|
|
"pm.plugins_state.enabled_plugins_ordered", lambda: {}
|
|
)
|
|
assert ws.enabled_member_dirs() == []
|
|
|
|
|
|
def test_scan_plugin_classifies_dep_surfaces(tmp_path):
|
|
full = tmp_path / "full-plug"
|
|
full.mkdir()
|
|
for name in ("pyproject.toml", "package.json", "packages.py", "plugin.yaml"):
|
|
(full / name).write_text("x\n", encoding="utf-8")
|
|
scan = ws.scan_plugin(full)
|
|
assert scan["pyproject"] and scan["package_json"] and scan["packages_py"]
|
|
assert not scan["legacy_deps"]
|
|
|
|
legacy = tmp_path / "legacy-plug"
|
|
legacy.mkdir()
|
|
(legacy / "plugin.yaml").write_text(
|
|
"name: legacy\npip_dependencies:\n - \"x>=1\"\n", encoding="utf-8"
|
|
)
|
|
scan = ws.scan_plugin(legacy)
|
|
assert scan["legacy_deps"] and not scan["pyproject"]
|
|
|
|
|
|
def test_enabled_member_dirs_ignores_non_profile_entries(tmp_path, monkeypatch):
|
|
import pm.plugins_state as pstate
|
|
|
|
home = tmp_path / "home"
|
|
profiles = home / "profiles"
|
|
profiles.mkdir(parents=True)
|
|
member = profiles / "work" / "plugins" / "member"
|
|
member.mkdir(parents=True)
|
|
(member / "pyproject.toml").write_text("[project]\n", encoding="utf-8")
|
|
(profiles / "work" / "config.yaml").write_text(
|
|
"plugins:\n enabled: [member]\n", encoding="utf-8",
|
|
)
|
|
(profiles / "README.txt").write_text("not a profile", encoding="utf-8")
|
|
monkeypatch.setattr("hermes_cli.runtime_paths.dependency_home_root", lambda: home)
|
|
|
|
assert pstate._all_homes() == [home, profiles / "work"]
|
|
assert ws.enabled_member_dirs() == [member]
|
|
|
|
|
|
# --- classified failures + staging surface (FINAL-RUNTIME-CONTRACT) ---
|
|
|
|
|
|
def test_classify_resolver_conflict_is_resolutionconflict():
|
|
from pm.package import InstallError
|
|
from pm.workspace import ResolutionConflict, classify_uv_failure
|
|
|
|
err = classify_uv_failure(
|
|
"lock", 1,
|
|
" x No solution found for `hermes-agent>=0.1.0` because only the "
|
|
"following versions are available:\n",
|
|
)
|
|
assert isinstance(err, ResolutionConflict)
|
|
assert isinstance(err, InstallError)
|
|
assert "no solution found" in str(err).lower()
|
|
|
|
|
|
def test_classify_network_failure_stays_generic():
|
|
from pm.workspace import ResolutionConflict, classify_uv_failure
|
|
|
|
err = classify_uv_failure("lock", 1, "error: Failed to fetch https://pypi.org (timed out)")
|
|
assert not isinstance(err, ResolutionConflict)
|
|
|
|
|
|
def test_sync_failure_is_never_a_conflict(tmp_path, monkeypatch):
|
|
"""--frozen: the lock already resolved, so a sync failure (download,
|
|
build, tooling) must stay generic — it must not disable plugins."""
|
|
from pm.package import InstallError
|
|
from pm.workspace import ResolutionConflict
|
|
|
|
class FakeProc:
|
|
returncode = 1
|
|
stderr = "error: Failed to download wheel (connection reset)"
|
|
stdout = ""
|
|
|
|
monkeypatch.setattr(ws, "_generate_pyproject", lambda *a, **k: (Path("/x/ws"), False))
|
|
monkeypatch.setattr("pm._uv._toolchain", lambda **kwargs: (Path("uv"), Path("pm-python")))
|
|
|
|
captured = {}
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
captured["cmd"] = cmd
|
|
return FakeProc()
|
|
|
|
monkeypatch.setattr(ws.subprocess, "run", fake_run)
|
|
with pytest.raises(InstallError) as excinfo:
|
|
ws.lock_and_sync([], [], venv_dir=tmp_path / "candidate")
|
|
assert not isinstance(excinfo.value, ResolutionConflict)
|
|
|
|
|
|
def test_staging_root_and_env_are_honored_without_live_mutation(monkeypatch, tmp_path):
|
|
"""lock_and_sync must resolve into the PARENT-SUPPLIED staging root +
|
|
venv, pass a COPY of the environment (never mutate the live one), and
|
|
leave the default generated root untouched."""
|
|
staging = tmp_path / "staging-ws"
|
|
staging.mkdir()
|
|
|
|
seen = {}
|
|
|
|
class FakeProc:
|
|
returncode = 0
|
|
stderr = ""
|
|
stdout = ""
|
|
|
|
def fake_run(cmd, cwd=None, env=None, **kwargs):
|
|
seen["cwd"] = cwd
|
|
seen["env"] = env
|
|
return FakeProc()
|
|
|
|
monkeypatch.setattr(ws, "_generate_pyproject", lambda *a, **k: (staging, False))
|
|
monkeypatch.setattr("pm._uv._toolchain", lambda **kwargs: (Path("uv"), Path("pm-python")))
|
|
monkeypatch.setattr("pm.packages.uv_cache_dir", lambda: tmp_path / "cache")
|
|
monkeypatch.setattr(ws.subprocess, "run", fake_run)
|
|
|
|
live_key = "PM_WORKSPACE_TEST_SENTINEL"
|
|
os.environ[live_key] = "live"
|
|
try:
|
|
ws.lock_and_sync(
|
|
[], [], venv_dir=tmp_path / "staging-venv", root=staging,
|
|
env={"PATH": "/staged/bin", live_key: "staged"},
|
|
)
|
|
|
|
assert Path(seen["cwd"]) == staging
|
|
assert seen["env"][live_key] == "staged" # staged env wins
|
|
assert seen["env"]["UV_CACHE_DIR"] == str(tmp_path / "cache")
|
|
assert seen["env"]["UV_PROJECT_ENVIRONMENT"] == str(tmp_path / "staging-venv")
|
|
assert seen["env"]["UV_PYTHON"] == "pm-python"
|
|
assert os.environ[live_key] == "live" # live env untouched
|
|
finally:
|
|
del os.environ[live_key]
|
|
|
|
|
|
def test_changed_root_seeds_from_committed_lock_unchanged_keeps_extended(
|
|
layout, monkeypatch, tmp_path
|
|
):
|
|
"""Seed the CURRENT resolution without writing shipped bytes: a fresh
|
|
root seeds from the committed lock; an existing root seeds from its
|
|
own extended lock; an explicit parent-supplied seed_lock wins."""
|
|
_, core, plug_a, store = layout
|
|
(core / "uv.lock").write_bytes(b"committed-bytes\n")
|
|
|
|
class FakeProc:
|
|
returncode = 0
|
|
stderr = ""
|
|
stdout = ""
|
|
|
|
monkeypatch.setattr("pm._uv._toolchain", lambda **kwargs: (Path("uv"), Path("pm-python")))
|
|
monkeypatch.setattr(ws.subprocess, "run", lambda cmd, **k: FakeProc())
|
|
|
|
root = ws.workspace_root()
|
|
venv = tmp_path / "venv"
|
|
|
|
# First sync: fresh root -> seeded from the COMMITTED lock.
|
|
ws.lock_and_sync([plug_a], [], venv_dir=venv)
|
|
assert (root / "uv.lock").read_bytes() == b"committed-bytes\n"
|
|
|
|
# Changed surface (new member), root's own EXTENDED lock present ->
|
|
# the current extended resolution is the seed, not the committed one.
|
|
(root / "uv.lock").write_bytes(b"extended-bytes\n")
|
|
plug_b = layout[0] / "home" / "plugins" / "plug-b"
|
|
plug_b.mkdir(parents=True)
|
|
(plug_b / "pyproject.toml").write_text("[project]\n", encoding="utf-8")
|
|
ws.lock_and_sync([plug_a, plug_b], [], venv_dir=venv)
|
|
assert (root / "uv.lock").read_bytes() == b"extended-bytes\n"
|
|
|
|
# Parent-supplied seed_lock wins over both.
|
|
other = tmp_path / "parent-extended.lock"
|
|
other.write_bytes(b"parent-bytes\n")
|
|
plug_c = layout[0] / "home" / "plugins" / "plug-c"
|
|
plug_c.mkdir(parents=True)
|
|
(plug_c / "pyproject.toml").write_text("[project]\n", encoding="utf-8")
|
|
ws.lock_and_sync([plug_a, plug_b, plug_c], [], venv_dir=venv, seed_lock=other)
|
|
assert (root / "uv.lock").read_bytes() == b"parent-bytes\n"
|
|
|
|
# Shipped core lock untouched throughout.
|
|
assert (core / "uv.lock").read_bytes() == b"committed-bytes\n"
|