mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
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:
1 parent
cf61742a47
commit
6a5a6a05d2
62 files changed
+230
-1085
No files matched your search
+1
-1
@@ -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; \
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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())
|
||||
|
||||
File renamed without changes.
+2
-2
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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."
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}})
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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("*"))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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
@@ -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)."
|
||||
)
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user