Third-party directory plugins declare python deps two ways; both now install through the workspace union (settled design: .hermes/plans/2026-09-02_164500-plugin-deps-workspace-union.md): - materialize_legacy_pyproject(): a plugin.yaml with pip_dependencies/ python_dependencies and no user-owned pyproject gets one GENERATED in its dir (specs carried verbatim, 'GENERATED by pm' header marks pm's own output — a user pyproject is never touched, a generated one is rewritten on spec change, byte-identical regen is a no-op). - scan_plugin(): auto-pickup classification of one plugin dir — pyproject (python), package.json (node sidecar), packages.py (pm store binaries), legacy manifest deps (bridge). - plugins.py manifest parser: pip_dependencies was whitelisted-but- silently-dropped (the memory-plugin legacy key real external plugins like basic-memory use); now parsed into python_dependencies with a deprecation warning naming the migration path. - _warn_python_dependencies(): the 'declaration seam ONLY' posture is replaced by the bridge — materialize + presence-check, pointing at the pm sync as the installer (conflict = loud refusal there). - memory_setup._provider_extras(): external legacy manifests bridged too, so hermes memory setup heals an external provider's deps through the union instead of print-only advice. tests: 3 new (materialize happy/idempotent/rewrite, skip-modern/ depless, scan classification); tests/pm 155 passed 0 failed.
273 lines
9.4 KiB
Python
273 lines
9.4 KiB
Python
"""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: ``<store_root()>/.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 materialize_legacy_pyproject(plugin_dir: Path) -> Optional[Path]:
|
|
"""Bridge: a plugin declaring legacy ``pip_dependencies`` /
|
|
``python_dependencies`` in plugin.yaml, with no pyproject.toml of its
|
|
own, gets one MATERIALIZED — the same specs, same workspace-union
|
|
install path. Never when lazy installs are off (the sync refuses
|
|
separately); idempotent (regenerating is a no-op when specs match)."""
|
|
manifest = plugin_dir / "plugin.yaml"
|
|
if not manifest.is_file():
|
|
return None
|
|
generated = plugin_dir / "pyproject.toml"
|
|
if generated.is_file():
|
|
# A USER-owned pyproject is the modern plugin shape — nothing to
|
|
# bridge. One WE generated earlier is still bridgeable (spec
|
|
# changes must rewrite it); the GENERATED header marks ours.
|
|
try:
|
|
existing = generated.read_text(encoding="utf-8-sig")
|
|
except OSError:
|
|
return None
|
|
if "GENERATED by pm" not in existing:
|
|
return None
|
|
|
|
try:
|
|
import re
|
|
|
|
text = manifest.read_text(encoding="utf-8-sig")
|
|
except OSError:
|
|
return None
|
|
|
|
specs: list[str] = []
|
|
for key in ("pip_dependencies", "python_dependencies"):
|
|
block = re.search(
|
|
rf"^\s*{key}:\s*\n((?:[ \t]+- [^\n]+\n?)*)", text, re.MULTILINE
|
|
)
|
|
if not block:
|
|
continue
|
|
for line in block.group(1).splitlines():
|
|
item = line.strip()
|
|
if item.startswith("- "):
|
|
spec = item[2:].strip().strip("'\"")
|
|
if spec:
|
|
specs.append(spec)
|
|
|
|
if not specs:
|
|
return None
|
|
|
|
name = plugin_dir.name
|
|
body = (
|
|
"# GENERATED by pm from plugin.yaml pip_dependencies — the legacy\n"
|
|
"# bridge (settled 2026-09-02). Migrate to a real pyproject.toml +\n"
|
|
"# uv.lock; this file is rewritten when the manifest changes.\n"
|
|
"[project]\n"
|
|
f'name = "{name}"\n'
|
|
'version = "0.0.0"\n'
|
|
'requires-python = ">=3.11"\n'
|
|
f'dependencies = [{", ".join(repr(s) for s in specs)}]\n'
|
|
)
|
|
if generated.is_file():
|
|
try:
|
|
if generated.read_text(encoding="utf-8-sig") == body:
|
|
return generated # already current
|
|
except OSError:
|
|
pass
|
|
generated.write_text(body, encoding="utf-8")
|
|
return generated
|
|
|
|
|
|
def scan_plugin(plugin_dir: Path) -> dict:
|
|
"""Auto-pickup scan of one plugin dir: which dep surfaces it declares.
|
|
Priority (settled): pyproject (python), package.json (node sidecar),
|
|
packages.py (pm store binaries), legacy manifest deps (bridge)."""
|
|
found: dict = {
|
|
"pyproject": (plugin_dir / "pyproject.toml").is_file(),
|
|
"package_json": (plugin_dir / "package.json").is_file(),
|
|
"packages_py": (plugin_dir / "packages.py").is_file(),
|
|
"legacy_deps": _is_member_candidate(plugin_dir)
|
|
and not (plugin_dir / "pyproject.toml").is_file(),
|
|
"dir": plugin_dir,
|
|
}
|
|
return found
|
|
|
|
|
|
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
|