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) missing.append(dep)
if missing: if missing:
print("\n The selected backends need extra packages:") print("\n The selected backends need extra packages:")
print(f" uv pip install {' '.join(missing)}") print(f" Missing: {', '.join(missing)}")
print(" Run that inside the hermes venv, then re-run setup.") 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]: 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 import mem0
installed_ver = getattr(mem0, "__version__", None) installed_ver = getattr(mem0, "__version__", None)
if installed_ver and tuple(int(x) for x in installed_ver.split(".")[:3]) < (2, 0, 7): 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:]) flags = parse_flags(sys.argv[1:])
handler = _MODE_HANDLERS.get(flags["mode"]) handler = _MODE_HANDLERS.get(flags["mode"])
flags["_mode_from_flag"] = handler is not None flags["_mode_from_flag"] = handler is not None

View File

@@ -787,19 +787,8 @@ def interactive_setup() -> None:
def _install_hint() -> str: def _install_hint() -> str:
"""Build the Teams install hint string. """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"
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}"
def register(ctx) -> None: 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("--cache", type=Path)
parser.add_argument("--extra", dest="extras", action="append", default=[]) parser.add_argument("--extra", dest="extras", action="append", default=[])
parser.add_argument("--group", dest="groups", 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("--all-extras", action="store_true")
parser.add_argument("--no-install-project", 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") 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: else:
executable = pm.build_environment( executable = pm.build_environment(
source=args.source, out=args.out, python=args.python, cache=args.cache, 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, no_install_project=args.no_install_project, frozen=not args.resolve,
sealed=args.sealed, offline=args.offline, explicit=True, sealed=args.sealed, offline=args.offline, explicit=True,
) )

View File

@@ -237,7 +237,7 @@ def _python_operation(operation: str, arguments: dict):
def build_environment( def build_environment(
*, source: Path, out: Path, python: Path | None = None, *, source: Path, out: Path, python: Path | None = None,
cache: Path | None = None, env: Mapping[str, str] | 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, all_extras: bool = False, no_install_project: bool = False,
frozen: bool = True, sealed: bool = False, offline: bool = False, frozen: bool = True, sealed: bool = False, offline: bool = False,
explicit: bool = False, timeout: int = 1800, explicit: bool = False, timeout: int = 1800,
@@ -245,7 +245,7 @@ def build_environment(
"""Build a validated Python environment without exposing install machinery.""" """Build a validated Python environment without exposing install machinery."""
return Path(_python_operation("build_environment", { return Path(_python_operation("build_environment", {
"source": Path(source), "out": Path(out), "python": python, "cache": cache, "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, "all_extras": all_extras, "no_install_project": no_install_project,
"frozen": frozen, "sealed": sealed, "offline": offline, "frozen": frozen, "sealed": sealed, "offline": offline,
"explicit": explicit, "timeout": timeout, "explicit": explicit, "timeout": timeout,

View File

@@ -188,7 +188,7 @@ class PythonEnvironment:
def sync(self, source: Path, *, extras: Sequence[str] = (), groups: Sequence[str] = (), def sync(self, source: Path, *, extras: Sequence[str] = (), groups: Sequence[str] = (),
timeout: int = 1800, frozen: bool = True, all_extras: bool = False, timeout: int = 1800, frozen: bool = True, all_extras: bool = False,
no_install_project: bool = False, locked: 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. """Install the root and every member; resolve only in a writable workspace.
``frozen=False`` is reserved for the caller-owned generated workspace, ``frozen=False`` is reserved for the caller-owned generated workspace,
@@ -196,6 +196,8 @@ class PythonEnvironment:
""" """
from pm.workspace import classify_uv_failure 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: if not frozen:
self.lock(source, timeout=timeout) self.lock(source, timeout=timeout)
# Locking members alone is insufficient: plain sync only installs root deps. # Locking members alone is insufficient: plain sync only installs root deps.
@@ -216,7 +218,7 @@ class PythonEnvironment:
for extra in sorted(set(extras)): for extra in sorted(set(extras)):
command += ["--extra", extra] command += ["--extra", extra]
for group in sorted(set(groups)): 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) result = self._run(command, cwd=source, timeout=timeout)
if result.returncode: if result.returncode:
raise classify_uv_failure("sync", result.returncode, result.stderr or result.stdout) 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( def build_environment(
*, source: Path, out: Path, python: Path | None = None, *, source: Path, out: Path, python: Path | None = None,
cache: Path | None = None, env: Mapping[str, str] | 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, all_extras: bool = False, no_install_project: bool = False,
frozen: bool = True, sealed: bool = False, offline: bool = False, frozen: bool = True, sealed: bool = False, offline: bool = False,
explicit: bool = False, timeout: int = 1800, explicit: bool = False, timeout: int = 1800,
@@ -57,7 +57,7 @@ def build_environment(
out.mkdir(parents=True) out.mkdir(parents=True)
try: try:
environment.create() 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) no_install_project=no_install_project, frozen=frozen, timeout=timeout)
environment.check() environment.check()
if sealed: if sealed:

View File

@@ -39,7 +39,7 @@ _BLOCK_MESSAGE = (
"See: https://hermes-agent.nousresearch.com/docs/getting-started/installation\n" "See: https://hermes-agent.nousresearch.com/docs/getting-started/installation\n"
"\n" "\n"
"If you are developing, use an editable install instead:\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" "\n"
"If you are building with Nix (uv2nix), this error should not fire —\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." "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() 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): def test_child_output_is_live_and_keeps_explicit_index_credentials(tmp_path):
import io import io
from pm.environment import PythonEnvironment 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(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.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=..., 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.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_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. | | `pm.ensure_python_tool(name, requirements, executable, explicit=True)` | Prepare an isolated tool and return its executable path. |