refactor(pm): own group-only builds and finish dependency hints

This commit is contained in:
ethernet
2026-09-11 18:25:05 -04:00
parent d08ff92751
commit 8b56a9306f
9 changed files with 33 additions and 24 deletions

View File

@@ -448,8 +448,8 @@ def _install_provider_deps(llm_id: str, embedder_id: str, vector_id: str) -> Non
missing.append(dep)
if missing:
print("\n The selected backends need extra packages:")
print(f" uv pip install {' '.join(missing)}")
print(" Run that inside the hermes venv, then re-run setup.")
print(f" Missing: {', '.join(missing)}")
print(" Declare these requirements in the plugin's pyproject.toml, then run `hermes pm install` and restart Hermes.")
def _probe(fn, ok: str, fail: str, exc=Exception) -> tuple[bool, str]:
@@ -510,7 +510,7 @@ def post_setup(hermes_home: str, config: dict) -> None:
import mem0
installed_ver = getattr(mem0, "__version__", None)
if installed_ver and tuple(int(x) for x in installed_ver.split(".")[:3]) < (2, 0, 7):
print(f"\n ⚠ mem0ai {installed_ver} installed but >=2.0.7 required.\n Run: uv pip install --python {sys.executable} 'mem0ai>=2.0.7'")
print(f"\n ⚠ mem0ai {installed_ver} installed but >=2.0.7 required.\n Run `hermes pm repair`, then restart Hermes.")
flags = parse_flags(sys.argv[1:])
handler = _MODE_HANDLERS.get(flags["mode"])
flags["_mode_from_flag"] = handler is not None

View File

@@ -787,19 +787,8 @@ def interactive_setup() -> None:
def _install_hint() -> str:
"""Build the Teams install hint string.
Prefers ``uv sync --frozen --extra teams`` (respects pyproject pinning);
falls back to a plain pip install of the two packages if that is
unavailable. Restarting the gateway also auto-installs via pm.
"""
try:
cmd = "uv sync --frozen --extra teams"
except Exception: # pragma: no cover — defensive
cmd = None
if not cmd:
cmd = f"{sys.executable} -m pip install microsoft-teams-apps aiohttp"
return f"Teams SDK missing — restart the gateway to auto-install, or run: {cmd}"
"""Point to the setup flow that requests PM's declared Teams extra."""
return "Teams SDK missing — run `hermes setup`, configure Teams, then restart the gateway"
def register(ctx) -> None:

View File

@@ -17,6 +17,7 @@ def main(argv: Sequence[str] | None = None) -> int:
parser.add_argument("--cache", type=Path)
parser.add_argument("--extra", dest="extras", action="append", default=[])
parser.add_argument("--group", dest="groups", action="append", default=[])
parser.add_argument("--only-groups", action="store_true", help="install selected groups without the application")
parser.add_argument("--all-extras", action="store_true")
parser.add_argument("--no-install-project", action="store_true")
parser.add_argument("--resolve", action="store_true", help="resolve the source lock before building")
@@ -77,7 +78,7 @@ def main(argv: Sequence[str] | None = None) -> int:
else:
executable = pm.build_environment(
source=args.source, out=args.out, python=args.python, cache=args.cache,
extras=args.extras, groups=args.groups, all_extras=args.all_extras,
extras=args.extras, groups=args.groups, only_groups=args.only_groups, all_extras=args.all_extras,
no_install_project=args.no_install_project, frozen=not args.resolve,
sealed=args.sealed, offline=args.offline, explicit=True,
)

View File

@@ -237,7 +237,7 @@ def _python_operation(operation: str, arguments: dict):
def build_environment(
*, source: Path, out: Path, python: Path | None = None,
cache: Path | None = None, env: Mapping[str, str] | None = None,
extras: Sequence[str] = (), groups: Sequence[str] = (),
extras: Sequence[str] = (), groups: Sequence[str] = (), only_groups: bool = False,
all_extras: bool = False, no_install_project: bool = False,
frozen: bool = True, sealed: bool = False, offline: bool = False,
explicit: bool = False, timeout: int = 1800,
@@ -245,7 +245,7 @@ def build_environment(
"""Build a validated Python environment without exposing install machinery."""
return Path(_python_operation("build_environment", {
"source": Path(source), "out": Path(out), "python": python, "cache": cache,
"env": dict(env) if env is not None else None, "extras": list(extras), "groups": list(groups),
"env": dict(env) if env is not None else None, "extras": list(extras), "groups": list(groups), "only_groups": only_groups,
"all_extras": all_extras, "no_install_project": no_install_project,
"frozen": frozen, "sealed": sealed, "offline": offline,
"explicit": explicit, "timeout": timeout,

View File

@@ -188,7 +188,7 @@ class PythonEnvironment:
def sync(self, source: Path, *, extras: Sequence[str] = (), groups: Sequence[str] = (),
timeout: int = 1800, frozen: bool = True, all_extras: bool = False,
no_install_project: bool = False, locked: bool = False,
no_default_groups: bool = False) -> None:
no_default_groups: bool = False, only_groups: bool = False) -> None:
"""Install the root and every member; resolve only in a writable workspace.
``frozen=False`` is reserved for the caller-owned generated workspace,
@@ -196,6 +196,8 @@ class PythonEnvironment:
"""
from pm.workspace import classify_uv_failure
if only_groups and (not groups or extras or all_extras):
raise ValueError("group-only builds require groups and cannot select extras")
if not frozen:
self.lock(source, timeout=timeout)
# Locking members alone is insufficient: plain sync only installs root deps.
@@ -216,7 +218,7 @@ class PythonEnvironment:
for extra in sorted(set(extras)):
command += ["--extra", extra]
for group in sorted(set(groups)):
command += ["--group", group]
command += ["--only-group" if only_groups else "--group", group]
result = self._run(command, cwd=source, timeout=timeout)
if result.returncode:
raise classify_uv_failure("sync", result.returncode, result.stderr or result.stdout)

View File

@@ -28,7 +28,7 @@ def _require_install_allowed(explicit: bool) -> None:
def build_environment(
*, source: Path, out: Path, python: Path | None = None,
cache: Path | None = None, env: Mapping[str, str] | None = None,
extras: Sequence[str] = (), groups: Sequence[str] = (),
extras: Sequence[str] = (), groups: Sequence[str] = (), only_groups: bool = False,
all_extras: bool = False, no_install_project: bool = False,
frozen: bool = True, sealed: bool = False, offline: bool = False,
explicit: bool = False, timeout: int = 1800,
@@ -57,7 +57,7 @@ def build_environment(
out.mkdir(parents=True)
try:
environment.create()
environment.sync(source, extras=extras, groups=groups, all_extras=all_extras,
environment.sync(source, extras=extras, groups=groups, only_groups=only_groups, all_extras=all_extras,
no_install_project=no_install_project, frozen=frozen, timeout=timeout)
environment.check()
if sealed:

View File

@@ -39,7 +39,7 @@ _BLOCK_MESSAGE = (
"See: https://hermes-agent.nousresearch.com/docs/getting-started/installation\n"
"\n"
"If you are developing, use an editable install instead:\n"
" uv sync # or: uv pip install -e .\n"
" source ./activate # PowerShell: . .\\activate.ps1\n"
"\n"
"If you are building with Nix (uv2nix), this error should not fire —\n"
"the Hermes Nix derivation sets HERMES_NIX_BUILD=1. If it does, file a bug."

View File

@@ -199,6 +199,22 @@ def test_public_dependency_only_build_needs_no_application_source(installable_pr
assert not Path(env["HERMES_HOME"]).exists()
def test_group_only_build_excludes_application_dependencies(locked_project, tmp_path):
import pm
source, _, env = locked_project
metadata = source / "pyproject.toml"
metadata.write_text(metadata.read_text() + '\n[dependency-groups]\nicons=["chosen-dep==1.0"]\n')
pm.lock_project(source, python=Path(sys.executable), cache=tmp_path / "cache", env=env,
offline=True, explicit=True)
python = pm.build_environment(source=source, out=tmp_path / "icons", groups=["icons"],
only_groups=True, python=Path(sys.executable), cache=tmp_path / "cache",
env=env, offline=True, explicit=True)
assert _run([str(python), "-I", "-c", "import chosen_dep, importlib.util; "
"assert importlib.util.find_spec('base_dep') is None; print(chosen_dep.__version__)"],
cwd=tmp_path, env=env) == "1.0"
def test_child_output_is_live_and_keeps_explicit_index_credentials(tmp_path):
import io
from pm.environment import PythonEnvironment

View File

@@ -356,6 +356,7 @@ Use the public `pm` module for Python dependency work:
| `pm.sync_venv(extras, explicit=True)` | Prepare and select the complete application dependency union, including enabled plugins. |
| `pm.sync_venv(repair=True, explicit=True)` | Replay the recorded dependency set in a new application generation. |
| `pm.build_environment(source=..., out=..., explicit=True)` | Build and validate a fresh caller-owned output. No plugin discovery or application selection. |
| `pm.build_environment(source=..., out=..., groups=[...], only_groups=True, explicit=True)` | Build only the selected locked dependency groups, without application dependencies. Used by icon builds. |
| `pm.lock_project(source, explicit=True)` | Refresh an explicit project's lock without selecting an environment. |
| `pm.ensure_environment(name, requirements, explicit=True)` | Prepare and select an isolated dependency generation. Return its Python path. |
| `pm.ensure_python_tool(name, requirements, executable, explicit=True)` | Prepare an isolated tool and return its executable path. |