refactor(pm): rename the pm.ensure submodule to pm.install; lazy_deps back to the shim

`import pm.ensure` bound the submodule onto the package, shadowing the facade's
`pm.ensure()` function for every later caller in the process (photon's sidecar
start hit `'module' object is not callable`). The module is pm.install now; the
function keeps its name. The facade resolves through `__import__` rather than
`importlib.import_module` so a test that patches import_module globally does not
break attribute access on pm.

tools/lazy_deps.py returns to the 16-line stop_for_relaunch shim the branch wrote
(an origin/main merge had replaced it with main's 775-line implementation); the
project-metadata tests follow. update_cmd re-exports the four old_updater_deps
names the shim tests resolve through hermes_cli.update_cmd.
This commit is contained in:
ethernet committed 2026-09-18 23:27:05 -04:00
1 parent cf61742a47
commit 6a5a6a05d2
62 files changed
+230 -1085

No files matched your search

+1 -1
View File
@@ -187,7 +187,7 @@ COPY hermes_constants.py hermes_constants.py
COPY hermes_cli/__init__.py hermes_cli/runtime_paths.py hermes_cli/runtime_state.py hermes_cli/
COPY scripts/bundles/payload.py scripts/bundles/payload.py
RUN set -eu; \
python3 -c 'from pm.ensure import ensure; [ensure(name, explicit=True) for name in ("uv", "chromium", "npm")]'; \
python3 -c 'from pm import ensure; [ensure(name, explicit=True) for name in ("uv", "chromium", "npm")]'; \
python3 -c 'from pathlib import Path; from pm import installed_package; [Path("/usr/local/bin", command).symlink_to(installed_package(package).binary) for command, package in (("python3", "python"), ("node", "node"), ("npm", "npm"))]'; \
python3 -c 'import shutil; from pathlib import Path; from pm import env_for; Path("/usr/local/bin/npx").symlink_to(shutil.which("npx", path=env_for("npm", base_env={})["PATH"]))'; \
node --version; npm --version; \
+1 -1
View File
@@ -112,7 +112,7 @@ def recover_at_startup(name: str) -> bool:
if name in _attempted:
return False
_attempted.add(name)
from pm.ensure import lazy_installs_allowed
from pm.install import lazy_installs_allowed
if not lazy_installs_allowed():
logger.warning("Memory provider '%s' is not installed; security.allow_lazy_installs is off — "
"run `hermes plugins install %s`.", name, name)
+1 -1
View File
@@ -235,7 +235,7 @@ def step_provision_runtimes() -> dict:
ensure/sync operations own freshness checks and publication under lock.
"""
import pm
from pm.ensure import lazy_installs_allowed, sealed
from pm.install import lazy_installs_allowed, sealed
problems = pm.drift()
if not problems:
+4 -2
View File
@@ -78,8 +78,10 @@ from hermes_cli.update_cmd_config import ( # noqa: F401
from hermes_cli.update_cmd_validation import ( # noqa: F401 — frozen updater surface (tests/compat)
_UPDATE_CRITICAL_MODULES, _critical_module_import_failures,
_validate_critical_modules_import)
from hermes_cli.old_updater_deps import ( # noqa: F401 — frozen updater surface (tests/compat)
_path_uid, _rebuild_desktop_after_update, _update_node_dependencies)
from hermes_cli.old_updater_deps import ( # noqa: F401 — frozen updater surface (tests/compat + test_old_updater_shims)
_capture_active_lazy_features, _npm_lockfile_changed, _path_uid,
_rebuild_desktop_after_update, _refresh_active_lazy_features,
_refresh_active_memory_provider_dependencies, _update_node_dependencies)
from hermes_cli.update_cmd_git import ( # noqa: F401
OFFICIAL_REPO_URL, OFFICIAL_REPO_URLS, SKIP_UPSTREAM_PROMPT_FILE, _ORPHAN_RESCUE_REFS_TO_KEEP,
_ORPHAN_RESCUE_REF_MAX_AGE_DAYS, _add_upstream_remote, _assess_parked_branch_switch,
+5 -3
View File
@@ -17,10 +17,10 @@ machinery in with it.
from __future__ import annotations
import importlib
import sys
_EXPORTS = {
"pm.ensure": (
"pm.install": (
"activate", "check", "drift", "enabled_extras", "env_for", "is_installed",
"installed_package", "lazy_installs_allowed",
),
@@ -46,7 +46,9 @@ def __getattr__(name: str):
module = _HOME.get(name)
if module is None:
raise AttributeError(f"module 'pm' has no attribute {name!r}")
value = getattr(importlib.import_module(module), name)
# Not importlib.import_module: tests patch that globally and the facade must keep resolving.
__import__(module)
value = getattr(sys.modules[module], name)
globals()[name] = value
return value
+1 -1
View File
@@ -16,7 +16,7 @@ def _toolchain(*, realize: bool = True, explicit: bool = False) -> tuple[Path, P
A read-only probe never installs. Windows bundle builds need a verified
writable interpreter so their venv redirectors can run outside the MSIX.
"""
from pm.ensure import (
from pm.install import (
_install, _installed_location, _lockfile, _refuse_lazy,
ensure, lazy_installs_allowed, sealed,
)
+4 -4
View File
@@ -7,7 +7,7 @@ from pathlib import Path
import sys
from types import MappingProxyType
from pm.ensure import InstalledPackage
from pm.install import InstalledPackage
from pm.lock import Lockfile
from pm.package import InstallError
@@ -91,7 +91,7 @@ def build_requirements_environment(
def prepare_tools(names: Sequence[str], *, out: Path, target: str,
cache: Path | None = None) -> Path:
"""Realize an explicit tool closure in a build-owned store, not live state."""
from pm.ensure import _install, _lockfile
from pm.install import _install, _lockfile
from pm.lock import Facts
from pm.package import StatePackage
from pm.registry import walk
@@ -143,7 +143,7 @@ def verified_tools(names: Sequence[str], *, source_store: Path, target: str,
digest to the canonical entry, lock and declared environment; it does not
rerun arbitrary version probes just to read a previously published tool.
"""
from pm.ensure import _identity, _lockfile
from pm.install import _identity, _lockfile
from pm.lock import Facts
from pm.package import StatePackage
from pm.registry import walk
@@ -193,7 +193,7 @@ def stage_tools(names: Sequence[str], *, source_store: Path, out: Path, target:
the ordinary installer owns verification, independent copies and fresh facts.
"""
from contextlib import ExitStack
from pm.ensure import _entry_verified, _install, _lockfile
from pm.install import _entry_verified, _install, _lockfile
from pm.lock import Facts
from pm.package import StatePackage
from pm.registry import walk
+8 -8
View File
@@ -11,7 +11,7 @@ import time
from pathlib import Path
from pm import termux_libs
from pm.ensure import _facts, _lockfile, _store, ensure, stage_only
from pm.install import _facts, _lockfile, _store, ensure, stage_only
from pm.operations import lock_project
from pm.package import InstallError
from pm.paths import repo_root
@@ -125,7 +125,7 @@ def _live_progress(name: str):
def _install_names(names: list[str], target: str | None = None) -> int:
from pm.ensure import _install_operation
from pm.install import _install_operation
failed = 0
with _install_operation() as operation:
@@ -167,7 +167,7 @@ def cmd_install(args) -> int:
names = args.names or source_install_packages(_lockfile().names())
failed = _install_names(names, target=cross_target)
if not args.names:
from pm.ensure import sync_venv
from pm.install import sync_venv
try:
# Default the venv to the [all] feature set — the same thing
@@ -184,7 +184,7 @@ def cmd_install(args) -> int:
def cmd_env(args) -> int:
from pm.ensure import env_for
from pm.install import env_for
names = args.names or _lockfile().names()
print(json.dumps(env_for(*names), indent=2, sort_keys=True))
@@ -192,7 +192,7 @@ def cmd_env(args) -> int:
def cmd_doctor(args) -> int:
from pm.ensure import _identity, _installed_location
from pm.install import _identity, _installed_location
from pm.store import tree_digest
lockfile = _lockfile()
@@ -380,7 +380,7 @@ def cmd_update(args) -> int:
if failed:
return 1
try:
from pm.ensure import sync_venv
from pm.install import sync_venv
sync_venv(explicit=True)
print("✓ venv")
except InstallError as e:
@@ -397,14 +397,14 @@ def cmd_update(args) -> int:
return 1
print("✓ uv.lock refreshed")
try:
from pm.ensure import sync_venv
from pm.install import sync_venv
sync_venv(explicit=True)
print("✓ venv")
except InstallError as e:
print(f"✗ {e}")
return 1
if args.npm:
from pm.ensure import env_for, installed_package
from pm.install import env_for, installed_package
from pm.packages import npm_env
from pm.paths import writable_store_root
+7 -7
View File
@@ -24,7 +24,7 @@ def _members(value):
def _missing_or_refuse(name):
from pm.ensure import _refuse_lazy, is_installed, lazy_installs_allowed
from pm.install import _refuse_lazy, is_installed, lazy_installs_allowed
from pm.registry import walk
missing = [package.name for package in walk([name]) if not is_installed(package.name)]
@@ -35,7 +35,7 @@ def _missing_or_refuse(name):
def _request(operation, arguments, *, callbacks=None, pause_event=None, project_root=None):
from pm import receipt
from pm.ensure import lazy_installs_allowed
from pm.install import lazy_installs_allowed
from pm.registry import get_package, package_definitions
request_id = uuid.uuid4().hex
@@ -158,11 +158,11 @@ def _request(operation, arguments, *, callbacks=None, pause_event=None, project_
def ensure(name, *, base_env=None, explicit=False, progress=None, pause_event=None, download_progress=None) -> Runner:
from pm.ensure import env_for
from pm.install import env_for
from pm.registry import get_package
if is_runtime():
from pm.ensure import ensure as direct
from pm.install import ensure as direct
return direct(name, base_env=base_env, explicit=explicit, progress=progress,
pause_event=pause_event, download_progress=download_progress)
if not explicit and not isinstance(get_package(name), StatePackage):
@@ -188,7 +188,7 @@ def sync_venv(extras=None, *, explicit=False, plugin_dirs=None, extra_plugin_dir
selection = {**selection, "expected_config": _digest(Path(selection["home"]) / "config.yaml") or "missing"}
foreign = project_root is not None and Path(project_root).resolve() != paths.repo_root().resolve()
if is_runtime() and not foreign:
from pm.ensure import sync_venv as direct
from pm.install import sync_venv as direct
return direct(extras, explicit=explicit, plugin_dirs=plugin_dirs,
selection=selection, staged_plugin=staged_plugin, extra_plugin_dirs=extra_plugin_dirs, repair=repair)
_request("sync_venv", {"extras": extras, "explicit": explicit, "repair": repair,
@@ -198,7 +198,7 @@ def sync_venv(extras=None, *, explicit=False, plugin_dirs=None, extra_plugin_dir
def stage_only(name, target, *, progress=None) -> Path:
if is_runtime():
from pm.ensure import stage_only as direct
from pm.install import stage_only as direct
return direct(name, target, progress=progress)
callbacks = {"progress": progress} if progress is not None else {}
return Path(_request("stage_only", {"name": name, "target": target}, callbacks=callbacks))
@@ -284,7 +284,7 @@ def venv_is_current(*, extras: list[str] | None = None, plugin_dirs=None, extra_
project_root: Path | None = None) -> bool:
"""Check through a ready PM, never bootstrap dependencies for a probe."""
if is_runtime() and (project_root is None or Path(project_root).resolve() == paths.repo_root().resolve()):
from pm.ensure import venv_is_current as direct
from pm.install import venv_is_current as direct
return direct(extras=extras, plugin_dirs=plugin_dirs, extra_plugin_dirs=extra_plugin_dirs, project_root=project_root)
members = plugin_dirs
try:
+1 -1
View File
@@ -218,7 +218,7 @@ def activate_dependencies(project_root: Path) -> None:
def activation_environment(project_root: Path) -> dict[str, str]:
"""Read the installed PM environment; do not provision or switch imports."""
from pm.ensure import env_for
from pm.install import env_for
from pm.registry import all_packages
env = env_for(*all_packages())
View File
File renamed without changes.
+2 -2
View File
@@ -19,7 +19,7 @@ from pm.package import InstallError
def _require_install_allowed(explicit: bool) -> None:
from pm.ensure import _refuse_lazy, lazy_installs_allowed
from pm.install import _refuse_lazy, lazy_installs_allowed
if not explicit and not lazy_installs_allowed():
raise _refuse_lazy("venv", "Python dependency operation requires an explicit request")
@@ -189,7 +189,7 @@ def ensure_environment(
An optional tool entrypoint is validated before publication, not afterwards.
"""
from hermes_cli.runtime_state import _lock
from pm.ensure import _refuse_lazy, lazy_installs_allowed
from pm.install import _refuse_lazy, lazy_installs_allowed
from pm.lock import Lockfile, _write
from pm import paths
from pm.store import current_target
+2 -2
View File
@@ -114,7 +114,7 @@ class BinaryPackage(Package):
must find the node it extends on PATH."""
if not self.deps:
return dict(os.environ)
from pm.ensure import env_for
from pm.install import env_for
return env_for(*self.deps)
@@ -537,7 +537,7 @@ class Npm(BinaryPackage):
)
wrapper.chmod(0o755)
return
from pm.ensure import _installed_location, _lockfile
from pm.install import _installed_location, _lockfile
from pm.registry import get_package
node = get_package("node")
+4 -4
View File
@@ -21,12 +21,12 @@ class Operation:
OPERATIONS = {
"ensure": Operation("pm.ensure", None, "state"),
"stage_only": Operation("pm.ensure", None, "always"),
"ensure": Operation("pm.install", None, "state"),
"stage_only": Operation("pm.install", None, "always"),
"stage_tools": Operation("pm.build_operations", None, "never"),
"prepare_tools": Operation("pm.build_operations", None, "always"),
"sync_venv": Operation("pm.ensure", ("venv",), "policy"),
"venv_is_current": Operation("pm.ensure", ("venv",), "never"),
"sync_venv": Operation("pm.install", ("venv",), "policy"),
"venv_is_current": Operation("pm.install", ("venv",), "never"),
"build_environment": Operation("pm.operations", ("uv",), "policy"),
"lock_project": Operation("pm.operations", ("uv",), "policy"),
"ensure_environment": Operation("pm.operations", ("uv",), "policy"),
+1 -1
View File
@@ -255,7 +255,7 @@ def install_node_sidecar(
return None # nothing to install
import pm
from pm.ensure import lazy_installs_allowed
from pm.install import lazy_installs_allowed
# Tool availability does not authorize mutation of the sidecar itself.
if not explicit and not lazy_installs_allowed():
+1 -1
View File
@@ -844,7 +844,7 @@ select = ["PLW1514", "ASYNC210", "ASYNC220", "ASYNC221", "ASYNC251", "TID251"]
[tool.ruff.lint.flake8-tidy-imports.banned-api]
"pm.uv".msg = "Use PM's semantic Python operations, not an installer executable."
"pm.client.uv".msg = "Use PM's semantic Python operations."
"pm.ensure.uv".msg = "The raw uv bridge was removed; use PM operations."
"pm.install.uv".msg = "The raw uv bridge was removed; use PM operations."
"pm._uv".msg = "Tool acquisition is private to PM."
"pm.environment".msg = "The environment engine is private; use PM operations."
"pm.runtime_stage".msg = "Use pm.stage_manager_runtime for build-time bootstrap."
+1 -1
View File
@@ -124,7 +124,7 @@ POST_SWAP_HELPER_MODULES = (
# not extend the historical cutoff: files absent there are skipped.
"pm/__init__.py",
"pm/cli.py",
"pm/ensure.py",
"pm/install.py",
"pm/extras.py",
"pm/lock.py",
"pm/package.py",
+1 -1
View File
@@ -10,7 +10,7 @@ import argparse
from dataclasses import asdict
from pathlib import Path
from pm.ensure import _lockfile
from pm.install import _lockfile
from pm.lock import Facts
from pm.registry import get_package, walk
from pm.store import current_target
+1 -1
View File
@@ -128,7 +128,7 @@ def install(args) -> None:
from pm import build_requirements_environment
from pm.cli import _live_progress
from pm.ensure import ensure, env_for
from pm.install import ensure, env_for
from pm.lock import Facts
from pm.package import compose_env
from pm.packages import uv_cache_dir
+3 -3
View File
@@ -741,7 +741,7 @@
"pm/client.py",
"pm/download_state.py",
"pm/downloader.py",
"pm/ensure.py",
"pm/install.py",
"pm/environment.py",
"pm/extras.py",
"pm/features.py",
@@ -916,7 +916,7 @@
"pm/cli.py",
"pm/client.py",
"pm/download_state.py",
"pm/ensure.py",
"pm/install.py",
"pm/environment.py",
"pm/extras.py",
"pm/features.py",
@@ -980,7 +980,7 @@
"hermes_cli/update_cmd_maint.py:_reload_modules: importlib.reload(module)",
"pm/client.py: module object pm.build_operations requires manual call-graph review",
"pm/client.py: module object pm.operations requires manual call-graph review",
"pm/ensure.py: module object pm.paths requires manual call-graph review",
"pm/install.py: module object pm.paths requires manual call-graph review",
"pm/registry.py:load_package_definitions: importlib.import_module(module_name)",
"tools/browser_tool_origin.py: module object tools.* requires manual call-graph review",
"tools/browser_tool_origin.py: module object tools.browser_tool.* requires manual call-graph review"
+1 -1
View File
@@ -41,7 +41,7 @@ def test_each_start_checks_pm_once_before_dispatch(surface, tmp_path, monkeypatc
def no_install(*args, **kwargs):
pytest.fail("startup must not install or sync")
monkeypatch.setattr(importlib.import_module("pm.ensure"), "check", check)
monkeypatch.setattr(importlib.import_module("pm.install"), "check", check)
monkeypatch.setattr(pm, "ensure", no_install)
monkeypatch.setattr(pm, "sync_venv", no_install)
dispatched = []
@@ -58,7 +58,7 @@ def test_setup_requires_dependencies_and_keeps_the_existing_union(tmp_path, monk
monkeypatch.setenv('HERMES_HOME', str(home))
monkeypatch.setenv('HERMES_RUNTIME_DIR', str(tmp_path / 'tools'))
monkeypatch.setattr(paths, 'repo_root', lambda: core)
ensure = importlib.import_module('pm.ensure')
ensure = importlib.import_module('pm.install')
monkeypatch.setattr(ensure, 'lazy_installs_allowed', lambda: True)
monkeypatch.setattr("pm._uv._toolchain", lambda **kwargs: (Path(uv), Path(sys.executable)))
# Keep the public client and real engine; only tool acquisition is injected.
+3 -3
View File
@@ -149,7 +149,7 @@ def test_provisioning_does_not_use_human_diagnostics(tmp_path, monkeypatch):
import pm
from pm import paths
engine = importlib.import_module("pm.ensure")
engine = importlib.import_module("pm.install")
runtime = tmp_path / "tools"
runtime.mkdir()
(runtime / "facts.json").write_text(json.dumps({"schema": 1, "packages": {}}))
@@ -182,7 +182,7 @@ def test_provision_runtimes_reensures_only_what_pm_names(monkeypatch):
# pm/__init__ rebinds the name `pm.ensure` to the FUNCTION; the module
# object (whose attrs step_provision_runtimes imports at call time)
# comes from sys.modules.
pm_ensure = importlib.import_module("pm.ensure")
pm_ensure = importlib.import_module("pm.install")
ensured = []
monkeypatch.setattr(pm, "drift", lambda: {"node": "outdated", "venv": "out of sync"})
@@ -202,7 +202,7 @@ def test_provision_runtimes_respects_the_lazy_install_policy(monkeypatch):
import pm
pm_ensure = importlib.import_module("pm.ensure")
pm_ensure = importlib.import_module("pm.install")
monkeypatch.setattr(pm, "drift", lambda: {"node": "outdated"})
monkeypatch.setattr(pm_ensure, "sealed", lambda: False)
+1 -1
View File
@@ -18,7 +18,7 @@ def test_check_uses_real_pm_selection_and_keeps_invalid_evidence(admission_env,
monkeypatch.setattr("pm.client.is_runtime", lambda: True)
root, home = admission_env
core = root / 'core'
ensure = importlib.import_module('pm.ensure')
ensure = importlib.import_module('pm.install')
pin_path = root / 'pins.json'
pins = Lockfile(pin_path)
pins.set_pin('python', '1.0', {'any': {'url': 'https://example.invalid/python', 'sha256': 'a' * 64}})
+1 -1
View File
@@ -64,7 +64,7 @@ def probe_pm(root: Path, desktop: str, command: list[str]) -> None:
if any((root / marker).exists() for marker in (".update-incomplete", ".lazy-refresh-incomplete")):
raise RuntimeError("source update left an incomplete marker; refusing launch-time recovery")
sys.path.insert(1, str(site_packages(selected_venv(root))))
from pm.ensure import installed_package, venv_is_current
from pm.install import installed_package, venv_is_current
if not venv_is_current(project_root=root):
raise RuntimeError("installed dependency generation is not current")
+1 -1
View File
@@ -17,7 +17,7 @@ from tests.pm._range_server import RangeHandler, dl_server, url # noqa: F401
def test_cold_consumers_recover_after_upstream_removal(tmp_path, dl_server, monkeypatch, mode):
from pm import artifact_mirror
engine = importlib.import_module("pm.ensure")
engine = importlib.import_module("pm.install")
data = io.BytesIO()
with zipfile.ZipFile(data, "w") as archive:
archive.writestr("tool.txt", b"pinned and preserved")
+2 -2
View File
@@ -395,7 +395,7 @@ def test_ready_tools_do_not_bypass_disabled_lazy_operations(locked_source, tmp_p
import importlib
from pm import build_requirements_environment, check_project_lock, export_requirements
monkeypatch.setattr(importlib.import_module("pm.ensure"), "lazy_installs_allowed", lambda: False)
monkeypatch.setattr(importlib.import_module("pm.install"), "lazy_installs_allowed", lambda: False)
out = tmp_path / "blocked-output"
before = {p.name: p.read_bytes() for p in locked_source.iterdir() if p.is_file()}
actions = {
@@ -422,7 +422,7 @@ def test_prune_cache_does_not_acquire_a_missing_toolchain(tmp_path, monkeypatch)
monkeypatch.setattr(pm.paths, "store_root", lambda: store)
monkeypatch.setattr(pm.paths, "writable_store_root", lambda: store)
monkeypatch.setattr(pm.paths, "facts_path", lambda: store / "facts.json")
monkeypatch.setattr(importlib.import_module("pm.ensure"), "ensure",
monkeypatch.setattr(importlib.import_module("pm.install"), "ensure",
lambda *args, **kwargs: pytest.fail("cache pruning must not acquire tools"))
with pytest.raises(InstallError, match="pinned toolchain is unavailable"):
prune_cache(tmp_path / "cache")
+1 -1
View File
@@ -694,7 +694,7 @@ def test_real_sync_retains_selection_until_commit(locked_project, tmp_path, monk
source, uv, env = locked_project
monkeypatch.setattr(paths, "repo_root", lambda: source)
monkeypatch.setattr("pm._uv._toolchain", lambda **kw: (uv, Path(sys.executable)))
engine = importlib.import_module("pm.ensure")
engine = importlib.import_module("pm.install")
monkeypatch.setattr(engine, "lazy_installs_allowed", lambda: True)
engine.sync_venv(["chosen"], plugin_dirs=[], explicit=True)
old = selected_venv(source)
+1 -1
View File
@@ -40,7 +40,7 @@ def test_sync_refuses_python_gated_extra_before_touching_environment(monkeypatch
from pathlib import Path
from packaging.markers import default_environment
engine = importlib.import_module("pm.ensure")
engine = importlib.import_module("pm.install")
monkeypatch.setattr(Path, "home", lambda: tmp_path)
version = default_environment()["python_full_version"]
monkeypatch.setattr(extras, "_PLATFORM_GATES", {
+2 -2
View File
@@ -44,7 +44,7 @@ def test_sync_venv_refuses_outside_frozen_extras(rooted, monkeypatch):
import sys
ensure_mod = sys.modules["pm.ensure"]
ensure_mod = sys.modules["pm.install"]
from pm.package import InstallError
monkeypatch.setattr(ensure_mod, "lazy_installs_allowed", lambda: False)
@@ -68,7 +68,7 @@ def test_sync_venv_allows_frozen_extras_when_lazy_off(rooted, monkeypatch):
from pm.lock import Facts
from pm.environments import install_state_dir, runtime_facts_path
ensure_mod = sys.modules["pm.ensure"]
ensure_mod = sys.modules["pm.install"]
# Matching stamp alone cannot certify a vanished environment. Reuse only
# the recorded selection while retaining the disabled acquisition policy.
+1 -1
View File
@@ -34,7 +34,7 @@ def install_spy(monkeypatch):
return None
monkeypatch.setattr(pm.cli, "_install_names", fake_install_names)
monkeypatch.setattr(importlib.import_module("pm.ensure"), "sync_venv", fake_sync_venv)
monkeypatch.setattr(importlib.import_module("pm.install"), "sync_venv", fake_sync_venv)
return calls
+1 -1
View File
@@ -14,7 +14,7 @@ import pytest
import pm
from pm import paths, registry
from pm.downloader import DownloadPaused
from pm.ensure import ensure, stage_only
from pm.install import ensure, stage_only
from pm.lock import Facts, Lockfile
from pm.package import Package
from pm.store import tree_digest
+2 -2
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import pm
from pm import paths
from pm.ensure import ensure
from pm.install import ensure
from pm.lock import Lockfile
from tests.pm.test_pm_authority import core_env, pm_env, served # noqa: F401
@@ -36,7 +36,7 @@ def test_state_fact_in_shared_facts_file_is_not_a_store_install(pm_env):
the same facts.json as tool facts. Reading the composed env over every
registered package must skip it, not KeyError on ``fact["entry"]`` —
that crashed ``activate`` (``pm.environments``) after a sync."""
from pm.ensure import env_for
from pm.install import env_for
from pm.lock import Facts
from pm.registry import all_packages
+1 -1
View File
@@ -70,7 +70,7 @@ def test_install_recovers_from_transient_http_failure(tmp_path, dl_server, monke
"url": url(dl_server, "/tool.zip"),
"sha256": hashlib.sha256(body).hexdigest(),
}})
ensure = importlib.import_module("pm.ensure")
ensure = importlib.import_module("pm.install")
if install_path == "install":
ensure._install(package, lock, facts, store, current_target())
entry = store.entry(facts.get(package.name)["entry"])
+1 -1
View File
@@ -24,7 +24,7 @@ def test_no_package_json_never_acquires_npm(tmp_path, monkeypatch):
def test_acquisition_receives_consent_and_reports_refusal(tmp_path, monkeypatch, explicit):
import importlib
monkeypatch.setattr(importlib.import_module("pm.ensure"), "lazy_installs_allowed", lambda: True)
monkeypatch.setattr(importlib.import_module("pm.install"), "lazy_installs_allowed", lambda: True)
(tmp_path / "package.json").write_text('{}')
calls = []
def refused(name, **kwargs):
+2 -2
View File
@@ -132,7 +132,7 @@ def _local_conflict_members(home: Path) -> tuple[Path, Path, Path, Path]:
@pytest.fixture
def admission_env(tmp_path, monkeypatch):
"""Fake core repo + temp HERMES_HOME so the REAL pm.ensure.sync_venv
"""Fake core repo + temp HERMES_HOME so the REAL pm.install.sync_venv
transaction (lock, receipts, config publication) runs entirely under
tmp — the production path, temp homes."""
core = tmp_path / "core"
@@ -154,7 +154,7 @@ def admission_env(tmp_path, monkeypatch):
import importlib
ensure = importlib.import_module("pm.ensure")
ensure = importlib.import_module("pm.install")
import pm.paths
monkeypatch.setattr(Path, "home", lambda: tmp_path)
+12 -12
View File
@@ -36,10 +36,10 @@ def test_same_version_different_sha_is_not_installed_and_repaired(pm_env, caplog
"""The witness: same version, different artifact sha. Version/path
matching cannot see this; identity matching must — check() reports
it and ensure() replaces the entry bytes."""
from pm.ensure import check, ensure, is_installed
from pm.install import check, ensure, is_installed
env = pm_env
from pm.ensure import stage_only
from pm.install import stage_only
realize = (lambda: ensure("faketool", explicit=True, base_env={})) if route == "install" else (
lambda: stage_only("faketool", "linux-arm64-bionic"))
realize()
@@ -70,7 +70,7 @@ def test_same_version_different_sha_is_not_installed_and_repaired(pm_env, caplog
assert not is_installed("faketool")
assert check() == ["faketool: not installed or outdated"]
with caplog.at_level(logging.INFO, logger="pm.ensure"):
with caplog.at_level(logging.INFO, logger="pm.install"):
realize()
assert any("1.0" in r.message and env["digest"][:12] in r.message for r in caplog.records)
assert is_installed("faketool")
@@ -89,7 +89,7 @@ def test_same_version_different_sha_is_not_installed_and_repaired(pm_env, caplog
@pytest.mark.parametrize("damage", ["missing-binary", "changed-bytes", "entry-is-file", "missing-entry"])
def test_install_repairs_corrupt_entry_from_verified_archive(pm_env, matching_fact, damage, caplog):
from pm.cli import cmd_doctor
from pm.ensure import ensure
from pm.install import ensure
ensure("faketool", base_env={})
facts_path = paths.facts_path()
@@ -109,7 +109,7 @@ def test_install_repairs_corrupt_entry_from_verified_archive(pm_env, matching_fa
data["packages"]["faketool"].pop("artifacts")
facts_path.write_text(json.dumps(data))
with caplog.at_level(logging.INFO, logger="pm.ensure"):
with caplog.at_level(logging.INFO, logger="pm.install"):
ensure("faketool", explicit=True, base_env={})
assert any("repair: faketool re-realized" in r.message for r in caplog.records)
assert binary.read_bytes() == b"#!x"
@@ -125,7 +125,7 @@ def test_install_repairs_corrupt_entry_from_verified_archive(pm_env, matching_fa
@pytest.mark.parametrize("initial", [False, True], ids=["replacement", "first-install"])
def test_failed_replacement_preserves_entry_and_facts(pm_env, monkeypatch, route, initial, failure):
from functools import partial
from pm.ensure import ensure, stage_only
from pm.install import ensure, stage_only
from pm.package import InstallError
env = pm_env
@@ -216,7 +216,7 @@ def test_killed_replacement_recovers_on_next_install(pm_env, route, interruption
import textwrap
from functools import partial
from pm.ensure import ensure, stage_only
from pm.install import ensure, stage_only
env = pm_env
target = current_target() if route == "install" else "linux-arm64-bionic"
@@ -239,7 +239,7 @@ def test_killed_replacement_recovers_on_next_install(pm_env, route, interruption
import pm.registry as registry
from pm.store import Store
from tests.pm.test_pm_authority import FakeTool
from pm.ensure import ensure, stage_only
from pm.install import ensure, stage_only
paths.lockfile_path = lambda: Path(sys.argv[1])
registry._packages[FakeTool.name] = FakeTool()
publish = Store.publish
@@ -275,7 +275,7 @@ def test_killed_replacement_recovers_on_next_install(pm_env, route, interruption
def test_failed_restore_preserves_both_interrupted_versions(pm_env, monkeypatch):
from pm.ensure import ensure
from pm.install import ensure
from pm.package import InstallError
ensure("faketool", base_env={})
@@ -329,7 +329,7 @@ def test_legacy_fact_without_identity_is_not_installed(pm_env, capsys):
"""Facts written before identity existed read back fine but are NOT
vouchable: installed() with identity returns False and forces one
reinstall."""
from pm.ensure import ensure, is_installed
from pm.install import ensure, is_installed
env = pm_env
ensure("faketool", base_env={})
@@ -355,7 +355,7 @@ def test_legacy_fact_without_identity_is_not_installed(pm_env, capsys):
def test_unproven_entry_without_facts_is_rebuilt(pm_env):
from pm.ensure import ensure
from pm.install import ensure
entry = paths.store_root() / FakeTool().store_entry("1.0", current_target())
(entry / "bin").mkdir(parents=True)
(entry / "bin/faketool").write_bytes(b"unproven")
@@ -401,7 +401,7 @@ def test_doctor_flags_tampered_entry_bytes(pm_env, capsys):
"""Post-install tampering: doctor re-hashes the realized tree against
the recorded digest and flags it; restoring the bytes clears it."""
from pm.cli import cmd_doctor
from pm.ensure import ensure
from pm.install import ensure
ensure("faketool", base_env={})
assert cmd_doctor(None) == 0
+1 -1
View File
@@ -85,7 +85,7 @@ def test_uv_bionic_row_matches_supplier(lock):
def test_registered_bionic_stage_preserves_host_facts(tmp_path, monkeypatch, lock, name, main, on_path):
import hashlib
from pm import paths
from pm.ensure import stage_only
from pm.install import stage_only
from pm.lock import Lockfile
from pm.package import InstallError
from pm.registry import get_package
+17 -17
View File
@@ -68,7 +68,7 @@ def pm_env(tmp_path, served, monkeypatch):
# Policy is pinned open here; the disabled-path tests pin it closed.
import importlib
ensure_mod = importlib.import_module("pm.ensure")
ensure_mod = importlib.import_module("pm.install")
monkeypatch.setattr(ensure_mod, "lazy_installs_allowed", lambda: True)
saved = dict(registry._packages)
@@ -106,7 +106,7 @@ def _pin(lockfile_path: Path, name: str, version: str, digest: str) -> None:
def test_bad_hash_rejected(pm_env):
from pm.ensure import ensure
from pm.install import ensure
lockfile_path, *_ = pm_env
_pin(lockfile_path, "faketool", "1.0", "0" * 64)
@@ -119,7 +119,7 @@ def test_multi_archive_merges_into_one_entry(pm_env, capsys):
"""A package split across two archives lands in ONE store entry: a
second entry would put the DLLs where a loading executable can never
find them. No per-archive entries, no leftover scratch."""
from pm.ensure import ensure, is_installed
from pm.install import ensure, is_installed
lockfile_path, runtime, docroot, _ = pm_env
_, digest_a = make_tar(docroot, "multitool-1.0-a.tar.gz",
@@ -168,7 +168,7 @@ def test_multi_archive_merges_into_one_entry(pm_env, capsys):
def test_deps_compose_dependents_win(pm_env):
from pm.ensure import ensure
from pm.install import ensure
lockfile_path, _, docroot, _ = pm_env
name, digest = make_tar(docroot, "deptool-1.0.tar.gz", {"bin/faketool": "y"})
@@ -189,7 +189,7 @@ def test_warm_install_verifies_shared_dependencies_once_under_lock(pm_env, monke
from hermes_cli.runtime_state import _lock
from pm.cli import _install_names
ensure = importlib.import_module("pm.ensure")
ensure = importlib.import_module("pm.install")
lockfile_path, runtime, docroot, _ = pm_env
for name in ("deptool", "toptool"):
_, digest = make_tar(docroot, f"{name}-1.0.tar.gz", {"bin/faketool": name})
@@ -225,7 +225,7 @@ def test_warm_install_verifies_shared_dependencies_once_under_lock(pm_env, monke
def test_standalone_warm_ensure_does_not_wait_for_unrelated_writer(pm_env):
from concurrent.futures import ThreadPoolExecutor
from pm.ensure import ensure
from pm.install import ensure
_, runtime, _, _ = pm_env
ensure("faketool", explicit=True)
@@ -245,7 +245,7 @@ def test_install_forgets_verification_when_state_operation_releases_lock(pm_env,
from pm.cli import _install_names
from pm.packages import Venv
ensure = importlib.import_module("pm.ensure")
ensure = importlib.import_module("pm.install")
lockfile_path, runtime, docroot, _ = pm_env
for name in ("deptool", "toptool"):
_, digest = make_tar(docroot, f"{name}-1.0.tar.gz", {"bin/faketool": name})
@@ -272,7 +272,7 @@ def test_install_forgets_verification_when_state_operation_releases_lock(pm_env,
def test_version_bump_selects_the_new_tool(pm_env):
from pm.ensure import ensure
from pm.install import ensure
lockfile_path, _, docroot, _ = pm_env
@@ -286,14 +286,14 @@ def test_version_bump_selects_the_new_tool(pm_env):
def test_lazy_installs_disabled(pm_env, monkeypatch):
import importlib
ensure_mod = importlib.import_module("pm.ensure")
ensure_mod = importlib.import_module("pm.install")
monkeypatch.setattr(ensure_mod, "lazy_installs_allowed", lambda: False)
with pytest.raises(InstallError, match="lazy installs are disabled"):
ensure_mod.ensure("faketool", base_env={})
def test_missing_platform_is_declared(pm_env):
from pm.ensure import ensure
from pm.install import ensure
lockfile_path, *_ = pm_env
pkg = registry._packages["faketool"]
@@ -306,7 +306,7 @@ def test_missing_platform_is_declared(pm_env):
def test_corrupt_facts_degrades_to_empty(pm_env):
from pm.ensure import ensure, is_installed
from pm.install import ensure, is_installed
_, runtime, *_ = pm_env
ensure("faketool", base_env={})
@@ -318,7 +318,7 @@ def test_corrupt_facts_degrades_to_empty(pm_env):
def test_concurrent_installs_do_not_clobber(pm_env):
from pm.ensure import ensure, is_installed
from pm.install import ensure, is_installed
lockfile_path, _, docroot, _ = pm_env
name, digest = make_tar(docroot, "deptool-1.0.tar.gz", {"bin/faketool": "y"})
@@ -348,7 +348,7 @@ def test_single_flight_one_store_entry(pm_env, monkeypatch):
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
from http.server import SimpleHTTPRequestHandler
from pm.ensure import ensure
from pm.install import ensure
_, runtime, *_ = pm_env
contenders = threading.Barrier(6)
@@ -394,7 +394,7 @@ def test_single_flight_one_store_entry(pm_env, monkeypatch):
def test_gc_keeps_used_removes_orphans(pm_env):
from pm.cli import cmd_gc
from pm.ensure import ensure
from pm.install import ensure
_, runtime, *_ = pm_env
ensure("faketool", base_env={})
@@ -410,7 +410,7 @@ def test_gc_removes_fetch_cache_archives(pm_env):
drop them so a staged payload (and the CI cache that stores it) doesn't
carry the raw archives. The live package entry survives."""
from pm.cli import cmd_gc
from pm.ensure import ensure
from pm.install import ensure
lock_path, runtime, *_ = pm_env
ensure("faketool", base_env={})
@@ -707,7 +707,7 @@ def test_install_verify_failure_reports_reason(pm_env):
name, digest = make_tar(docroot, "faketool-2.0.tar.gz", {"nope/x": "y"})
_pin(lockfile_path, "faketool", "2.0", digest)
from pm.ensure import ensure
from pm.install import ensure
with pytest.raises(InstallError) as exc:
ensure("faketool", explicit=True)
@@ -723,7 +723,7 @@ def test_store_path_dirs_include_node_npm_when_installed(tmp_path, monkeypatch):
installed store packages (non-internal), their dirs enter the
provisioned PATH. Regression test for the flag flip."""
from pm import paths
from pm.ensure import _store_path_dirs
from pm.install import _store_path_dirs
from pm.lock import Facts, Lockfile
runtime = tmp_path / "runtime"
+2 -2
View File
@@ -85,7 +85,7 @@ def test_repair_restores_recorded_plugin_dependencies_without_config(tmp_path, m
import pm.workspace as workspace
from pm.environments import selected_venv, site_packages
engine = importlib.import_module("pm.ensure")
engine = importlib.import_module("pm.install")
uv = shutil.which("uv")
assert uv, "recovery integration requires real uv"
core, plugin = recovery_graph
@@ -165,7 +165,7 @@ def test_uncertain_profile_selection_refuses_sync_but_not_recorded_repair(tmp_pa
from hermes_cli.plugins_admission import AdmissionRefused, admit_plugin_set_change
from pm.environments import install_state_dir, selected_venv, site_packages
engine = importlib.import_module("pm.ensure")
engine = importlib.import_module("pm.install")
# Use the same engine for admission and repair with the offline uv fixture.
monkeypatch.setattr("pm.client.sync_venv", engine.sync_venv)
uv = shutil.which("uv")
+2 -2
View File
@@ -29,7 +29,7 @@ from tests.pm._fixtures import isolated_python # noqa: F401
@pytest.fixture
def source_launch(tmp_path, monkeypatch, isolated_python):
client = importlib.import_module("pm.client")
engine = importlib.import_module("pm.ensure")
engine = importlib.import_module("pm.install")
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
@@ -150,7 +150,7 @@ def test_source_python_pin_update_survives_real_gc(source_launch, tmp_path, monk
# CLI must still pin, ensure, synchronize and publish through its owners.
monkeypatch.setattr(cli, "resolve_package", lambda *a, **k: Resolved("python", "A", "semver", "B"))
monkeypatch.setattr(cli, "_pin_artifacts", lambda *a: {})
monkeypatch.setattr(importlib.import_module("pm.ensure"), "sync_venv", pm.sync_venv)
monkeypatch.setattr(importlib.import_module("pm.install"), "sync_venv", pm.sync_venv)
assert cli.cmd_update(SimpleNamespace(names=["python"], target=None, check=False, uv=False, npm=False, termux=False)) == 0
assert Lockfile(paths.lockfile_path()).version("python") == "B"
assert pm.venv_is_current(project_root=root)
+1 -1
View File
@@ -12,7 +12,7 @@ from pm.package import InstallError, Package
from pm.lock import Lockfile
from pm.store import Store
ensure_mod = importlib.import_module("pm.ensure")
ensure_mod = importlib.import_module("pm.install")
class _FakePackage(Package):
+1 -1
View File
@@ -16,7 +16,7 @@ from tests.pm._fixtures import make_tar, served as served
@pytest.fixture
def checked_store(tmp_path, monkeypatch, served):
engine = importlib.import_module("pm.ensure")
engine = importlib.import_module("pm.install")
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "store"))
+1 -1
View File
@@ -36,7 +36,7 @@ def test_bootstrap_repairs_before_dependency_activation(tmp_path, monkeypatch, m
import pm.paths as paths
from pm.environments import selected_venv, site_packages
engine = importlib.import_module("pm.ensure")
engine = importlib.import_module("pm.install")
repo = Path(__file__).resolve().parents[2]
core = tmp_path / "app"
core.mkdir()
+2 -2
View File
@@ -11,7 +11,7 @@ from tests.pm.test_pm_authority import core_env, pm_env, served # noqa: F401
@pytest.mark.parametrize("sealed_install", [True, False])
def test_missing_bundle_tool_is_installed_in_writable_store(pm_env, tmp_path, monkeypatch, sealed_install):
from pm.ensure import ensure, env_for, is_installed
from pm.install import ensure, env_for, is_installed
import importlib
fixture = pm_env
@@ -21,7 +21,7 @@ def test_missing_bundle_tool_is_installed_in_writable_store(pm_env, tmp_path, mo
monkeypatch.setattr(paths, "store_root", lambda: shipped)
monkeypatch.setattr(paths, "facts_path", lambda: shipped / "facts.json")
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
ensure_module = importlib.import_module("pm.ensure")
ensure_module = importlib.import_module("pm.install")
monkeypatch.setattr(ensure_module, "lazy_installs_allowed", lambda: True)
monkeypatch.setattr(ensure_module, "sealed", lambda: sealed_install)
before = sorted(p.relative_to(shipped).as_posix() for p in shipped.rglob("*"))
+1 -1
View File
@@ -63,7 +63,7 @@ def test_same_minor_refresh_updates_real_bytes_and_preserves_unresolved_targets(
monkeypatch.setattr(paths, "partials_root", lambda: tmp_path / "partials")
monkeypatch.setenv("HERMES_RUNTIME_DIR", str(store))
syncs = []
engine = importlib.import_module("pm.ensure")
engine = importlib.import_module("pm.install")
monkeypatch.setattr(engine, "sync_venv", lambda **kwargs: syncs.append(kwargs))
before = lock.path.read_bytes()
args = Namespace(names=["rolling-tool"], target=None, check=True, uv=False, npm=False, termux=False)
+1 -1
View File
@@ -75,7 +75,7 @@ def test_uv_refresh_uses_real_installed_tool_and_only_the_owned_project(tmp_path
before_lock = lock.path.read_bytes()
parent_env = dict(os.environ)
syncs = []
ensure = importlib.import_module('pm.ensure')
ensure = importlib.import_module('pm.install')
monkeypatch.setattr(ensure, 'sync_venv', lambda **kw: syncs.append(kw))
args = Namespace(names=['manual-fixture'], target=None, check=True, uv=True, npm=False, termux=False)
assert cli.cmd_update(args) == 0
+1 -1
View File
@@ -42,7 +42,7 @@ def prepare(tmp_path, monkeypatch, packages):
for name in ("_pin_artifacts", "_install_names", "_run_live", "lock_project"):
monkeypatch.setattr(cli, name, forbidden)
monkeypatch.setattr(importlib.import_module("pm.ensure"), "sync_venv", forbidden)
monkeypatch.setattr(importlib.import_module("pm.install"), "sync_venv", forbidden)
return lock
+3 -3
View File
@@ -106,7 +106,7 @@ def test_all_uv_commands_keep_the_pm_interpreter(installed_uv, monkeypatch):
def test_uv_refuses_discovery_when_pm_python_is_missing(installed_uv, monkeypatch):
root, _, facts, target, digest = installed_uv
ensure = importlib.import_module("pm.ensure")
ensure = importlib.import_module("pm.install")
monkeypatch.setattr(ensure, "lazy_installs_allowed", lambda: False)
monkeypatch.setenv("UV_PYTHON", str(root / "ambient-python"))
assert _toolchain(realize=False) is None
@@ -133,7 +133,7 @@ def test_bundled_uv_uses_a_verified_writable_python_without_changing_runtime(ins
from pm.store import Store, tree_digest
root, uv_binary, facts, target, digest = installed_uv
ensure = importlib.import_module("pm.ensure")
ensure = importlib.import_module("pm.install")
shipped = uv_binary.parent.parent
writable = root / "writable-tools"
monkeypatch.setattr(paths, "writable_store_root", lambda: writable)
@@ -202,7 +202,7 @@ def test_copy_failure_preserves_previous_python(installed_uv, monkeypatch, damag
from pm.store import Store, tree_digest
root, _, facts, target, digest = installed_uv
ensure = importlib.import_module("pm.ensure")
ensure = importlib.import_module("pm.install")
shipped = facts.path.parent
writable = root / "writable-tools"
writable.mkdir()
+2 -2
View File
@@ -23,7 +23,7 @@ from tests.pm._fixtures import (
def test_isolated_worker_preserves_install_error(client, monkeypatch):
engine = importlib.import_module("pm.ensure")
engine = importlib.import_module("pm.install")
monkeypatch.setattr(engine, "ensure", lambda *a, **kw: pytest.fail("engine ran in caller"))
with pytest.raises(InstallError) as caught:
client.ensure("node", explicit=True)
@@ -101,7 +101,7 @@ def test_currency_probe_preserves_union_and_candidate_inputs(client, tmp_path, m
monkeypatch.setattr("pm.runtime.runtime_python", ready_runtime)
if route != "direct":
engine = importlib.import_module("pm.ensure")
engine = importlib.import_module("pm.install")
monkeypatch.setattr(engine, "venv_is_current", lambda **kw: pytest.fail("probe ran in caller"))
def snapshot():
return {path.relative_to(tmp_path): (path.read_bytes() if path.is_file() else None)
+1 -1
View File
@@ -65,7 +65,7 @@ def test_registered_package_installs_archive_in_real_worker(tmp_path, monkeypatc
"url": url(dl_server, "/plugin.tar.gz"), "sha256": hashlib.sha256(data).hexdigest(),
}})
lock.save()
engine = importlib.import_module("pm.ensure")
engine = importlib.import_module("pm.install")
monkeypatch.setattr(engine, operation, lambda *a, **kw: pytest.fail("install ran in caller"))
if operation == "ensure":
pm.ensure("registry-worker-archive", explicit=True)
+1 -1
View File
@@ -83,7 +83,7 @@ def test_node_sidecar_retains_output_and_exit_status(
if install_cmd == "ci":
(tmp_path / "package-lock.json").write_text("{}", encoding="utf-8")
monkeypatch.setattr(
importlib.import_module("pm.ensure"), "lazy_installs_allowed", lambda: True
importlib.import_module("pm.install"), "lazy_installs_allowed", lambda: True
)
diagnostic = "🔍 node-gyp: build toolchain unavailable"
raw = diagnostic.encode("utf-8") + b"\xff\n"
+1 -1
View File
@@ -92,7 +92,7 @@ def test_real_cli_miss_hit_and_staging_use_the_same_archived_bytes(tmp_path, ups
assert inputs.main(["--target", "linux-arm64-bionic", "--store", str(store.root), "--payload", str(payload)]) == 0
assert not any(r[0] == "PUT" for r in r2_server.requests)
assert (stage(payload, {"lib": row}) / "libarchive-proof.so").read_bytes().endswith(b"pinned library")
engine = importlib.import_module("pm.ensure")
engine = importlib.import_module("pm.install")
monkeypatch.setattr(engine, "_store", lambda: store)
monkeypatch.setattr(engine, "get_package", lambda _: _FakePackage())
entry = engine.stage_only("stage-test", "linux-arm64-bionic")
+1 -1
View File
@@ -126,7 +126,7 @@ def test_bundle_stages_git_tree_and_runs_native_children_before_manifest(tmp_pat
monkeypatch.setattr("pm.paths.repo_root", lambda: repo)
(repo / "untracked").write_text("must not ship", encoding="utf-8")
monkeypatch.setattr(native, "_bundle_package_names", lambda: ["agent-browser", "uv"])
monkeypatch.setattr(importlib.import_module("pm.ensure"), "_prepare_artifacts",
monkeypatch.setattr(importlib.import_module("pm.install"), "_prepare_artifacts",
lambda *args, **kwargs: pytest.fail("prepared tools reached acquisition"))
monkeypatch.setattr("pm.client.is_runtime", lambda: True)
monkeypatch.setattr("pm._uv._toolchain", lambda **kwargs: (Path(uv), Path(sys.executable)))
+1 -1
View File
@@ -25,7 +25,7 @@ def test_ci_setup_exports_no_installer_path_or_policy(tmp_path, monkeypatch, loc
from scripts.ci import setup_toolchain
monkeypatch.setattr(setup_toolchain, "packages", lambda toolchain, extra=None: ["python", "uv"])
manager = importlib.import_module("pm.ensure")
manager = importlib.import_module("pm.install")
ensured = []
monkeypatch.setattr(manager, "ensure", lambda name, **kwargs: ensured.append((name, kwargs["explicit"])))
composed = []
+90 -190
View File
@@ -10,11 +10,61 @@ def _load_optional_dependencies():
return project["optional-dependencies"]
def _load_package_data():
pyproject_path = Path(__file__).resolve().parents[1] / "pyproject.toml"
with pyproject_path.open("rb") as handle:
tool = tomllib.load(handle)["tool"]
return tool["setuptools"]["package-data"]
def test_wake_dependencies_and_runtime_gate_agree_on_supported_targets():
from packaging.markers import Marker, default_environment
from packaging.requirements import Requirement
root = Path(__file__).resolve().parents[1]
metadata = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8-sig"))
optional = metadata["project"]["optional-dependencies"]
gates = metadata["tool"]["hermes"]["extras-platforms"]
targets = [
("darwin", "Darwin", "x86_64", False),
("darwin", "Darwin", "arm64", True),
("linux", "Linux", "x86_64", True),
("linux", "Linux", "aarch64", True),
("win32", "Windows", "AMD64", True),
("win32", "Windows", "ARM64", False),
]
for system, platform_system, machine, supported in targets:
environment = {**default_environment(), "sys_platform": system,
"platform_system": platform_system, "platform_machine": machine}
for extra in ("wake", "wake-openwakeword"):
requirement = next(req for spec in optional[extra]
if (req := Requirement(spec)).name == "pyopen-wakeword")
assert requirement.marker.evaluate(environment) is supported, (extra, system, machine)
assert Marker(gates["wake-openwakeword"]).evaluate(environment) is supported
selected = {req.name for spec in optional["wake"]
if (req := Requirement(spec)).marker is None or req.marker.evaluate(environment)}
assert {"sherpa-onnx", "pvporcupine", "sentencepiece", "pypinyin"} <= selected
sherpa_deps = {req.name for spec in optional["wake-sherpa"]
if (req := Requirement(spec)).marker is None or req.marker.evaluate(environment)}
assert {"sherpa-onnx", "sentencepiece", "pypinyin"} <= sherpa_deps
for extra in ("wake-sherpa", "wake-porcupine"):
assert all(req.marker is None or req.marker.evaluate(environment)
for req in map(Requirement, optional[extra]))
gate = gates.get(extra)
assert gate is None or Marker(gate).evaluate(environment)
def test_direct_overrides_preserve_the_declared_exact_version():
from packaging.requirements import Requirement
root = Path(__file__).resolve().parents[1]
metadata = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8-sig"))
locked = tomllib.loads((root / "uv.lock").read_text(encoding="utf-8-sig"))
direct = {req.name: req for spec in metadata["project"]["dependencies"]
if (req := Requirement(spec)).specifier}
for spec in metadata["tool"]["uv"].get("override-dependencies", []):
override = Requirement(spec)
requirement = direct.get(override.name)
if requirement is None or not any(s.operator == "==" for s in requirement.specifier):
continue
assert override.specifier == requirement.specifier, (
f"{override.name}: override {override.specifier} contradicts exact requirement {requirement.specifier}"
)
versions = {row["version"] for row in locked["package"] if row["name"] == override.name}
assert versions and all(version in requirement.specifier for version in versions)
def test_matrix_extra_not_in_all():
@@ -24,9 +74,9 @@ def test_matrix_extra_not_in_all():
With matrix in [all], `uv sync --locked` on Windows tried to build
python-olm from sdist and failed on `make`. As of 2026-05-12 the
[matrix] extra is excluded from [all] entirely and routed through
`tools/lazy_deps.py` (LAZY_DEPS["platform.matrix"]) — installs at
first use, where the user is expected to have a toolchain.
[matrix] extra is excluded from [all] entirely and installs on first
use (pm.ensure_import("matrix")), where the user is expected to have
a toolchain.
"""
optional_dependencies = _load_optional_dependencies()
@@ -38,39 +88,37 @@ def test_matrix_extra_not_in_all():
if "matrix" in dep
]
assert not matrix_in_all, (
"matrix must not appear in [all] — it's lazy-installed via "
"tools/lazy_deps.py LAZY_DEPS['platform.matrix']. Found: "
f"{matrix_in_all}"
"matrix must not appear in [all] — it installs on first use via "
f"pm.ensure_import('matrix'). Found: {matrix_in_all}"
)
def test_lazy_installable_extras_excluded_from_all():
"""Policy (2026-05-12): every extra that has a `LAZY_DEPS` entry
in `tools/lazy_deps.py` must be excluded from [all].
"""Policy (2026-05-12): opt-in backends stay out of [all].
The lazy-install system exists so one quarantined PyPI release
On-demand install exists so one quarantined PyPI release
(e.g. mistralai 2.4.6) can't break every fresh install. Putting a
backend in BOTH [all] and LAZY_DEPS defeats that — fresh installs
eager-install it and inherit whatever's broken upstream.
If you're tempted to add an opt-in backend to [all] for "convenience,"
add it to `LAZY_DEPS` instead so it installs at first use.
backend in [all] defeats that — fresh installs eager-install it and
inherit whatever's broken upstream. Opt-in backends are extras that
install at first use via pm.ensure_import(extra).
"""
optional_dependencies = _load_optional_dependencies()
# Hard-coded mirror of the extras that are in LAZY_DEPS as of
# 2026-05-12. This list intentionally duplicates rather than
# imports tools/lazy_deps.py so the test stays a contract — if
# someone adds a new lazy-install backend, they have to update
# this list AND verify [all] doesn't contain it.
# The on-demand backends as of 2026-05-12. Deliberately a literal
# list so the test stays a contract — adding a new opt-in backend
# means updating this list AND verifying [all] doesn't contain it.
lazy_covered_extras = {
"anthropic", "bedrock",
"exa", "firecrawl", "parallel-web",
"fal",
"edge-tts", "tts-premium",
"voice", # faster-whisper / sounddevice / numpy
"voice", # faster-whisper / sounddevice / numpy (composes stt-whisper + audio-io)
"stt-whisper",
"modal", "daytona", "vercel",
"messaging", "slack", "matrix", "dingtalk", "feishu", "google-chat",
"messaging", "slack", "matrix", "dingtalk", "feishu",
"telegram", "discord",
"wake", "wake-openwakeword", "wake-sherpa", "wake-porcupine",
"google-chat",
"honcho", "hindsight",
"supermemory", "mem0",
"mistral", # mistralai — Voxtral STT/TTS, lazy-installed (stt.mistral / tts.mistral)
@@ -102,54 +150,31 @@ def _exact_pins(specs):
def test_pyproject_pins_match_lazy_deps_pins():
"""Generalize #31817 to the whole pin surface, not just aiohttp.
def test_extras_pin_each_package_at_one_version():
"""One package, one version, across every extra.
Any package that is exact-pinned in BOTH a pyproject extra and a
`tools/lazy_deps.py` LAZY_DEPS entry must use the SAME version in both
places. When they drift, `hermes update` resolves the pyproject extra
pin and downgrades the package to the older version, reopening whatever
the lazy pin fixed (the aiohttp #31817 case, and the anthropic
CVE-2026-34450/34452 case found alongside it) — only for the lazy
refresh to re-upgrade it on next feature use. The lazy pin is the
security-current source of truth; extras must track it.
tools/lazy_deps.py is gone — pyproject.toml is the single authority for
optional-dependency pins (pm syncs the venv from uv.lock, which resolves
from here). The drift class that killed us before (#31817: two documents
pinning the same package differently, update ping-ponging the version)
is now only possible BETWEEN extras — so pin consistency across extras
is the whole remaining contract.
"""
from tools.lazy_deps import LAZY_DEPS
optional_dependencies = _load_optional_dependencies()
# package -> version, as pinned across all pyproject extras. If an
# extra pins a package at a different version than another extra, that
# is itself a bug (caught below); here we just collect the set.
pyproject_pins: dict[str, set[str]] = {}
for specs in optional_dependencies.values():
pins: dict[str, dict[str, set[str]]] = {}
for extra, specs in optional_dependencies.items():
for package, version in _exact_pins(specs).items():
pyproject_pins.setdefault(package, set()).add(version)
# package -> version, as pinned across all LAZY_DEPS entries.
lazy_pins: dict[str, set[str]] = {}
for specs in LAZY_DEPS.values():
if isinstance(specs, str):
specs = (specs,)
for package, version in _exact_pins(specs).items():
lazy_pins.setdefault(package, set()).add(version)
shared = sorted(set(pyproject_pins) & set(lazy_pins))
assert shared, "expected at least one package pinned in both pyproject and LAZY_DEPS"
pins.setdefault(package, {}).setdefault(version, set()).add(extra)
drift = {
package: {
"pyproject": sorted(pyproject_pins[package]),
"lazy_deps": sorted(lazy_pins[package]),
}
for package in shared
if pyproject_pins[package] != lazy_pins[package]
package: {v: sorted(extras) for v, extras in versions.items()}
for package, versions in pins.items()
if len(versions) > 1
}
assert not drift, (
"pyproject extras pins must match tools/lazy_deps.py LAZY_DEPS pins "
"for every shared package — otherwise `hermes update` downgrades the "
"package below the security-current lazy pin (see #31817). Drift: "
f"{drift}"
"extras pin the same package at different versions — uv sync would "
f"resolve whichever wins and silently downgrade the other: {drift}"
)
@@ -164,128 +189,3 @@ def test_dingtalk_extra_includes_qrcode_for_qr_auth():
dingtalk_extra = optional_dependencies["dingtalk"]
assert any(dep.startswith("qrcode") for dep in dingtalk_extra)
def _uv_lock_version(package: str) -> str:
"""Resolved version of ``package`` in uv.lock, or fail loudly."""
versions = _uv_lock_versions(package)
assert versions, f"{package} not found in uv.lock"
assert len(versions) == 1, f"{package} resolves to multiple versions in uv.lock: {versions}"
return next(iter(versions))
def _uv_lock_versions(package: str) -> set[str]:
"""All resolved versions of ``package`` in uv.lock (normally 0 or 1)."""
import re
lock_path = Path(__file__).resolve().parents[1] / "uv.lock"
lock = lock_path.read_text(encoding="utf-8")
return {
m.group(1)
for m in re.finditer(
rf'\[\[package\]\]\nname = "{re.escape(package)}"\nversion = "([^"]+)"',
lock,
)
}
def test_every_lazy_deps_exact_pin_matches_uv_lock():
"""Class invariant for #60783/#60685: one version per package, everywhere.
Any package that is BOTH exact-pinned in ``tools/lazy_deps.py`` AND
resolved in the committed uv.lock is a *shared* package: the core
install ships the locked version, and the ``hermes update`` lazy-refresh
pass re-asserts the LAZY_DEPS pin whenever the package is present
(``active_features()``). If the two disagree, every update churns the
package — and when the lazy pin is older, it force-DOWNGRADES a version
another consumer needs (huggingface-hub==1.2.3 vs transformers'
>=1.5.0 broke Hindsight local embeddings; stale aiohttp pins reopened
patched CVEs in #31817). Contract: for every such package, pin ==
locked version. When bumping a pin, regenerate the lock in the same
commit (`uv lock --upgrade-package <name>`), and vice versa.
"""
from tools.lazy_deps import LAZY_DEPS
drift = {}
seen = set()
for feature, specs in LAZY_DEPS.items():
for package, pin in _exact_pins(specs).items():
if (package, pin) in seen:
continue
seen.add((package, pin))
locked = _uv_lock_versions(package)
if not locked:
# Lazy-only package never resolved by the core lock — no
# shared-version hazard.
continue
if pin not in locked:
drift.setdefault(package, {})[feature] = {
"lazy_pin": pin,
"uv_lock": sorted(locked),
}
assert not drift, (
"LAZY_DEPS exact pins must match the uv.lock resolved version for "
"every package the core lock also ships — otherwise `hermes update` "
"churns/downgrades the shared package out from under its other "
"consumers (#60783, #31817). Bump the pin AND run "
"`uv lock --upgrade-package <name>` in the same commit. Drift: "
f"{drift}"
)
def test_huggingface_hub_lazy_pin_matches_uv_lock():
"""The whole tree must converge on ONE huggingface-hub version (#60783).
huggingface-hub is a shared dependency: the core lock resolves it (via
faster-whisper/tokenizers, and transformers/sentence-transformers when
local Hindsight embeddings are installed), and LAZY_DEPS
['tool.trace_upload'] exact-pins it. Because active_features() activates
a feature from mere package presence, the `hermes update` lazy-refresh
pass re-asserts the LAZY_DEPS pin on every install where hub is present.
If that pin drifts from the lock's resolved version, every update churns
the shared package — and a pin below transformers' floor (>=1.5.0)
force-downgrades it and breaks the Hindsight local daemon on startup.
"""
from tools.lazy_deps import LAZY_DEPS
lazy_pin = _exact_pins(LAZY_DEPS["tool.trace_upload"]).get("huggingface-hub")
assert lazy_pin, "tool.trace_upload must exact-pin huggingface-hub"
locked = _uv_lock_version("huggingface-hub")
assert lazy_pin == locked, (
"LAZY_DEPS['tool.trace_upload'] pins huggingface-hub=="
f"{lazy_pin} but uv.lock resolves {locked}. These must move in "
"lockstep (bump the pin AND run `uv lock --upgrade-package "
"huggingface-hub`), or `hermes update` will churn/downgrade the "
"shared package and break Hindsight local embeddings (#60783)."
)
def test_huggingface_hub_lazy_pin_inside_transformers_window():
"""The hub pin must stay in transformers' accepted range (#60783).
transformers (pulled by sentence-transformers for Hindsight
local/local_embedded embeddings) requires huggingface-hub>=1.5.0,<2.
An exact pin outside that window makes the lazy-refresh downgrade the
shared package below what the embedding stack imports, and the
Hindsight daemon fails on startup. Contract, not a snapshot: any
future exact pin is fine as long as it stays inside the window.
"""
from packaging.specifiers import SpecifierSet
from packaging.version import Version
from tools.lazy_deps import LAZY_DEPS
pin = _exact_pins(LAZY_DEPS["tool.trace_upload"]).get("huggingface-hub")
assert pin, "tool.trace_upload must exact-pin huggingface-hub"
transformers_window = SpecifierSet(">=1.5.0,<2")
assert Version(pin) in transformers_window, (
f"huggingface-hub=={pin} falls outside transformers' accepted "
"range (>=1.5.0,<2). The lazy refresh would downgrade the shared "
"package and break Hindsight local embeddings (#60783)."
)
+1 -1
View File
@@ -69,7 +69,7 @@ def test_git_consumers_fall_back_to_system_git(tmp_path, monkeypatch, consumer,
# Fail at the PM boundary reached by both ensure and env_for, without
# pretending this interpreter is running on a different host OS/CPU.
resolver = importlib.import_module("pm.ensure")
resolver = importlib.import_module("pm.install")
calls = []
def unavailable(*args, **kwargs):
+2 -2
View File
@@ -16,12 +16,12 @@ def _lazy_installs(monkeypatch, allowed: bool) -> None:
"""Stand in for ``security.allow_lazy_installs``.
The submodule must be fetched through ``import_module``: ``pm.ensure`` on the pm PACKAGE is the
``ensure()`` function, so ``pm.ensure.lazy_installs_allowed`` is not the seam the production
``ensure()`` function, so ``pm.install.lazy_installs_allowed`` is not the seam the production
import reads.
"""
import importlib
monkeypatch.setattr(importlib.import_module("pm.ensure"), "lazy_installs_allowed", lambda: allowed)
monkeypatch.setattr(importlib.import_module("pm.install"), "lazy_installs_allowed", lambda: allowed)
def _no_extra(monkeypatch) -> None:
+1 -1
View File
@@ -19,7 +19,7 @@ import pytest
import pm
import importlib
pm_ensure = importlib.import_module("pm.ensure")
pm_ensure = importlib.import_module("pm.install")
import tools.wake_word as ww
+10 -769
View File
@@ -1,775 +1,16 @@
"""Lazy dependency installer for opt-in Hermes backends.
"""Shims to suppress old updater work until relaunch. New code must not use these."""
Backends call :func:`ensure(feature)` on first import; missing packages are installed into the
active venv (or the durable target) unless ``security.allow_lazy_installs: false``, in which
case :class:`FeatureUnavailable` carries a remediation hint. Security model: venv-scoped
(never system Python); durable-target mode (``HERMES_LAZY_INSTALL_TARGET``, sealed images)
APPENDS the target to ``sys.path`` so core site-packages wins every collision and a lazy
package can only add modules, never shadow core; PyPI-by-name specs only (``_spec_is_safe``);
``ensure`` accepts only the :data:`LAZY_DEPS` allowlist; failures surface pip's stderr, no retry.
"""
from typing import NoReturn
from __future__ import annotations
from hermes_cli._old_updater import stop_for_relaunch
import contextlib
import logging
import os
import re
import shutil
import site
import subprocess
import sys
import sysconfig
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Optional
from hermes_cli._subprocess_compat import windows_hide_flags
def ensure(feature: str, *, prompt: bool = True) -> NoReturn:
# Shim to suppress old updater work until relaunch. Do not claim readiness.
# Preserve the dependency-unavailable failure without claiming a completed install.
raise ImportError("Dependencies are unknown to this old updater. Please relaunch Hermes.")
logger = logging.getLogger(__name__)
# Allowlist: "namespace.backend" -> pip specs matching the pyproject extra. Pins are exact
# (security posture); bump here AND in pyproject. Shared patched floors (aiohttp==3.14.3,
# starlette==1.3.1) are literals in every feature: tests/test_packaging_metadata.py checks by AST.
LAZY_DEPS: dict[str, tuple[str, ...]] = {
# ─── Inference providers ───────────────────────────────────────────────
# Native Anthropic SDK (provider=anthropic; aggregators use the openai SDK).
"provider.anthropic": ("anthropic==0.87.0",), # CVE-2026-34450, CVE-2026-34452
"provider.bedrock": ("boto3==1.42.89",),
# Vertex OAuth2 token minting; google-auth is NOT in [all] on purpose.
"provider.vertex": (
"google-auth==2.55.1",
"pyasn1==0.6.4",
),
# Foundry Entra ID auth; only when model.auth_mode=entra_id.
"provider.azure_identity": ("azure-identity==1.25.3",),
# ─── Web search backends ───────────────────────────────────────────────
"search.exa": ("exa-py==2.10.2",),
"search.firecrawl": ("firecrawl-py==4.17.0",),
"search.parallel": ("parallel-web==0.4.2",),
# ─── Monitoring ─────────────────────────────────────────────────────────
# OTLP export; tracks the `otlp` extra.
"export.otlp": (
"opentelemetry-sdk==1.39.1",
"opentelemetry-exporter-otlp-proto-http==1.39.1",
),
# ─── TTS providers ─────────────────────────────────────────────────────
# mistralai: 2.4.6 was a malicious quarantined release — never pin below 2.4.7.
# Voxtral STT + TTS share the SDK.
"tts.mistral": ("mistralai==2.4.8",),
"tts.edge": ("edge-tts==7.2.7",),
"tts.elevenlabs": ("elevenlabs==1.59.0",),
# ─── Speech-to-text providers ──────────────────────────────────────────
"stt.mistral": ("mistralai==2.4.8",),
"stt.faster_whisper": (
"faster-whisper==1.2.1",
"sounddevice==0.5.5",
"numpy==2.4.3",
),
# SILK voice-note decoding (WeChat/QQ); silk-v3 codec binding.
"stt.silk": ("pilk==0.2.4",),
# ─── Wake word ("Hey Hermes") engines (sync with the `wake` extra) ──────
# openWakeWord's ONNX model scores ~0 on macOS ARM64, so macOS uses the tflite backend
# (ai-edge-litert, bridged in tools/wake_word.py). Separate feature because specs cannot
# carry PEP 508 markers (";" is rejected) — the caller applies the platform gate.
"wake.openwakeword.tflite": (
"ai-edge-litert==2.1.6",
),
"wake.openwakeword": (
"openwakeword==0.6.0",
"onnxruntime==1.27.0",
"sounddevice==0.5.5",
"numpy==2.4.3",
),
# Open-vocabulary keyword spotting. sentencepiece is needed by
# sherpa_onnx.text2token but undeclared by sherpa-onnx.
"wake.sherpa": (
"sherpa-onnx==1.13.4",
"sentencepiece==0.2.2",
"sounddevice==0.5.5",
"numpy==2.4.3",
),
"wake.porcupine": (
"pvporcupine==4.0.3",
"sounddevice==0.5.5",
"numpy==2.4.3",
),
# ─── Image generation backends ─────────────────────────────────────────
"image.fal": ("fal-client==0.13.1",),
# ─── Memory providers ──────────────────────────────────────────────────
"memory.honcho": ("honcho-ai==2.2.0",),
"memory.hindsight": ("hindsight-client==0.6.1",),
# Cloud memory SDKs MUST be allowlisted + ensure()'d at the import site, or they never
# install on the sealed Docker image (durable-target only).
"memory.supermemory": ("supermemory==3.50.0",),
"memory.mem0": ("mem0ai==2.0.10",),
# ─── Messaging platforms (lazy-installable on demand) ──────────────────
"platform.telegram": ("python-telegram-bot[webhooks]==22.8",),
# brotlicffi: aiohttp needs its 2-arg Decompressor for Discord CDN Brotli attachments
# (google's 1-arg `Brotli` fails "Can not decode br"). aiohttp is only capped transitively
# by these adapters, so pin the patched floor explicitly.
# Without it, aiohttp falls back to google's `Brotli` package (1-arg API), and any .txt/.md/.doc
# uploaded to the Discord gateway fails to decode at att.read() with "Can not decode content-encoding:
# br" — see #12511 / #15744.
"platform.discord": (
"discord.py[voice]==2.7.1",
"brotlicffi==1.2.0.2",
"aiohttp==3.14.3",
),
"platform.slack": (
"slack-bolt==1.30.0",
"slack-sdk==3.44.1",
"aiohttp==3.14.3",
),
"platform.matrix": (
"mautrix[encryption]==0.21.1",
"aiosqlite==0.22.1",
"asyncpg==0.31.0",
"aiohttp-socks==0.11.0",
"aiohttp==3.14.3",
),
"platform.dingtalk": (
"dingtalk-stream==0.24.3",
"alibabacloud-dingtalk==2.2.42",
"qrcode==7.4.2",
),
"platform.feishu": (
"lark-oapi==1.6.8",
"qrcode==7.4.2",
),
# WeCom callback adapter parses untrusted XML POST bodies -> defusedxml.
"platform.wecom_callback": ("defusedxml==0.7.1",),
# Teams pulls a heavy tree (msal, dependency-injector); also the `teams` extra.
"platform.teams": ("microsoft-teams-apps==2.0.13.4", "aiohttp==3.14.3"),
# Google Chat — Pub/Sub + Chat API. Not in [all]; Docker bakes `--extra google-chat`
# so hosted/immutable images do not have to write the sealed venv.
"platform.google_chat": (
"google-cloud-pubsub==2.39.0",
"google-api-python-client==2.194.0",
"google-auth==2.55.1",
"google-auth-oauthlib==1.3.1",
"google-auth-httplib2==0.3.1",
"httplib2==0.32.0",
"pyasn1==0.6.4",
),
# ─── Terminal backends ─────────────────────────────────────────────────
"terminal.modal": ("modal==1.3.4",),
"terminal.daytona": ("daytona==0.155.0",),
"terminal.vercel": ("vercel==0.7.2",),
# ─── Skills ────────────────────────────────────────────────────────────
"skill.google_workspace": (
"google-api-python-client==2.194.0",
"google-auth==2.55.1",
"google-auth-oauthlib==1.3.1",
"google-auth-httplib2==0.3.1",
# Explicit transitive pins: httplib2 <0.32 has a decompression-bomb DoS.
"httplib2==0.32.0",
"pyasn1==0.6.4",
),
"skill.youtube": ("youtube-transcript-api==1.2.4",),
# ─── Tools ─────────────────────────────────────────────────────────────
# ACP adapter (VS Code / Zed / JetBrains)
"tool.acp": ("agent-client-protocol==0.9.0",),
"tool.dashboard": (
"fastapi==0.133.1",
"uvicorn[standard]==0.41.0",
"starlette==1.3.1",
"python-multipart==0.0.32", # FastAPI UploadFile/Form streaming uploads
),
# Pillow and firecrawl-anydoc are CORE deps; these entries self-heal lean/partial installs.
# Call sites use prompt=False so read_file / vision never block on input() mid-session.
# Vision image-resize recovery (Pillow). Pillow is now a CORE dependency (pyproject `dependencies`), so
# this entry is a belt-and-suspenders fallback for stripped/source-build installs that somehow dropped
# it. See #40490.
"tool.vision": ("Pillow==12.3.0",),
"tool.doc_extract": ("firecrawl-anydoc==0.2.4",), # imports as `anydoc`; lockstep with pyproject
# MCP client SDK for the cua-driver, so computer_use never dead-ends on `No module named 'mcp'`.
"tool.computer_use": (
"mcp==2.0.0",
"httpx2==2.7.0", # mcp 2.x HTTP stack — sync with pyproject [computer-use]
"starlette==1.3.1",
),
# huggingface-hub is SHARED with transformers (>=1.5.0,<2 via Hindsight) and marked active
# on mere presence, so `hermes update` re-asserts this pin everywhere hub exists. MUST stay
# inside transformers' window and match uv.lock (tests/test_project_metadata.py enforces).
# HF Agent Trace Viewer upload (hermes trace upload / /upload-trace). huggingface-hub is a SHARED
# dependency: transformers (pulled by sentence-transformers for local Hindsight embeddings) requires
# >=1.5.0,<2, and faster-whisper/tokenizers depend on it transitively. Because active_features() marks a
# feature active from mere package presence, the `hermes update` lazy-refresh pass re-asserts THIS pin
# on every install where hub is present — so an exact pin below 1.5.0 force-downgrades the shared
# package and breaks Hindsight startup (#60783). Policy: keep the exact pin (no ranges — security
# posture), but it MUST stay inside transformers' accepted window and MUST match uv.lock so the whole
# tree converges on ONE hub version (tests/test_project_metadata.py enforces both). When bumping: update
# here AND `uv lock --upgrade-package huggingface-hub` in lockstep.
"tool.trace_upload": ("huggingface-hub==1.24.0",),
}
# Spec validation: name[extras]specifier only — no URLs, paths, or shell metachars.
_NAME_RE = r"[A-Za-z0-9_][A-Za-z0-9_.\-]*"
_NAME_EXTRAS_RE = re.compile(rf"^{_NAME_RE}(?:\[[A-Za-z0-9_,\-]+\])?")
_SAFE_SPEC = re.compile(rf"^{_NAME_RE}(?:\[[A-Za-z0-9_,\-]+\])?(?:[<>=!~]=?[A-Za-z0-9_.\-+,*<>=!~]+)?$")
class FeatureUnavailable(RuntimeError):
"""A lazily-installable feature is missing and cannot be made available (installs disabled or failed)."""
def __init__(self, feature: str, missing: tuple[str, ...], reason: str):
self.feature, self.missing, self.reason = feature, missing, reason
spec_list = " ".join(repr(s) for s in missing)
super().__init__(
f"Feature {feature!r} unavailable: {reason}. "
f"To enable manually: uv pip install {spec_list} (or: pip install {spec_list}).")
@dataclass(frozen=True)
class _InstallResult:
success: bool
stdout: str
stderr: str
# Internal bridge var (set by the Docker image, not user config) redirecting lazy installs from the
# sealed venv to a writable durable volume.
_LAZY_TARGET_ENV = "HERMES_LAZY_INSTALL_TARGET"
# Stamp of the Python X.Y + ABI the target was populated for; a mismatch after an image rebuild
# wipes the store so stale .so files are never imported.
_TARGET_STAMP_NAME = ".python-abi"
_SUBPROCESS_KW = dict(capture_output=True, text=True, encoding="utf-8", errors="replace", stdin=subprocess.DEVNULL)
def _python_abi_tag() -> str:
"""X.Y + EXT_SUFFIX (ABI tag + platform); interpreters sharing compiled wheels match."""
return f"{sys.version_info.major}.{sys.version_info.minor}:{sysconfig.get_config_var('EXT_SUFFIX') or ''}"
def _lazy_install_target() -> Optional[Path]:
"""Durable install-target dir (:data:`_LAZY_TARGET_ENV`), or None for venv-scoped mode."""
raw = os.environ.get(_LAZY_TARGET_ENV, "").strip()
return Path(raw) if raw else None
def _ensure_target_ready(target: Path) -> Optional[str]:
"""Create the target dir and validate its ABI stamp; a different-ABI stamp wipes the contents
first (stale .so must never import). None on success, else an error string."""
want = _python_abi_tag()
stamp = target / _TARGET_STAMP_NAME
try:
if target.exists():
have = ""
with contextlib.suppress(OSError):
have = stamp.read_text(encoding="utf-8").strip()
if have and have != want:
logger.info("Lazy install target %s was built for ABI %r but running ABI is %r; wiping stale packages.", target, have, want)
for child in target.iterdir():
if child.is_dir() and not child.is_symlink():
shutil.rmtree(child, ignore_errors=True)
else:
with contextlib.suppress(OSError):
child.unlink()
target.mkdir(parents=True, exist_ok=True)
stamp.write_text(want, encoding="utf-8")
except OSError as e:
return f"lazy install target {target} is not writable: {e}"
return None
def _activate_target_on_syspath(target: Path) -> None:
"""Append the durable target to ``sys.path`` (idempotent). ``site.addsitedir`` honours ``.pth``
files but inserts near the front, so new entries are moved to the END — core venv site-packages must win collisions."""
target_str = str(target)
before = list(sys.path)
if target_str not in before:
site.addsitedir(target_str)
new_entries = [p for p in sys.path if p not in before]
if new_entries:
sys.path[:] = [p for p in sys.path if p not in new_entries] + new_entries
_invalidate_import_caches()
def _invalidate_import_caches() -> None:
"""Make just-installed dists visible to importers and importlib.metadata in this process."""
with contextlib.suppress(Exception):
import importlib
importlib.invalidate_caches()
import importlib.metadata as _md
if hasattr(_md, "_cache_clear"):
_md._cache_clear() # type: ignore[attr-defined]
def activate_durable_lazy_target() -> None:
"""Wire the durable target onto ``sys.path`` at startup so packages installed on a previous run
import on this one. No-op when unset or absent. Never raises."""
target = _lazy_install_target()
try:
if target is not None and target.exists():
_activate_target_on_syspath(target)
except Exception as e: # pragma: no cover - defensive
logger.debug("Failed to activate durable lazy target %s: %s", target, e)
def _allow_lazy_installs() -> bool:
"""Whether lazy installs are permitted: (1) ``security.allow_lazy_installs: false`` blocks in BOTH
modes; (2) the sealed venv (``HERMES_DISABLE_LAZY_INSTALLS=1``) blocks only without a durable
target to redirect into. Unreadable config fails OPEN — blocking is an explicit opt-in."""
cfg = None
with contextlib.suppress(Exception):
from hermes_cli.config import load_config
cfg = load_config()
if cfg is not None and not bool((cfg.get("security") or {}).get("allow_lazy_installs", True)):
return False
if os.environ.get("HERMES_DISABLE_LAZY_INSTALLS") == "1":
return _lazy_install_target() is not None
return True
def _unsupported_feature_reason(feature: str) -> Optional[str]:
"""Platform capability gate (not policy): why a feature cannot work on this host, or None."""
if sys.platform == "win32" and feature == "platform.matrix":
return ("unsupported on Windows: Matrix E2EE depends on python-olm, which has no Windows wheel and "
"requires make + libolm to build from sdist. Run Hermes under WSL to use Matrix on Windows.")
return None
def _spec_is_safe(spec: str) -> bool:
"""Reject pip specs that contain URLs, paths, or shell metacharacters."""
return bool(spec and len(spec) <= 200
and not any(ch in spec for ch in (";", "|", "&", "`", "$", "\n", "\r", "\t", "\\"))
and not spec.startswith(("-", "/", ".")) and "://" not in spec and "@" not in spec
and _SAFE_SPEC.match(spec))
def _pkg_name_from_spec(spec: str) -> str:
"""``"mautrix[encryption]>=0.20"`` -> ``"mautrix"``."""
m = re.match(rf"^({_NAME_RE})", spec)
return m.group(1) if m else spec
def _specifier_from_spec(spec: str) -> str:
"""``"mautrix[encryption]>=0.20,<1"`` -> ``">=0.20,<1"``; ``""`` if unconstrained."""
m = _NAME_EXTRAS_RE.match(spec)
return spec[m.end():] if m else ""
def _installed_version(spec: str) -> Optional[str]:
"""Installed version of the spec's package, or None when absent."""
try:
from importlib.metadata import version
return version(_pkg_name_from_spec(spec))
except Exception:
return None
def _is_satisfied(spec: str) -> bool:
"""Present AND inside the spec's version range, so ``hermes update`` propagates pin bumps to
installed backends. Unparseable specs/versions or a missing ``packaging`` count as satisfied — err
toward "don't churn"."""
installed = _installed_version(spec)
if installed is None:
return False
if not (spec_tail := _specifier_from_spec(spec)):
return True
try:
from packaging.specifiers import SpecifierSet
from packaging.version import Version
return Version(installed) in SpecifierSet(spec_tail)
except Exception:
return True
def _is_present(spec: str) -> bool:
"""Presence-only check (any version) — how :func:`active_features` detects activated backends."""
return _installed_version(spec) is not None
def _core_constraints_file() -> Optional[Path]:
"""Temp ``--constraint`` file pinning every core-venv package to its installed version for
durable-target installs: shared deps resolve as satisfied (store stays minimal) and a conflicting
backend fails loudly instead of installing a shadowed copy that can never win on sys.path. None if
enumeration failed (install unconstrained)."""
try:
import tempfile
from importlib.metadata import distributions
pins: dict[str, str] = {}
for dist in distributions():
name = dist.metadata["Name"] if dist.metadata else None
if name and dist.version and name.lower() not in pins:
pins[name.lower()] = f"{name}=={dist.version}"
if not pins:
return None
fd, path = tempfile.mkstemp(prefix="hermes-core-constraints-", suffix=".txt")
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write("\n".join(sorted(pins.values())) + "\n")
return Path(path)
except Exception as e:
logger.debug("Could not build core constraints file: %s", e)
return None
def _installed_dist_roots(spec: str, target: Optional[Path]) -> set[Path]:
"""Package dirs a freshly installed *spec* owns, from the dist's file list (``python-telegram-bot``
ships ``telegram``; some ship several)."""
name = _pkg_name_from_spec(spec)
roots: set[Path] = set()
try:
import importlib.metadata as _md
dist = next(iter(_md.distributions(name=name, path=[str(target)])), None) if target is not None else _md.distribution(name)
for entry in dist.files or () if dist is not None else ():
top = entry.parts[0] if entry.parts else ""
# Skip hidden entries, __pycache__ and metadata dirs (no importable code).
if not top or top.startswith(".") or top == "__pycache__" or top.endswith((".dist-info", ".egg-info")):
continue
root = Path(dist.locate_file(top))
if root.is_dir():
roots.add(root)
except Exception:
return set()
return roots
def _warm_installed_bytecode(specs: tuple[str, ...], target: Optional[Path]) -> None:
"""Byte-compile what was just installed: a fresh install writes no ``__pycache__``, so the next
import (often a user request, ~2-10s for a big SDK, reading as a hang) would pay the compile. Pay
it here while the caller already waits. Best-effort; never fails the install.
A pip/uv install writes ``.py`` sources and no ``__pycache__`` — and an install of the *same* version
still deletes the cache the old copy had. Whoever imports the package next pays the whole compile: for
``anthropic==0.87.0`` (541 modules) on cpython-3.12.13 that measured 2.2-2.7s cold against 0.7-1.0s
warm, and 10.5s cold under concurrent load. That bill lands wherever the first import happens, and for a
lazily-installed backend that is the foreground of a user request (#100461) — with nothing printed while
it runs, so it reads as a hang. Worse, N per-profile daemons cold-starting together each pay it in full
before any of them has written the cache.
"""
if sys.dont_write_bytecode:
return
try:
import compileall
except Exception: # pragma: no cover — stdlib, but never break an install
return
for spec in specs:
try:
for root in _installed_dist_roots(spec, target):
try:
compileall.compile_dir(str(root), quiet=2, force=False, workers=1)
except Exception as exc:
logger.debug("Bytecode warm skipped for %s: %s", root, exc)
except Exception as exc:
logger.debug("Bytecode warm skipped for %s: %s", spec, exc)
def _run_installer(cmd: list[str], **kw) -> subprocess.CompletedProcess:
# _SUBPROCESS_KW carries stdin=DEVNULL # noqa: subprocess-stdin
return subprocess.run(cmd, **_SUBPROCESS_KW, creationflags=windows_hide_flags(), **kw)
def _uv_binary() -> Optional[str]:
"""Managed uv first ($HERMES_HOME/bin is never on PATH), then PATH. A lookup, not ensure_uv():
downloading uv mid-turn is more than the caller asked for; pip covers no-uv."""
try:
from hermes_cli.managed_uv import resolve_uv
return resolve_uv() or shutil.which("uv")
except Exception:
return shutil.which("uv")
def _write_constraints_file(lines: tuple[str, ...]) -> Path:
import tempfile
fd, path = tempfile.mkstemp(prefix="hermes-plugin-constraints-", suffix=".txt")
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write("\n".join(lines) + "\n")
return Path(path)
def _after_successful_install(specs: tuple[str, ...], target: Optional[Path], dry_run: bool) -> None:
"""Post-install bookkeeping; a dry run installed nothing, so there is nothing to activate/warm."""
if dry_run:
return
if target is not None:
_activate_target_on_syspath(target)
_warm_installed_bytecode(specs, target)
def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300, constraint_lines: tuple[str, ...] = (),
dry_run: bool = False) -> _InstallResult:
"""Install ``specs`` via the uv -> pip -> ensurepip ladder, venv-scoped or into the durable
``--target`` (constrained to core versions) when :data:`_LAZY_TARGET_ENV` is set. Independent of
``hermes_cli.tools_config._pip_install`` (no CLI dependency).
*constraint_lines* pins the resolver (plugin installs pass Hermes' own declared ranges so a plugin
can never move a core package out of range); *dry_run* resolves without installing."""
if not specs:
return _InstallResult(True, "", "")
target = _lazy_install_target()
constraints: Optional[Path] = None
extra_args: list[str] = ["--dry-run"] if dry_run else []
if target is not None:
if err := _ensure_target_ready(target):
return _InstallResult(False, "", err)
constraints = _core_constraints_file()
extra_args += ["--target", str(target)]
elif constraint_lines:
constraints = _write_constraints_file(constraint_lines)
if constraints is not None:
extra_args += ["--constraint", str(constraints)]
def _finish(r: subprocess.CompletedProcess) -> _InstallResult:
if r.returncode == 0:
_after_successful_install(specs, target, dry_run)
return _InstallResult(r.returncode == 0, r.stdout or "", r.stderr or "")
try:
from tools.environments.local import hermes_subprocess_env
uv_env = hermes_subprocess_env(inherit_credentials=False)
uv_env["VIRTUAL_ENV"] = str(Path(sys.executable).parent.parent)
# Tier 1: uv. --compile-bytecode because uv writes no __pycache__ by default, so the first
# import would recompile the backend AND its transitives (_warm_installed_bytecode is the
# belt-and-braces pass for the spec's own roots on any tier).
if uv_bin := _uv_binary():
try:
r = _run_installer([uv_bin, "pip", "install", "--compile-bytecode", *extra_args, *specs], timeout=timeout, env=uv_env)
if r.returncode != 0:
logger.debug("uv pip install failed: %s", r.stderr)
# A uv resolver failure is authoritative: falling through to pip would discard uv
# policy (exclude-newer) and could install a quarantined release.
return _finish(r)
except subprocess.TimeoutExpired as e:
logger.debug("uv invocation failed: %s", e)
return _InstallResult(False, "", f"uv pip install timed out: {e}")
except FileNotFoundError as e: # uv vanished between lookup and spawn; it never evaluated the requirements
logger.debug("uv invocation failed: %s", e)
# Tier 2: python -m pip (ensurepip bootstrap if needed)
pip_cmd = [sys.executable, "-m", "pip"]
try:
if _run_installer(pip_cmd + ["--version"], timeout=15).returncode != 0:
raise FileNotFoundError("pip not in venv")
except (subprocess.TimeoutExpired, FileNotFoundError):
try:
_run_installer([sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"], timeout=120, check=True)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
return _InstallResult(False, "", f"pip not available and ensurepip failed: {e}")
try:
return _finish(_run_installer(pip_cmd + ["install", *extra_args, *specs], timeout=timeout))
except subprocess.TimeoutExpired as e:
return _InstallResult(False, "", f"pip install timed out: {e}")
except Exception as e:
return _InstallResult(False, "", f"pip install failed: {e}")
finally:
if constraints is not None:
with contextlib.suppress(OSError):
constraints.unlink()
def feature_missing(feature: str) -> tuple[str, ...]:
"""Return the subset of specs for ``feature`` not currently installed."""
if feature not in LAZY_DEPS:
raise KeyError(f"Unknown lazy feature: {feature!r}")
return tuple(s for s in LAZY_DEPS[feature] if not _is_satisfied(s))
def _prompt_toolkit_active() -> bool:
"""A bare input() deadlocks while a prompt_toolkit app owns the terminal, so ensure() skips the
confirmation under the TUI — reaching it is already gated by security.allow_lazy_installs."""
if "prompt_toolkit.application.current" not in sys.modules:
return False
try:
from prompt_toolkit.application.current import get_app_or_none
return bool(getattr(get_app_or_none(), "is_running", False))
except Exception:
return False
def ensure(feature: str, *, prompt: bool = True) -> None:
"""Make every package for ``feature`` importable, installing if needed; raises
:class:`FeatureUnavailable` when installs are disabled or fail. ``prompt``: confirm on a TTY first
(non-interactive callers pass False and rely on the config gate)."""
if feature not in LAZY_DEPS:
raise FeatureUnavailable(feature, (), f"feature {feature!r} not in LAZY_DEPS allowlist")
missing = feature_missing(feature)
if not missing:
return
if unsupported := _unsupported_feature_reason(feature):
raise FeatureUnavailable(feature, missing, unsupported)
# Package-manager installs (NixOS etc.) have read-only site-packages: fail fast instead of burning
# ~15s on ensurepip — unless a durable target is configured. The reason MUST start with
# "unsupported ": _refresh_features classifies skips by that prefix.
if _lazy_install_target() is None:
managed_by = "" # config unreadable — proceed with the install
with contextlib.suppress(Exception):
from hermes_cli.config import get_managed_system
managed_by = get_managed_system()
if managed_by:
raise FeatureUnavailable(
feature, missing,
f"unsupported on {managed_by}-managed installs: this build's packages come from {managed_by}, "
f"so Hermes cannot install them at runtime. Add the dependencies for {feature!r} via "
f"{managed_by} (or run a pip/uv install of Hermes instead).")
for spec in missing: # belt and braces on top of the allowlist
if not _spec_is_safe(spec):
raise FeatureUnavailable(feature, missing, f"refusing to install unsafe spec {spec!r}")
if not _allow_lazy_installs():
raise FeatureUnavailable(feature, missing, "lazy installs disabled (security.allow_lazy_installs=false)")
if prompt and not _prompt_toolkit_active() and sys.stdin.isatty() and sys.stdout.isatty():
try:
answer = input(f"\nFeature {feature!r} requires: {', '.join(missing)}\nInstall into the active venv now? [Y/n] ").strip().lower()
except (EOFError, KeyboardInterrupt):
answer = "n"
if answer and answer not in {"y", "yes"}:
raise FeatureUnavailable(feature, missing, "user declined install at prompt")
logger.info("Lazy-installing %s for feature %r", " ".join(missing), feature)
result = _venv_pip_install(missing)
if not result.success: # surface pip's own error (quarantine 404, network), tail-clipped
snippet = (result.stderr or result.stdout or "").strip()[-2000:]
raise FeatureUnavailable(feature, missing, f"pip install failed: {snippet or 'no error output'}")
_invalidate_import_caches()
if still_missing := feature_missing(feature):
raise FeatureUnavailable(feature, still_missing, "install reported success but packages still not importable (may require Python restart)")
logger.info("Lazy install complete for feature %r", feature)
def is_available(feature: str) -> bool:
"""Return True if the feature's deps are already satisfied."""
return feature in LAZY_DEPS and not feature_missing(feature)
def feature_install_command(feature: str, *, venv_pip: bool = False) -> Optional[str]:
"""Manual install command for a feature, or None. ``venv_pip=True`` uses ``{sys.executable} -m pip``
— immune to PEP 668 failures a bare ``pip install`` invites."""
if feature not in LAZY_DEPS:
return None
joined = " ".join(repr(s) for s in LAZY_DEPS[feature])
return f"{sys.executable} -m pip install {joined}" if venv_pip else "uv pip install " + joined
@dataclass
class InstallSpecsResult:
"""Outcome of :func:`install_specs` for one batch of pip specs. ``blocked`` means installs are gated
off (config kill switch, sealed venv without a durable target) or a spec failed validation — nothing
was executed, ``reason`` says why. ``command`` is the human-readable description of what ran."""
ok: bool
blocked: bool = False
reason: str = ""
command: str = ""
stdout: str = ""
stderr: str = ""
def install_specs(specs: list[str] | tuple[str, ...], *, timeout: int = 300,
constraints: list[str] | tuple[str, ...] = (), dry_run: bool = False) -> InstallSpecsResult:
"""Install data-driven pip specs (plugin manifest ``pip_dependencies``) with the same routing and
gating as :func:`ensure`, but unknown packages are allowed — the caller owns manifest trust, this
owns spec hygiene. *constraints* are requirement lines the resolver must honour; *dry_run* only
resolves. Never raises; inspect the :class:`InstallSpecsResult`."""
cleaned = tuple(str(s).strip() for s in specs if str(s).strip())
if not cleaned:
return InstallSpecsResult(ok=True, command="")
for spec in cleaned:
if not _spec_is_safe(spec):
return InstallSpecsResult(ok=False, blocked=True, reason=f"refusing to install unsafe spec {spec!r}")
target = _lazy_install_target()
if not _allow_lazy_installs():
sealed = os.environ.get("HERMES_DISABLE_LAZY_INSTALLS") == "1" and target is None
reason = ("runtime installs are disabled on this deployment: the agent environment is immutable "
"and no writable install target is configured (HERMES_LAZY_INSTALL_TARGET)"
) if sealed else "runtime installs disabled (security.allow_lazy_installs=false)"
return InstallSpecsResult(ok=False, blocked=True, reason=reason)
display = "uv pip install " + (f"--target {target} " if target is not None else "") + " ".join(cleaned)
logger.info("%s pip specs %s (target=%s)", "Resolving" if dry_run else "Installing", " ".join(cleaned), target or "venv")
try:
result = _venv_pip_install(cleaned, timeout=timeout, constraint_lines=tuple(constraints), dry_run=dry_run)
except Exception as exc:
logger.warning("install_specs failed unexpectedly: %s", exc)
return InstallSpecsResult(ok=False, command=display, stderr=f"install failed: {exc}")
_invalidate_import_caches() # dashboard rechecks availability inline
return InstallSpecsResult(ok=result.success, command=display, stdout=result.stdout, stderr=result.stderr)
def active_features() -> list[str]:
"""Features whose ANCHOR package (first spec) is present at any version — shared helpers like
asyncpg are deliberately not proof a backend was enabled. Drives ``hermes update``."""
return [f for f, specs in LAZY_DEPS.items() if specs and _is_present(specs[0])]
def refresh_active_features(*, prompt: bool = False) -> dict[str, str]:
"""Re-run ``ensure`` for every active feature (``hermes update``); returns
``{feature: "current" | "refreshed" | "failed: <reason>" | "skipped: <reason>"}``. Never raises."""
return _refresh_features(active_features(), prompt=prompt, restoring=False)
def restore_features(features: list[str]) -> dict[str, str]:
"""Restore features captured before a managed-runtime rebuild; opt-out -> "skipped"."""
return _refresh_features(features, prompt=False, restoring=True)
def _refresh_features(features: list[str], *, prompt: bool, restoring: bool) -> dict[str, str]:
"""Refresh or restore a known set of allowlisted lazy features."""
results: dict[str, str] = {}
for feature in features:
if feature not in LAZY_DEPS:
continue
if not feature_missing(feature):
results[feature] = "current"
continue
if unsupported := _unsupported_feature_reason(feature):
results[feature] = f"skipped: {unsupported}"
continue
try:
ensure(feature, prompt=False if restoring else prompt)
results[feature] = "restored" if restoring else "refreshed"
except FeatureUnavailable as e: # opt-outs and platform-incompatible features are skips, not failures
skip = "lazy installs disabled" in str(e) or "declined" in str(e) or e.reason.startswith("unsupported ")
results[feature] = f"skipped: {e.reason}" if skip else f"failed: {e.reason}"
except Exception as e:
results[feature] = f"failed: {e}"
return results
def ensure_and_bind(feature: str, importer: Callable[[], dict[str, Any]], target_globals: dict, *, prompt: bool = False) -> bool:
""":func:`ensure` the feature, then ``target_globals.update(importer())`` so module-level names are
rebound after a lazy install (``importer`` returns ``{name: obj}`` and runs only after ensure
succeeds). Returns False (and logs) if deps could not be installed or imported."""
try:
ensure(feature, prompt=prompt)
except FeatureUnavailable as exc:
logger.warning("%s", exc)
return False
except Exception as exc:
logger.warning("Failed to ensure feature %r: %s", feature, exc)
return False
try:
target_globals.update(importer())
except ImportError as exc:
logger.warning("Failed to import feature %r after install: %s", feature, exc)
return False
return True
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
def feature_specs(feature: str) -> tuple[str, ...]:
"""Return the registered specs for a feature, or raise KeyError."""
if feature not in LAZY_DEPS:
raise KeyError(f"Unknown lazy feature: {feature!r}")
return LAZY_DEPS[feature]
# ---- END PLUGIN-COMPAT ----
def install_specs(specs: list[str] | tuple[str, ...], *, timeout: int = 300) -> NoReturn:
# Shim to suppress old updater work until relaunch. Do not install or report success.
stop_for_relaunch()
+1 -1
View File
@@ -546,7 +546,7 @@ def _ready_after_first_use_install(provider: str) -> bool:
if key_env and not _resolve_provider_key(key_env, provider):
return False
try:
from pm.ensure import lazy_installs_allowed
from pm.install import lazy_installs_allowed
from pm.extras import extra_supported
except Exception:
return False
+2 -2
View File
@@ -324,7 +324,7 @@ def _tts_ready() -> bool:
if feature is not None:
try:
import pm
from pm.ensure import lazy_installs_allowed
from pm.install import lazy_installs_allowed
if not pm.available(feature):
# Not installed: ready iff it can install at first speak.
@@ -345,7 +345,7 @@ def check_wake_word_requirements(cfg: Optional[Dict[str, Any]] = None, *,
"""Report whether wake-word detection can run, with a remediation hint."""
cfg = cfg if cfg is not None else load_wake_word_config()
import pm
from pm.ensure import lazy_installs_allowed
from pm.install import lazy_installs_allowed
from pm.extras import extra_supported
supported = supported or extra_supported