diff --git a/pm/packages.py b/pm/packages.py index 5e63c51f0b..090b6c24a4 100644 --- a/pm/packages.py +++ b/pm/packages.py @@ -303,10 +303,23 @@ class Venv(StatePackage): h.update(_uv_lock_digest(self.project_root() / "uv.lock")) h.update(",".join(sorted(extras)).encode()) h.update(f"{sys.version_info.major}.{sys.version_info.minor}".encode()) + # Plugin members union into the venv — a changed member set must + # re-sync even when extras and core lock are unchanged. + from pm.workspace import enabled_member_dirs, members_stamp + + h.update(members_stamp(enabled_member_dirs()).encode()) return h.hexdigest() def apply(self, extras: list[str]) -> None: from pm.ensure import uv as pm_uv + from pm.workspace import enabled_member_dirs, lock_and_sync + + member_dirs = enabled_member_dirs() + if member_dirs: + # Plugin deps union into the venv through the generated + # workspace root (one lock, conflict = loud refusal). + lock_and_sync(member_dirs, extras, venv_dir=self.venv_dir()) + return uv_bin, env = pm_uv(venv=self.venv_dir()) if uv_bin is None: diff --git a/pm/workspace.py b/pm/workspace.py new file mode 100644 index 0000000000..faece715b9 --- /dev/null +++ b/pm/workspace.py @@ -0,0 +1,191 @@ +"""The generated uv-workspace root that unions plugin deps into the venv. + +Design (settled 2026-09-02, .hermes/plans/2026-09-02_164500-plugin-deps- +workspace-union.md): + +- The workspace root is pm-GENERATED, never the committed pyproject.toml. + Sealed installs are read-only, and the member list is machine-specific + (which plugins the user enabled). Generated root lives beside the byte + store: ``/.pm-workspace`` — per-install, writable on every + install kind. +- Its pyproject = core's pyproject verbatim + a ``[tool.uv.workspace]`` + members block of relative ``../``-escaping paths to each enabled plugin + dir. Relative members can escape the workspace root (probed live). +- ``uv lock`` resolves core + plugin deps as ONE graph: existing core pins + are preserved (a plugin's range spec does not move them) and a conflict + fails loudly, so the plugin simply does not install. +- ``uv sync --frozen --extra ...`` at the root installs the union into the + project venv; ``UV_PROJECT_ENVIRONMENT`` pins WHICH venv that is. +- Plugin-member extras are banned/ignored for now (settled): uv selects + only ROOT extras, and the root mirrors core's extras. +""" + +from __future__ import annotations + +import hashlib +import os +from pathlib import Path +from typing import Optional + +from pm import paths + +WORKSPACE_DIRNAME = ".pm-workspace" + + +def workspace_root() -> Path: + """The generated workspace root — per-install, beside the byte store.""" + return paths.store_root() / WORKSPACE_DIRNAME + + +def _member_rel(root: Path, plugin_dir: Path) -> str: + """Workspace member string: relative path from the root to the plugin + dir, always escaping the root (``../…``) — probed to resolve.""" + return os.path.relpath(plugin_dir.resolve(), root.resolve()).replace("\\", "/") + + +def members_stamp(plugin_dirs: list[Path]) -> str: + """Content hash of the member SET — order-independent, so venv stamps + compare the set of unioned plugins, not the discovery order.""" + resolved = sorted({str(p.resolve()) for p in plugin_dirs}) + h = hashlib.sha256() + for entry in resolved: + h.update(entry.encode("utf-8")) + h.update(b"\0") + return h.hexdigest() + + +def build_root(plugin_dirs: list[Path]) -> Path: + """(Re)generate the workspace root's pyproject.toml from core's + pyproject + the enabled plugin members. Idempotent — same inputs, + same bytes.""" + root = workspace_root() + root.mkdir(parents=True, exist_ok=True) + + core_pyproject = paths.repo_root() / "pyproject.toml" + core_text = core_pyproject.read_text(encoding="utf-8-sig") + + members = [_member_rel(root, Path(p)) for p in {str(Path(p).resolve()) for p in plugin_dirs}] + + lines = [core_text.rstrip("\n")] + if members: + lines.append("") + lines.append("[tool.uv.workspace]") + lines.append("members = [" + ", ".join(f'"{m}"' for m in sorted(members)) + "]") + + (root / "pyproject.toml").write_text("\n".join(lines) + "\n", encoding="utf-8") + return root + + +def _plugin_dir_roots() -> set[Path]: + """All profile plugin dirs + the default home's, machine-wide.""" + roots: set[Path] = set() + try: + profiles_root = Path.home() / ".hermes" / "profiles" + if profiles_root.is_dir(): + for profile in profiles_root.iterdir(): + if profile.is_dir(): + roots.add(profile / "plugins") + except OSError: + pass + try: + from hermes_constants import get_default_hermes_root + + roots.add(get_default_hermes_root() / "plugins") + except Exception: + pass + return roots + + +def _is_member_candidate(plugin_dir: Path) -> bool: + """A plugin dir is a workspace-member candidate when it declares python + deps: a pyproject.toml (modern), or legacy pip_dependencies/ + python_dependencies in plugin.yaml (the bridge materializes those).""" + try: + if (plugin_dir / "pyproject.toml").is_file(): + return True + manifest = plugin_dir / "plugin.yaml" + if manifest.is_file(): + text = manifest.read_text(encoding="utf-8-sig") + return "pip_dependencies" in text or "python_dependencies" in text + except OSError: + return False + return False + + +def enabled_member_dirs() -> list[Path]: + """Plugin dirs that carry python deps, machine-wide across profiles. + Per-install union: profiles share the venv, so their enabled plugins + share the resolution graph (settled).""" + member_dirs: list[Path] = [] + for plugins_dir in sorted(_plugin_dir_roots(), key=str): + try: + if not plugins_dir.is_dir(): + continue + entries = sorted(plugins_dir.iterdir(), key=str) + except OSError: + continue + for plugin_dir in entries: + try: + if plugin_dir.is_dir() and _is_member_candidate(plugin_dir): + member_dirs.append(plugin_dir) + except OSError: + continue + return member_dirs + + +def lock_and_sync( + plugin_dirs: list[Path], + extras: Optional[list[str]] = None, + *, + venv_dir: Optional[Path] = None, +) -> None: + """Build the root, then `uv lock` + `uv sync --frozen --extra ...`. + + Raises InstallError on any failure (the caller surfaces the resolver's + message — that IS the plugin-conflict UX). ``venv_dir`` pins + UV_PROJECT_ENVIRONMENT; default is the core project venv. + """ + from pm.package import InstallError + from pm.packages import uv_env + + root = build_root(plugin_dirs) + + if venv_dir is None: + venv_dir = _default_venv_dir() + + uv_bin = _uv_binary() + if uv_bin is None: + raise InstallError("venv", "uv is not installed (pm ensure uv)") + + env = uv_env() + env["UV_PROJECT_ENVIRONMENT"] = str(venv_dir) + + import subprocess + + lock = subprocess.run( + [uv_bin, "lock"], cwd=str(root), env=env, capture_output=True, text=True + ) + if lock.returncode != 0: + raise InstallError("venv", f"uv lock exited {lock.returncode}: {lock.stderr[-600:]}") + + cmd = [uv_bin, "sync", "--frozen"] + for extra in sorted(set(extras or [])): + cmd += ["--extra", extra] + sync = subprocess.run(cmd, cwd=str(root), env=env, capture_output=True, text=True) + if sync.returncode != 0: + raise InstallError( + "venv", f"uv sync exited {sync.returncode}: {sync.stderr[-600:]}" + ) + + +def _default_venv_dir() -> Path: + from pm.packages import Venv + + return Venv().venv_dir() + + +def _uv_binary() -> Optional[str]: + from pm.ensure import uv as pm_uv + + uv_bin, _env = pm_uv(realize=False) + return uv_bin diff --git a/tests/pm/test_workspace.py b/tests/pm/test_workspace.py new file mode 100644 index 0000000000..adf4a43aa6 --- /dev/null +++ b/tests/pm/test_workspace.py @@ -0,0 +1,152 @@ +"""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 + +from pathlib import Path + +import pytest + +import pm.workspace as ws + + +@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_lives_in_the_store(layout): + _, _, _, store = layout + assert ws.workspace_root() == store / ".pm-workspace" + assert ws.workspace_root() == ws.workspace_root() # stable + + +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_are_relative_paths_escaping_the_root(layout): + _, _, plug_a, _ = layout + root = ws.build_root([plug_a]) + text = (root / "pyproject.toml").read_text(encoding="utf-8") + assert "[tool.uv.workspace]" in text + # member must be the relative path from the root to the plugin dir + expected = ws._member_rel(root, plug_a) + assert f'"{expected}"' in text + assert expected.startswith(".."), "member must escape the generated root" + + +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_enabled_member_dirs_finds_pyproject_and_legacy_plugins(tmp_path, monkeypatch): + home = tmp_path / "home" + plugins = home / "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 + plain = plugins / "plain-plug" + plain.mkdir() + (plain / "plugin.yaml").write_text("name: plain-plug\n", encoding="utf-8") + + # not a plugin dir at all + (plugins / "stray.txt").write_text("x", encoding="utf-8") + + monkeypatch.setattr(ws, "_plugin_dir_roots", lambda: {plugins}) + found = ws.enabled_member_dirs() + names = {p.name for p in found} + assert names == {"modern-plug", "legacy-plug"} + + +def test_enabled_member_dirs_survives_unreadable_roots(tmp_path, monkeypatch): + # an OSError mid-scan (dangling junction etc.) must not lose other roots + good = tmp_path / "good-plugins" + good.mkdir() + member = good / "member" + member.mkdir() + (member / "pyproject.toml").write_text("[project]\n", encoding="utf-8") + + class _Broken: + def is_dir(self): + raise OSError("dangling junction") + + monkeypatch.setattr(ws, "_plugin_dir_roots", lambda: {good, _Broken()}) + found = ws.enabled_member_dirs() + assert [p.name for p in found] == ["member"]