From bbec97351485932cbad3f25a809c8e9c45d14b60 Mon Sep 17 00:00:00 2001 From: ethernet Date: Fri, 18 Sep 2026 20:02:36 -0400 Subject: [PATCH] refactor(pm): pm owns the dependency-environment layout and interpreter paths hermes_cli.runtime_paths (venv generations, selection, activation) moves to pm.environments, and gains venv_bin_dir / venv_python / project_python. Every in-tree caller asks pm for an interpreter now; pm no longer reaches back into hermes_cli for its own environment layout (pm.packages, pm.extras, pm.ensure, pm.paths imported hermes_cli.runtime_paths). The three open-coded "Scripts/python.exe or bin/python" ladders in pm collapse onto venv_python. hermes_constants.venv_python_path / venv_bin_dir and hermes_cli.runtime_paths stay as frozen-updater-surface shims only (tests/compat/old_updater_surface.json). To keep the boot path light, pm/__init__ resolves its facade lazily (PEP 562) and pm.registry loads the built-in package definitions on first read instead of at import: `import hermes_bootstrap` now loads pm + pm.environments only (25ms, was 37ms with the eager facade dragging in the downloader). The stripped-payload fixtures that ship only pre-import files keep working for the same reason. Also restores two frozen-surface re-exports the F401 sweep dropped (banner._github_compare_behind, cua_backend.resolve_cua_driver_cmd). --- .../electron/backend-probes-runtime.test.ts | 2 +- .../electron/fixtures/source-backend.py | 2 +- cron/scheduler_script.py | 2 +- hermes_bootstrap.py | 2 +- hermes_cli/_early_recovery.py | 4 +- hermes_cli/_install_repair.py | 3 +- hermes_cli/_launchers.py | 2 +- hermes_cli/_update_takeover.py | 4 +- hermes_cli/banner.py | 1 + hermes_cli/boot_bootstrap.py | 2 +- hermes_cli/data_cleanup.py | 2 +- hermes_cli/doctor_platform.py | 4 +- hermes_cli/gateway.py | 2 +- hermes_cli/macos_tcc_anchor.py | 10 +- hermes_cli/main_install_repair.py | 3 +- hermes_cli/memory_setup.py | 2 +- hermes_cli/runtime_paths.py | 229 +---------------- hermes_cli/runtime_state.py | 4 +- hermes_cli/source_completion.py | 9 +- hermes_cli/update_channel.py | 2 +- hermes_cli/update_completion.py | 7 +- hermes_cli/update_handoff.py | 5 +- hermes_cli/update_serve_resume.py | 2 +- hermes_cli/venv_sync.py | 2 +- hermes_constants.py | 22 +- pm/__init__.py | 104 +++----- pm/ensure.py | 4 +- pm/environment.py | 4 +- pm/environments.py | 241 ++++++++++++++++++ pm/extras.py | 2 +- pm/operations.py | 4 +- pm/packages.py | 4 +- pm/paths.py | 4 +- pm/plugins_state.py | 4 +- pm/publication.py | 2 +- pm/registry.py | 27 +- pm/runtime.py | 6 +- pm/store.py | 8 +- scripts/_activation.py | 2 +- scripts/ci/setup_toolchain.py | 2 +- tests/cron/test_cron_script.py | 2 +- tests/hermes_cli/plugin_worker_support.py | 2 +- tests/hermes_cli/test_data_uninstall.py | 2 +- .../hermes_cli/test_doctor_command_install.py | 4 +- tests/hermes_cli/test_early_recovery.py | 2 +- tests/hermes_cli/test_gateway_service.py | 2 +- tests/hermes_cli/test_install_lock_timeout.py | 4 +- .../test_launcher_runtime_selection.py | 11 +- tests/hermes_cli/test_macos_tcc_anchor.py | 22 +- .../test_memory_dependency_admission.py | 2 +- tests/hermes_cli/test_old_updater_takeover.py | 5 +- .../hermes_cli/test_plugin_update_recovery.py | 2 +- .../test_plugin_update_transaction.py | 4 +- tests/hermes_cli/test_runtime_paths.py | 2 +- .../test_source_launcher_publication.py | 4 +- .../test_update_completion_process.py | 18 +- tests/hermes_cli/test_update_finish.py | 4 +- .../test_update_launch_completion.py | 2 +- .../test_update_missing_configured_deps.py | 6 +- .../hermes_cli/test_update_zip_completion.py | 6 +- tests/hermes_cli/test_venv_sync_currency.py | 2 +- .../test_web_memory_provider_setup_install.py | 6 +- tests/install/e2e-assets/source_driver.py | 2 +- tests/installation_launcher_fixture.py | 2 +- .../memory/test_hindsight_embedded_runtime.py | 12 +- tests/pm/test_activate_scripts.py | 2 +- tests/pm/test_activation_runtime.py | 2 +- tests/pm/test_bootstrap_import_closure.py | 2 +- tests/pm/test_cold_runtime_e2e.py | 4 +- tests/pm/test_environment_build.py | 10 +- tests/pm/test_feature_inventory.py | 2 +- tests/pm/test_features.py | 2 +- tests/pm/test_installed_package.py | 2 +- tests/pm/test_plugin_survival_contract.py | 4 +- tests/pm/test_recovery.py | 6 +- tests/pm/test_runtime_boot_gc.py | 4 +- tests/pm/test_runtime_context_home.py | 2 +- .../test_runtime_journal_concurrent_edit.py | 2 +- tests/pm/test_runtime_journal_safety.py | 2 +- tests/pm/test_runtime_selection.py | 14 +- tests/pm/test_source_update_launch.py | 6 +- tests/pm/test_startup_recovery.py | 4 +- tests/pm/test_takeover_preparation.py | 2 +- tests/pm/test_worker.py | 12 +- tests/pm/test_worker_publication.py | 14 +- tests/scripts/test_bundle_native.py | 4 +- tests/scripts/test_commit_stamp_identity.py | 2 +- tests/scripts/test_desktop_toolchain.py | 2 +- tests/scripts/test_mint_launchers.py | 2 +- tests/scripts/test_pm_runtime_bundle.py | 2 +- tests/scripts/test_release_build_commit.py | 2 +- tests/scripts/test_setup_toolchain.py | 2 +- tests/scripts/test_source_build_env.py | 2 +- tests/scripts/test_source_driver.py | 2 +- tests/test_plugin_historic_compat.py | 4 +- tests/test_source_launcher_stages.py | 2 +- tests/tools/test_local_env_blocklist.py | 4 +- tests/tools/test_tts_lifecycle_leases.py | 2 +- tools/computer_use/cua_backend.py | 5 +- tools/environments/local_pythonpath.py | 4 +- .../docs/developer-guide/pm-audit-status.md | 2 +- 101 files changed, 525 insertions(+), 493 deletions(-) create mode 100644 pm/environments.py diff --git a/apps/desktop/electron/backend-probes-runtime.test.ts b/apps/desktop/electron/backend-probes-runtime.test.ts index 6b215ea797..9872d15f95 100644 --- a/apps/desktop/electron/backend-probes-runtime.test.ts +++ b/apps/desktop/electron/backend-probes-runtime.test.ts @@ -38,7 +38,7 @@ import json, os, re, shutil, subprocess, sys, tomllib, venv from pathlib import Path root, temp = map(Path, sys.argv[1:]) sys.path.insert(0, str(root)) -from hermes_cli.runtime_paths import install_state_dir, runtime_facts_path, site_packages +from pm.environments import install_state_dir, runtime_facts_path, site_packages seed = temp / 'seed' venv.EnvBuilder(with_pip=False).create(seed) dependencies = temp / 'dependencies' diff --git a/apps/desktop/electron/fixtures/source-backend.py b/apps/desktop/electron/fixtures/source-backend.py index 28e9b2fe49..a000bf8dc4 100644 --- a/apps/desktop/electron/fixtures/source-backend.py +++ b/apps/desktop/electron/fixtures/source-backend.py @@ -57,7 +57,7 @@ def main() -> None: from pm import paths from pm.lock import Facts from pm.store import current_target, tree_digest - from hermes_cli.runtime_paths import selected_venv + from pm.environments import selected_venv from hermes_cli._launchers import ensure_install_launchers worker = root / "pm" / "worker.py" diff --git a/cron/scheduler_script.py b/cron/scheduler_script.py index 75d02bcef6..b67efc41c7 100644 --- a/cron/scheduler_script.py +++ b/cron/scheduler_script.py @@ -131,7 +131,7 @@ def _windows_cron_python_invocation(python_exe: str) -> tuple[str, dict[str, str interpreter = sibling from hermes_cli._launchers import resolve_store_python - from hermes_cli.runtime_paths import selected_venv, site_packages as dependency_site + from pm.environments import selected_venv, site_packages as dependency_site repo = Path(__file__).resolve().parents[1] managed_python = resolve_store_python(repo) diff --git a/hermes_bootstrap.py b/hermes_bootstrap.py index 73c7a688b8..6afac1b9a1 100644 --- a/hermes_bootstrap.py +++ b/hermes_bootstrap.py @@ -311,7 +311,7 @@ suppress_platform_ver_console() # Every entry point imports this module before its dependency graph. from pathlib import Path -from hermes_cli.runtime_paths import activate_dependencies +from pm.environments import activate_dependencies from hermes_cli._early_recovery import recover_if_needed from hermes_cli._parser import command_argv diff --git a/hermes_cli/_early_recovery.py b/hermes_cli/_early_recovery.py index b834b0506e..ccaa9c058f 100644 --- a/hermes_cli/_early_recovery.py +++ b/hermes_cli/_early_recovery.py @@ -181,7 +181,7 @@ def recover_if_needed(project_root: Path | None = None, argv: list[str] | None = args = command_argv(sys.argv[1:] if argv is None else argv) if not explicit and args[:1] == ["pm"]: return False # PM's command boundary owns the explicit repair. - from hermes_cli.runtime_paths import install_state_dir, runtime_facts_path, selected_venv, site_packages + from pm.environments import install_state_dir, runtime_facts_path, selected_venv, site_packages missing_marker = install_state_dir(root) / ".repair-incomplete" marker_paths = (root / ".update-incomplete", root / ".lazy-refresh-incomplete", missing_marker) @@ -249,7 +249,7 @@ _EARLY_CORE_INSTALL_MAX_ATTEMPTS = 3 def _claim_recovery_lock(root: Path) -> int | None: """Hold a kernel lock in writable state; process exit releases it.""" - from hermes_cli.runtime_paths import install_state_dir + from pm.environments import install_state_dir from hermes_cli.runtime_state import _lock state = install_state_dir(root) diff --git a/hermes_cli/_install_repair.py b/hermes_cli/_install_repair.py index d94824487d..2ab15e2c25 100644 --- a/hermes_cli/_install_repair.py +++ b/hermes_cli/_install_repair.py @@ -281,7 +281,8 @@ def migrate_windows_bin_path( root = Path(root) # Same per-machine anchor as ensure_windows_bin_launchers (see there). - from hermes_constants import get_default_hermes_root, venv_bin_dir + from hermes_constants import get_default_hermes_root + from pm.environments import venv_bin_dir try: home = Path(get_default_hermes_root()) diff --git a/hermes_cli/_launchers.py b/hermes_cli/_launchers.py index 4f21358414..d37de86c87 100644 --- a/hermes_cli/_launchers.py +++ b/hermes_cli/_launchers.py @@ -21,7 +21,7 @@ if __name__ == "__main__": sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from hermes_constants import get_hermes_home -from hermes_cli.runtime_paths import dependency_home_root, store_root +from pm.environments import dependency_home_root, store_root def runtime_command(repo_root: Path, args=(), *, module: str = "hermes_cli.main", diff --git a/hermes_cli/_update_takeover.py b/hermes_cli/_update_takeover.py index b14db5360a..dcb1d49489 100644 --- a/hermes_cli/_update_takeover.py +++ b/hermes_cli/_update_takeover.py @@ -21,7 +21,7 @@ def prepare(request: dict) -> tuple[Path, dict[str, str]]: from pm.client import ensure, sync_venv, venv_is_current from pm.lock import Lockfile from pm.registry import source_install_packages - from hermes_cli.runtime_paths import activation_environment, install_state_dir, runtime_facts_path + from pm.environments import activation_environment, install_state_dir, runtime_facts_path from hermes_cli._launchers import resolve_store_python from hermes_cli.venv_sync import publish_launchers @@ -68,7 +68,7 @@ def main() -> int: # Historical atexit cleanup may run after the update's result is fixed. # It must reuse that installation, never start a second update/repair. from hermes_cli._launchers import resolve_store_python - from hermes_cli.runtime_paths import activation_environment + from pm.environments import activation_environment root = Path(request["root"]) python = resolve_store_python(root) diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index 0d71fc2203..655aa74db8 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -9,6 +9,7 @@ import threading from pathlib import Path from hermes_cli import source_check # Historical updater import (tests/compat/old_updater_surface.json). In-tree callers use the owner. +from hermes_cli.source_check import _github_compare_behind # noqa: F401 from hermes_constants import get_hermes_home from typing import TYPE_CHECKING, Any, Dict, List, Optional diff --git a/hermes_cli/boot_bootstrap.py b/hermes_cli/boot_bootstrap.py index 4caa8530c6..a5cd8dc9c7 100644 --- a/hermes_cli/boot_bootstrap.py +++ b/hermes_cli/boot_bootstrap.py @@ -8,7 +8,7 @@ an explicit post-update run). PM owns runtime diagnosis, not this record. """ from __future__ import annotations -from hermes_cli.runtime_paths import install_state_dir +from pm.environments import install_state_dir import json import logging import os diff --git a/hermes_cli/data_cleanup.py b/hermes_cli/data_cleanup.py index 082976cead..0aeacb7dcc 100644 --- a/hermes_cli/data_cleanup.py +++ b/hermes_cli/data_cleanup.py @@ -17,7 +17,7 @@ class DataRemovalPlan: def plan_data_removal(home: Path, project: Path, userdata: Path | None = None) -> DataRemovalPlan: from hermes_constants import get_default_hermes_root - from hermes_cli.runtime_paths import base_venv, installs_root, store_root + from pm.environments import base_venv, installs_root, store_root from hermes_cli.steward import is_bundled_payload from tools.checkpoint_pruning import store_lock_path diff --git a/hermes_cli/doctor_platform.py b/hermes_cli/doctor_platform.py index 766ee76f16..2afa6b295e 100644 --- a/hermes_cli/doctor_platform.py +++ b/hermes_cli/doctor_platform.py @@ -464,7 +464,7 @@ def _check_python_environment(should_fix: bool, f: Finding) -> None: # sys.path. Neither sys.prefix nor a stale PYTHONPATH proves activation. staged = _staged_venv_dir() if staged is not None: - from hermes_cli.runtime_paths import site_packages + from pm.environments import site_packages selected_site = site_packages(staged).resolve() running_here = selected_site.is_dir() and any(Path(entry).resolve() == selected_site for entry in sys.path) @@ -537,7 +537,7 @@ def _check_command_installation(should_fix: bool, f: Finding) -> None: f.manual_issues.append(_python_repair_hint()) return from hermes_cli._launchers import resolve_store_python - from hermes_cli.runtime_paths import base_venv, selected_venv + from pm.environments import base_venv, selected_venv try: selected = selected_venv(PROJECT_ROOT) diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index c9939f0a31..3eef1c1643 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -6811,7 +6811,7 @@ def _pm_runtime_venv_dir(project_root: Path | None = None) -> Path | None: selection raises — fail closed, never a silently wrong venv. """ root = Path(project_root) if project_root is not None else PROJECT_ROOT - from hermes_cli.runtime_paths import selected_venv + from pm.environments import selected_venv venv = selected_venv(root) # a malformed committed selection raises: fail closed return venv if venv.is_dir() else None diff --git a/hermes_cli/macos_tcc_anchor.py b/hermes_cli/macos_tcc_anchor.py index 0113315a17..3fc8f116da 100644 --- a/hermes_cli/macos_tcc_anchor.py +++ b/hermes_cli/macos_tcc_anchor.py @@ -23,7 +23,7 @@ import sys import tempfile from pathlib import Path -from hermes_constants import venv_python_path +from pm.environments import venv_python from utils import atomic_write_text logger = logging.getLogger(__name__) @@ -60,7 +60,7 @@ def _is_uv_macos_store(path: str) -> bool: def _venv_dir(project_root: Path | None = None) -> Path | None: root = Path(project_root) if project_root is not None else Path(__file__).resolve().parents[1] - return next((root / n for n in ("venv", ".venv") if _present(venv_python_path(root / n))), None) + return next((root / n for n in ("venv", ".venv") if _present(venv_python(root / n))), None) def _present(path: Path) -> bool: @@ -85,7 +85,7 @@ def _interpreter_file(src: str | Path) -> Path | None: def _interpreter_source(venv_dir: Path) -> str | None: """Return the interpreter file the venv currently resolves to (symlink target or pyvenv.cfg home).""" - venv_py = venv_python_path(venv_dir) + venv_py = venv_python(venv_dir) if venv_py.is_symlink(): try: return str(venv_py.resolve(strict=False)) @@ -113,7 +113,7 @@ def _managed_venv(project_root: Path | None) -> tuple[Path, Path, str] | str: source = _interpreter_source(venv_dir) if source is None or not _is_uv_macos_store(source): return "interpreter not uv-managed (stable path)" - return venv_dir, venv_python_path(venv_dir), source + return venv_dir, venv_python(venv_dir), source def _anchor_marker(venv_bin: Path) -> Path: @@ -283,7 +283,7 @@ def _install_anchor(venv_dir: Path, source_file: Path) -> None: """Replace ``bin/python`` with a signed copy, gated on a real boot.""" from hermes_cli.macos_signing import sign_managed_python - venv_py = venv_python_path(venv_dir) + venv_py = venv_python(venv_dir) venv_bin = venv_py.parent venv_bin.mkdir(parents=True, exist_ok=True) diff --git a/hermes_cli/main_install_repair.py b/hermes_cli/main_install_repair.py index 6b817bbbe2..1755eb1074 100644 --- a/hermes_cli/main_install_repair.py +++ b/hermes_cli/main_install_repair.py @@ -93,7 +93,8 @@ def _is_windows() -> bool: def _venv_scripts_dir() -> Path | None: """Return the venv Scripts directory if we're running inside the project venv.""" from hermes_cli.main import PROJECT_ROOT - from hermes_constants import project_venv_dir, venv_bin_dir + from hermes_constants import project_venv_dir + from pm.environments import venv_bin_dir venv_dir = project_venv_dir(PROJECT_ROOT) if venv_dir is None: return None diff --git a/hermes_cli/memory_setup.py b/hermes_cli/memory_setup.py index bac6a6cd31..c95e0e612c 100644 --- a/hermes_cli/memory_setup.py +++ b/hermes_cli/memory_setup.py @@ -85,7 +85,7 @@ def prepare_memory_provider_dependencies(provider_name: str) -> tuple[dict, str if not inputs: return meta, None pm.sync_venv(explicit=True, **inputs) - from hermes_cli.runtime_paths import selected_venv, site_packages + from pm.environments import selected_venv, site_packages from pm.paths import repo_root selected = site_packages(selected_venv(repo_root())).resolve() diff --git a/hermes_cli/runtime_paths.py b/hermes_cli/runtime_paths.py index 140e3cfdfe..09bf5f5819 100644 --- a/hermes_cli/runtime_paths.py +++ b/hermes_cli/runtime_paths.py @@ -1,220 +1,13 @@ -"""Dependency-environment paths shared by PM and pre-import launchers. +"""Frozen updater surface: pre-PM updaters import these names from here. -Only stdlib and hermes_constants: environment selection must work before -any dependency from that environment has been imported. +The implementation moved to ``pm.environments``; in-tree code imports it from there. """ -from __future__ import annotations - -import hashlib -import json -import os -from pathlib import Path - -from hermes_constants import get_default_hermes_root, project_venv_dir - - -def install_key(project_root: Path) -> str: - canonical = str(Path(project_root).resolve()) - return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16] - - -def dependency_home_root() -> Path: - """Scope dependency state like a process launched in the active home.""" - from hermes_constants import get_default_hermes_root, get_hermes_home_override - - override = get_hermes_home_override() - return get_default_hermes_root(home=override) if override else get_default_hermes_root() - - -def installs_root() -> Path: - return dependency_home_root() / "installs" - - -def install_state_dir(project_root: Path) -> Path: - return installs_root() / install_key(project_root) - - -def runtime_facts_path(project_root: Path) -> Path: - return install_state_dir(project_root) / "facts.json" - - -def base_venv(project_root: Path) -> Path: - root = Path(project_root).resolve() - manifest_path = root.parent / "manifest.json" - if manifest_path.is_file(): - manifest = json.loads(manifest_path.read_text(encoding="utf-8-sig")) - if (root.parent / manifest.get("repo", "")).resolve() == root: - venv = (root.parent / manifest["venv"]).resolve() - if not venv.is_relative_to(root.parent): - raise RuntimeError("payload environment escapes its root") - return venv - return project_venv_dir(root) or root / "venv" - - -def store_root(project_root: Path) -> Path: - """Resolve a payload-relative or stamped store before PM imports.""" - override = os.environ.get("HERMES_RUNTIME_DIR") - if override: - return Path(override).resolve() - root = Path(project_root).resolve() - manifest_path = root.parent / "manifest.json" - if manifest_path.is_file(): - manifest = json.loads(manifest_path.read_text(encoding="utf-8-sig")) - if (root.parent / manifest.get("repo", "")).resolve() == root: - store = (root.parent / manifest["store"]).resolve() - if not store.is_relative_to(root.parent): - raise RuntimeError("payload store escapes its root") - return store - for directory in (root, *root.parents): - stamp = directory / "install-stamp.json" - if stamp.is_file(): - try: - data = json.loads(stamp.read_text(encoding="utf-8-sig")) - except (OSError, ValueError): - return get_default_hermes_root() / "tools" - value = data.get("runtimeDir") if isinstance(data, dict) else None - return Path(value).resolve() if value else get_default_hermes_root() / "tools" - return get_default_hermes_root() / "tools" - - -def selected_venv(project_root: Path) -> Path: - """Use the committed environment, or the original install before first sync. - - A broken committed selection is an error, not permission to load an older - dependency set silently. Reading this function never creates user state. - """ - path = runtime_facts_path(project_root) - try: - data = json.loads(path.read_text(encoding="utf-8-sig")) - except FileNotFoundError: - return base_venv(project_root) - except (OSError, ValueError) as exc: - raise RuntimeError(f"cannot read dependency environment: {path}") from exc - try: - fact = data.get("packages", {}).get("venv", {}) - value = fact.get("environment") - except AttributeError as exc: - raise RuntimeError(f"invalid dependency environment record: {path}") from exc - if value is None: - return base_venv(project_root) - if not isinstance(value, str): - raise RuntimeError("invalid dependency environment path") - environment = Path(value).resolve() - generations = install_state_dir(project_root) / "environments" - if not environment.is_relative_to(generations.resolve()) or not (environment / "pyvenv.cfg").is_file(): - raise RuntimeError(f"dependency environment is missing or outside this install: {environment}") - return environment - - -def venv_python_version(venv: Path) -> tuple[int, int] | None: - """The interpreter version a POSIX venv actually holds, or ``None``. - - ``site_packages`` must not date the tree from the CALLER's ``sys.version_info``: - an update can rebuild the dependency environment with a different Python than - the launcher that later imports it. Observed on an app-driven upgrade -- PM - built the environment with CPython 3.14 while the PATH shim ran 3.11, so the - shim composed ``lib/python3.11/site-packages`` inside a 3.14 venv, found no - tree, and failed *after* a successful update. - """ - try: - for line in (venv / "pyvenv.cfg").read_text(encoding="utf-8").splitlines(): - key, _, value = line.partition("=") - if key.strip() != "version": - continue - major, _, rest = value.strip().partition(".") - minor, _, _ = rest.partition(".") - if major.isdigit() and minor.isdigit(): - return int(major), int(minor) - except OSError: - pass - try: - candidates = sorted((venv / "lib").glob("python3*")) - except OSError: - return None - for candidate in candidates: - major, _, rest = candidate.name.removeprefix("python").partition(".") - minor, _, _ = rest.partition(".") - if major.isdigit() and minor.isdigit(): - return int(major), int(minor) - return None - - -def site_packages(venv: Path) -> Path: - import sys - - if os.name == "nt": - return venv / "Lib/site-packages" - version = venv_python_version(venv) or (sys.version_info.major, sys.version_info.minor) - return venv / f"lib/python{version[0]}.{version[1]}/site-packages" - - -def activate_dependencies(project_root: Path) -> None: - """Select the committed tree at process boot, before third-party imports. - - A process with no extension selection keeps its original launch contract. - Already-running processes are never switched after a dependency install. - """ - import sys - - state = install_state_dir(project_root) - if state.is_dir(): - from hermes_cli.runtime_state import runtime_lock, recover_publication, lease_generation - # The lock's holder may be another profile's backend running a full dependency rebuild; - # this process only reads the committed selection, so it proceeds without waiting rather - # than leaving the backend unbound (see runtime_lock). - with runtime_lock(project_root) as held: - if held: - recover_publication(project_root) - environment = selected_venv(project_root) - lease_generation(environment) - selected = site_packages(environment) - if not selected.is_dir() and not runtime_facts_path(project_root).is_file(): - return - else: - # Older installs and sealed payloads still select once, before imports. - # Never consult VIRTUAL_ENV: it can describe the invoking shell's Python. - environment = base_venv(project_root) - selected = site_packages(environment) - if not selected.is_dir(): - return # External/Nix interpreter owns its original sys.path. - if not selected.is_dir(): - raise RuntimeError(f"dependency environment has no site-packages: {selected}") - import site - - sys.path[:] = [entry for entry in sys.path - if Path(entry).name not in ("site-packages", "dist-packages") - and Path(entry).resolve() != project_root.resolve()] - # uv editable members are activated by .pth files, not by sys.path alone. - site.addsitedir(str(selected)) - sys.path[:] = [str(project_root.resolve()), str(selected), - *[entry for entry in sys.path if Path(entry).resolve() != selected.resolve()]] - os.environ["PYTHONPATH"] = os.pathsep.join([str(project_root.resolve()), str(selected)]) - os.environ.pop("VIRTUAL_ENV", None) - executable_dir = environment / ("Scripts" if os.name == "nt" else "bin") - if executable_dir.is_dir(): - os.environ["PATH"] = os.pathsep.join([str(executable_dir), os.environ.get("PATH", "")]) - - -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.registry import all_packages - - env = env_for(*all_packages()) - selected = site_packages(selected_venv(project_root)) - env.pop("PYTHONHOME", None) - env.pop("VIRTUAL_ENV", None) - env["PYTHONPATH"] = os.pathsep.join([str(project_root.resolve()), str(selected)]) - # The child-process sentinel. Its VALUE is the installed-state file this - # environment was composed against, so a consumer gets three things for - # free: that it inherited an activated shell, which checkout/profile that - # shell came from, and a staleness stamp — uv.lock / pyproject.toml / - # pm/lock.json newer than this file means the shell's environment predates - # its inputs. pm rewrites it on every real sync and no-ops otherwise, so a - # `-nt` comparison settles back to "current" after one re-activation. - env["__HERMES_ACTIVATED"] = str(runtime_facts_path(project_root)) - return env - - -if __name__ == "__main__": - print(json.dumps(activation_environment(Path(__file__).resolve().parents[1]))) +from pm.environments import ( # noqa: F401 + activation_environment, + dependency_home_root, + install_state_dir, + runtime_facts_path, + selected_venv, + site_packages, + store_root, +) diff --git a/hermes_cli/runtime_state.py b/hermes_cli/runtime_state.py index c245bcc0fb..e8dc35aa4c 100644 --- a/hermes_cli/runtime_state.py +++ b/hermes_cli/runtime_state.py @@ -15,7 +15,7 @@ import tempfile import time import uuid -from hermes_cli.runtime_paths import dependency_home_root, install_state_dir, runtime_facts_path +from pm.environments import dependency_home_root, install_state_dir, runtime_facts_path LOG = logging.getLogger(__name__) @@ -205,7 +205,7 @@ def lease_generation(environment: Path) -> None: def collect_generations(project: Path, *, min_age_seconds: float = 86400) -> list[Path]: """Remove unselected lease-managed generations after their readers exit.""" - from hermes_cli.runtime_paths import selected_venv + from pm.environments import selected_venv removed = [] root = install_state_dir(project) if not root.exists(): diff --git a/hermes_cli/source_completion.py b/hermes_cli/source_completion.py index 324b2b72d7..e7949daf29 100644 --- a/hermes_cli/source_completion.py +++ b/hermes_cli/source_completion.py @@ -63,10 +63,9 @@ def complete_source_checkout( def _bootstrap_command(root: Path, argv: list[str]) -> list[str]: """Re-enter the checkout on PM's selected interpreter with its environment.""" - from hermes_cli.runtime_paths import selected_venv - from hermes_constants import venv_python_path + from pm.environments import project_python - return [str(venv_python_path(selected_venv(root))), "-I", "-B", "-u", + return [str(project_python(root)), "-I", "-B", "-u", str(Path(__file__).resolve()), "--source", str(root), *argv, _PREPARED] @@ -91,7 +90,7 @@ def main(argv: list[str] | None = None) -> int: if prepared: # Dependencies are selected before any application import: this is the # same ordering the update completion guarantees. - from hermes_cli.runtime_paths import activate_dependencies + from pm.environments import activate_dependencies activate_dependencies(root) if args.finish_update: @@ -111,7 +110,7 @@ def main(argv: list[str] | None = None) -> int: ) return 0 if ok else 1 - from hermes_cli.runtime_paths import activation_environment + from pm.environments import activation_environment # This interpreter is a bootstrap one: the work happens in the re-exec, so # every flag that decides WHICH tail runs has to survive into it. Losing diff --git a/hermes_cli/update_channel.py b/hermes_cli/update_channel.py index 0dbe13a059..ec60af4671 100644 --- a/hermes_cli/update_channel.py +++ b/hermes_cli/update_channel.py @@ -31,7 +31,7 @@ installers and boot paths read it before the full config machinery loads. from __future__ import annotations -from hermes_cli.runtime_paths import install_key, installs_root +from pm.environments import install_key, installs_root from hermes_cli.release_channels import validate_name from contextlib import contextmanager import logging diff --git a/hermes_cli/update_completion.py b/hermes_cli/update_completion.py index dcd4a5768a..40ed6fe4f9 100644 --- a/hermes_cli/update_completion.py +++ b/hermes_cli/update_completion.py @@ -135,8 +135,7 @@ def _read_terminal_receipt(request: dict) -> dict | None: def _prepare(request: dict, request_path: Path, result_path: Path) -> int: import pm from pm import receipt - from hermes_cli.runtime_paths import activation_environment, selected_venv - from hermes_constants import venv_python_path + from pm.environments import activation_environment, project_python root = Path(request["source"]) update_id = request["receipt"]["update_id"] @@ -146,7 +145,7 @@ def _prepare(request: dict, request_path: Path, result_path: Path) -> int: finally: request["pm_receipt"] = receipt.last_for_update(update_id) _write_json(request_path, request) - command = [str(venv_python_path(selected_venv(root))), + command = [str(project_python(root)), "-I", "-S", "-u", "-X", f"pycache_prefix={request['bytecode_cache']}", str(root / "hermes_cli/update_completion.py"), str(request_path), str(result_path), "--prepared"] @@ -262,7 +261,7 @@ def main() -> int: if "--prepared" in sys.argv[3:]: # Claim the selected generation's lease and process its .pth files only # after PM selection, before importing any application dependencies. - from hermes_cli.runtime_paths import activate_dependencies + from pm.environments import activate_dependencies activate_dependencies(root) return _finish(request, result_path) try: diff --git a/hermes_cli/update_handoff.py b/hermes_cli/update_handoff.py index b03c38c268..a7e48e0767 100644 --- a/hermes_cli/update_handoff.py +++ b/hermes_cli/update_handoff.py @@ -54,11 +54,12 @@ def post_swap_python() -> Path: if sys.platform != "win32": return Path(sys.executable) from hermes_cli._launchers import _is_windows - from hermes_constants import project_venv_dir, venv_python_path + from hermes_constants import project_venv_dir + from pm.environments import venv_python venv_dir = project_venv_dir(Path(__file__).resolve().parents[1]) if venv_dir is not None and _is_windows(): - candidate = venv_python_path(venv_dir, windows=True) + candidate = venv_python(venv_dir, windows=True) if candidate.is_file(): return candidate return Path(sys.executable) diff --git a/hermes_cli/update_serve_resume.py b/hermes_cli/update_serve_resume.py index af862bdcc7..5dd5ba3892 100644 --- a/hermes_cli/update_serve_resume.py +++ b/hermes_cli/update_serve_resume.py @@ -16,7 +16,7 @@ def main(context: Path, result: Path) -> int: handled = True return 0 sys.path.insert(0, str(root)) - from hermes_cli.runtime_paths import activate_dependencies + from pm.environments import activate_dependencies activate_dependencies(root) from hermes_cli.dashboard_procs import _filter_dashboard_respawn_candidates from hermes_cli.main_dashboard import _respawn_dashboard_processes diff --git a/hermes_cli/venv_sync.py b/hermes_cli/venv_sync.py index 2abd12349d..5f576c33fa 100644 --- a/hermes_cli/venv_sync.py +++ b/hermes_cli/venv_sync.py @@ -156,7 +156,7 @@ def prepare_launch(project_root: Path, argv: list[str]) -> Path | None: import pm from hermes_cli._launchers import resolve_store_python - from hermes_cli.runtime_paths import activation_environment, runtime_facts_path + from pm.environments import activation_environment, runtime_facts_path current = pm.venv_is_current(project_root=root) if not current: diff --git a/hermes_constants.py b/hermes_constants.py index 7ab7b372ff..f061e6985f 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -973,19 +973,10 @@ AI_GATEWAY_BASE_URL = "https://ai-gateway.vercel.sh/v1" def venv_bin_dir(venv_dir, *, windows: bool | None = None) -> Path: - """Venv executable dir (``Scripts``/``bin``); *windows* lets tests exercise Windows paths on Linux. + """Frozen updater surface: pre-PM updaters import this name; pm.environments owns it.""" + from pm.environments import venv_bin_dir as resolve - Returned unconditionally — callers differ on whether a missing venv is an error. - - Canonical helper for venv layout. This was open-coded in seven places across four ``hermes_cli`` modules - using three different Windows predicates (``platform.system()``, ``is_windows()``, ``_is_windows()``); - each new call site had to re-derive it, and #76091 shipped an eighth copy because the correct behaviour - lived 2400 lines away in another function. A few sites outside ``hermes_cli`` - (``tools/code_execution_tool.py``, ``agent/lsp/install.py``, ``agent/lsp/servers.py``) still hand-roll - it — convert them as they are touched. - """ - windows = sys.platform == "win32" if windows is None else windows - return Path(venv_dir) / ("Scripts" if windows else "bin") + return resolve(venv_dir, windows=windows) def project_venv_dir(project_root) -> Path | None: @@ -1001,9 +992,10 @@ def project_venv_dir(project_root) -> Path | None: def venv_python_path(venv_dir, *, windows: bool | None = None) -> Path: - """Path to the Python interpreter inside *venv_dir* (may not exist).""" - bin_dir = venv_bin_dir(venv_dir, windows=windows) - return bin_dir / ("python.exe" if bin_dir.name == "Scripts" else "python") + """Frozen updater surface: pre-PM updaters import this name; pm.environments owns it.""" + from pm.environments import venv_python + + return venv_python(venv_dir, windows=windows) # First-party roots: an ImportError naming one means our own tree is inconsistent. The diff --git a/pm/__init__.py b/pm/__init__.py index 2103a39dcd..fef0c7b872 100644 --- a/pm/__init__.py +++ b/pm/__init__.py @@ -9,70 +9,48 @@ file (facts.json, per install) says what is actually on this machine. ensure(name) makes the installed state match the lockfile and returns a Runner with the composed environment. env_for(*names) composes already-installed packages' env without installing anything. + +The facade resolves lazily (PEP 562): ``pm.environments`` runs at process boot, +before any dependency is importable, and must not drag the downloader/ensure +machinery in with it. """ -from pm.ensure import ( - activate, - check, - drift, - enabled_extras, - env_for, - is_installed, - installed_package, - lazy_installs_allowed, -) -from pm.client import ( - ensure, sync_venv, build_environment, lock_project, stage_manager_runtime, - ensure_environment, ensure_python_tool, venv_is_current, - check_project_lock, export_requirements, build_requirements_environment, prune_cache, stage_tools, prepare_tools, -) -from pm.operations import environment_python, python_tool -from pm.extras import available, ensure_import -from pm.lock import Facts, Lockfile -from pm.package import InstallError, Package, Runner, compose_env -from pm.registry import all_packages, get_package, register, walk -from pm.store import Store, current_target +from __future__ import annotations -__all__ = [ - "ensure", - "env_for", - "is_installed", - "installed_package", - "check", - "drift", - "activate", - "sync_venv", - "available", - "ensure_import", - "enabled_extras", - "lazy_installs_allowed", - "build_environment", - "lock_project", - "stage_manager_runtime", - "stage_tools", - "prepare_tools", - "ensure_environment", - "environment_python", - "ensure_python_tool", - "python_tool", - "venv_is_current", - "check_project_lock", - "export_requirements", - "build_requirements_environment", - "prune_cache", - "Facts", - "Lockfile", - "InstallError", - "Package", - "Runner", - "compose_env", - "all_packages", - "get_package", - "register", - "walk", - "Store", - "current_target", -] +import importlib + +_EXPORTS = { + "pm.ensure": ( + "activate", "check", "drift", "enabled_extras", "env_for", "is_installed", + "installed_package", "lazy_installs_allowed", + ), + "pm.client": ( + "ensure", "sync_venv", "build_environment", "lock_project", "stage_manager_runtime", + "ensure_environment", "ensure_python_tool", "venv_is_current", "check_project_lock", + "export_requirements", "build_requirements_environment", "prune_cache", "stage_tools", + "prepare_tools", + ), + "pm.operations": ("environment_python", "python_tool"), + "pm.extras": ("available", "ensure_import"), + "pm.lock": ("Facts", "Lockfile"), + "pm.package": ("InstallError", "Package", "Runner", "compose_env"), + "pm.registry": ("all_packages", "get_package", "register", "walk"), + "pm.store": ("Store", "current_target"), +} +_HOME = {name: module for module, names in _EXPORTS.items() for name in names} + +__all__ = list(_HOME) + + +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) + globals()[name] = value + return value + + +def __dir__(): + return sorted(set(globals()) | set(_HOME)) -import pm.packages # noqa: E402,F401 (registers the built-in definitions) -import pm.security_packages # noqa: E402,F401 diff --git a/pm/ensure.py b/pm/ensure.py index 79013ec402..1b4f26307b 100644 --- a/pm/ensure.py +++ b/pm/ensure.py @@ -496,7 +496,7 @@ def env_for(*names: str, base_env: Optional[dict] = None) -> dict[str, str]: def _runtime_state_matches(fact: dict, stamp: str, *, project_root: Path | None = None) -> bool: if not isinstance(fact, dict) or fact.get("stamp") != stamp: return False - from hermes_cli.runtime_paths import selected_venv + from pm.environments import selected_venv try: environment = selected_venv(paths.repo_root() if project_root is None else project_root) @@ -511,7 +511,7 @@ def _runtime_state_matches(fact: dict, stamp: str, *, project_root: Path | None def venv_is_current(*, extras: list[str] | None = None, plugin_dirs=None, extra_plugin_dirs=(), project_root: Path | None = None) -> bool: """Probe the requested union without changing recorded dependency state.""" - from hermes_cli.runtime_paths import runtime_facts_path + from pm.environments import runtime_facts_path from pm.packages import Venv root = paths.repo_root() if project_root is None else Path(project_root).absolute() diff --git a/pm/environment.py b/pm/environment.py index 790f7344ac..b5262ba50c 100644 --- a/pm/environment.py +++ b/pm/environment.py @@ -172,7 +172,9 @@ class PythonEnvironment: @property def executable(self) -> Path: - return self.destination / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + from pm.environments import venv_python + + return venv_python(self.destination) def _run(self, args: list[str], *, cwd: Path, timeout: int) -> subprocess.CompletedProcess: # Explicit index credentials survive, but cannot redirect the project, diff --git a/pm/environments.py b/pm/environments.py new file mode 100644 index 0000000000..6b869c215a --- /dev/null +++ b/pm/environments.py @@ -0,0 +1,241 @@ +"""Dependency-environment layout: where a project's venv generations live, which one +is selected, and the interpreter inside any venv. Shared by PM and pre-import launchers. + +Only stdlib and hermes_constants: environment selection must work before +any dependency from that environment has been imported. +""" +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path + +from hermes_constants import get_default_hermes_root, project_venv_dir + + +def install_key(project_root: Path) -> str: + canonical = str(Path(project_root).resolve()) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16] + + +def dependency_home_root() -> Path: + """Scope dependency state like a process launched in the active home.""" + from hermes_constants import get_default_hermes_root, get_hermes_home_override + + override = get_hermes_home_override() + return get_default_hermes_root(home=override) if override else get_default_hermes_root() + + +def installs_root() -> Path: + return dependency_home_root() / "installs" + + +def install_state_dir(project_root: Path) -> Path: + return installs_root() / install_key(project_root) + + +def runtime_facts_path(project_root: Path) -> Path: + return install_state_dir(project_root) / "facts.json" + + +def base_venv(project_root: Path) -> Path: + root = Path(project_root).resolve() + manifest_path = root.parent / "manifest.json" + if manifest_path.is_file(): + manifest = json.loads(manifest_path.read_text(encoding="utf-8-sig")) + if (root.parent / manifest.get("repo", "")).resolve() == root: + venv = (root.parent / manifest["venv"]).resolve() + if not venv.is_relative_to(root.parent): + raise RuntimeError("payload environment escapes its root") + return venv + return project_venv_dir(root) or root / "venv" + + +def store_root(project_root: Path) -> Path: + """Resolve a payload-relative or stamped store before PM imports.""" + override = os.environ.get("HERMES_RUNTIME_DIR") + if override: + return Path(override).resolve() + root = Path(project_root).resolve() + manifest_path = root.parent / "manifest.json" + if manifest_path.is_file(): + manifest = json.loads(manifest_path.read_text(encoding="utf-8-sig")) + if (root.parent / manifest.get("repo", "")).resolve() == root: + store = (root.parent / manifest["store"]).resolve() + if not store.is_relative_to(root.parent): + raise RuntimeError("payload store escapes its root") + return store + for directory in (root, *root.parents): + stamp = directory / "install-stamp.json" + if stamp.is_file(): + try: + data = json.loads(stamp.read_text(encoding="utf-8-sig")) + except (OSError, ValueError): + return get_default_hermes_root() / "tools" + value = data.get("runtimeDir") if isinstance(data, dict) else None + return Path(value).resolve() if value else get_default_hermes_root() / "tools" + return get_default_hermes_root() / "tools" + + +def selected_venv(project_root: Path) -> Path: + """Use the committed environment, or the original install before first sync. + + A broken committed selection is an error, not permission to load an older + dependency set silently. Reading this function never creates user state. + """ + path = runtime_facts_path(project_root) + try: + data = json.loads(path.read_text(encoding="utf-8-sig")) + except FileNotFoundError: + return base_venv(project_root) + except (OSError, ValueError) as exc: + raise RuntimeError(f"cannot read dependency environment: {path}") from exc + try: + fact = data.get("packages", {}).get("venv", {}) + value = fact.get("environment") + except AttributeError as exc: + raise RuntimeError(f"invalid dependency environment record: {path}") from exc + if value is None: + return base_venv(project_root) + if not isinstance(value, str): + raise RuntimeError("invalid dependency environment path") + environment = Path(value).resolve() + generations = install_state_dir(project_root) / "environments" + if not environment.is_relative_to(generations.resolve()) or not (environment / "pyvenv.cfg").is_file(): + raise RuntimeError(f"dependency environment is missing or outside this install: {environment}") + return environment + + +def venv_bin_dir(venv: Path, *, windows: bool | None = None) -> Path: + """``Scripts`` on Windows, ``bin`` elsewhere. Returned unconditionally — callers + differ on whether a missing venv is an error. *windows* lets a POSIX process + reason about a Windows layout (update hand-off, launcher repair).""" + if windows is None: + windows = os.name == "nt" + return Path(venv) / ("Scripts" if windows else "bin") + + +def venv_python(venv: Path, *, windows: bool | None = None) -> Path: + """The interpreter inside *venv* (may not exist).""" + bin_dir = venv_bin_dir(venv, windows=windows) + return bin_dir / ("python.exe" if bin_dir.name == "Scripts" else "python") + + +def project_python(project_root: Path) -> Path: + """The interpreter of the committed dependency environment for *project_root*.""" + return venv_python(selected_venv(project_root)) + + +def venv_python_version(venv: Path) -> tuple[int, int] | None: + """The interpreter version a POSIX venv actually holds, or ``None``. + + ``site_packages`` must not date the tree from the CALLER's ``sys.version_info``: + an update can rebuild the dependency environment with a different Python than + the launcher that later imports it. Observed on an app-driven upgrade -- PM + built the environment with CPython 3.14 while the PATH shim ran 3.11, so the + shim composed ``lib/python3.11/site-packages`` inside a 3.14 venv, found no + tree, and failed *after* a successful update. + """ + try: + for line in (venv / "pyvenv.cfg").read_text(encoding="utf-8").splitlines(): + key, _, value = line.partition("=") + if key.strip() != "version": + continue + major, _, rest = value.strip().partition(".") + minor, _, _ = rest.partition(".") + if major.isdigit() and minor.isdigit(): + return int(major), int(minor) + except OSError: + pass + try: + candidates = sorted((venv / "lib").glob("python3*")) + except OSError: + return None + for candidate in candidates: + major, _, rest = candidate.name.removeprefix("python").partition(".") + minor, _, _ = rest.partition(".") + if major.isdigit() and minor.isdigit(): + return int(major), int(minor) + return None + + +def site_packages(venv: Path) -> Path: + import sys + + if os.name == "nt": + return venv / "Lib/site-packages" + version = venv_python_version(venv) or (sys.version_info.major, sys.version_info.minor) + return venv / f"lib/python{version[0]}.{version[1]}/site-packages" + + +def activate_dependencies(project_root: Path) -> None: + """Select the committed tree at process boot, before third-party imports. + + A process with no extension selection keeps its original launch contract. + Already-running processes are never switched after a dependency install. + """ + import sys + + state = install_state_dir(project_root) + if state.is_dir(): + from hermes_cli.runtime_state import runtime_lock, recover_publication, lease_generation + # The lock's holder may be another profile's backend running a full dependency rebuild; + # this process only reads the committed selection, so it proceeds without waiting rather + # than leaving the backend unbound (see runtime_lock). + with runtime_lock(project_root) as held: + if held: + recover_publication(project_root) + environment = selected_venv(project_root) + lease_generation(environment) + selected = site_packages(environment) + if not selected.is_dir() and not runtime_facts_path(project_root).is_file(): + return + else: + # Older installs and sealed payloads still select once, before imports. + # Never consult VIRTUAL_ENV: it can describe the invoking shell's Python. + environment = base_venv(project_root) + selected = site_packages(environment) + if not selected.is_dir(): + return # External/Nix interpreter owns its original sys.path. + if not selected.is_dir(): + raise RuntimeError(f"dependency environment has no site-packages: {selected}") + import site + + sys.path[:] = [entry for entry in sys.path + if Path(entry).name not in ("site-packages", "dist-packages") + and Path(entry).resolve() != project_root.resolve()] + # uv editable members are activated by .pth files, not by sys.path alone. + site.addsitedir(str(selected)) + sys.path[:] = [str(project_root.resolve()), str(selected), + *[entry for entry in sys.path if Path(entry).resolve() != selected.resolve()]] + os.environ["PYTHONPATH"] = os.pathsep.join([str(project_root.resolve()), str(selected)]) + os.environ.pop("VIRTUAL_ENV", None) + executable_dir = venv_bin_dir(environment) + if executable_dir.is_dir(): + os.environ["PATH"] = os.pathsep.join([str(executable_dir), os.environ.get("PATH", "")]) + + +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.registry import all_packages + + env = env_for(*all_packages()) + selected = site_packages(selected_venv(project_root)) + env.pop("PYTHONHOME", None) + env.pop("VIRTUAL_ENV", None) + env["PYTHONPATH"] = os.pathsep.join([str(project_root.resolve()), str(selected)]) + # The child-process sentinel. Its VALUE is the installed-state file this + # environment was composed against, so a consumer gets three things for + # free: that it inherited an activated shell, which checkout/profile that + # shell came from, and a staleness stamp — uv.lock / pyproject.toml / + # pm/lock.json newer than this file means the shell's environment predates + # its inputs. pm rewrites it on every real sync and no-ops otherwise, so a + # `-nt` comparison settles back to "current" after one re-activation. + env["__HERMES_ACTIVATED"] = str(runtime_facts_path(project_root)) + return env + + +if __name__ == "__main__": + print(json.dumps(activation_environment(Path(__file__).resolve().parents[1]))) diff --git a/pm/extras.py b/pm/extras.py index 554ec0ae4c..9f1709a45c 100644 --- a/pm/extras.py +++ b/pm/extras.py @@ -178,7 +178,7 @@ def ensure_import(extra: str) -> None: sync_venv([extra]) # Activation is a process-boot operation. Never mix a newly resolved # dependency tree with libraries already imported by this process. - from hermes_cli.runtime_paths import selected_venv, site_packages + from pm.environments import selected_venv, site_packages from pm.paths import repo_root, runtime_facts_path import sys from pathlib import Path diff --git a/pm/operations.py b/pm/operations.py index 2568af3eb2..63ebab9ab9 100644 --- a/pm/operations.py +++ b/pm/operations.py @@ -120,7 +120,9 @@ def _environment_root(name: str, root: Path | None) -> Path: def _python(environment: Path) -> Path: - return environment / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + from pm.environments import venv_python + + return venv_python(environment) def _selection(root: Path) -> dict: diff --git a/pm/packages.py b/pm/packages.py index 137605600f..7013099eec 100644 --- a/pm/packages.py +++ b/pm/packages.py @@ -358,7 +358,7 @@ class Venv(StatePackage): return repo_root() if self._project_root is None else self._project_root def venv_dir(self) -> Path: - from hermes_cli.runtime_paths import selected_venv + from pm.environments import selected_venv return selected_venv(self.project_root()) @@ -387,7 +387,7 @@ class Venv(StatePackage): def apply(self, extras: list[str], *, plugin_dirs=None, repair: bool = False, explicit: bool = False) -> dict: """Prepare one complete environment; the caller commits its selection.""" import uuid - from hermes_cli.runtime_paths import install_state_dir, runtime_facts_path + from pm.environments import install_state_dir, runtime_facts_path from pm.environment import managed_environment from pm.lock import Facts from pm.workspace import enabled_member_dirs, lock_and_sync diff --git a/pm/paths.py b/pm/paths.py index 72b4ad8967..bf1b05587f 100644 --- a/pm/paths.py +++ b/pm/paths.py @@ -14,7 +14,7 @@ def lockfile_path() -> Path: def store_root() -> Path: - from hermes_cli.runtime_paths import store_root as resolve + from pm.environments import store_root as resolve return resolve(repo_root()) @@ -47,6 +47,6 @@ def writable_store_root() -> Path: def runtime_facts_path() -> Path: - from hermes_cli.runtime_paths import runtime_facts_path as resolve + from pm.environments import runtime_facts_path as resolve return resolve(repo_root()) diff --git a/pm/plugins_state.py b/pm/plugins_state.py index 14d50af510..5565acee23 100644 --- a/pm/plugins_state.py +++ b/pm/plugins_state.py @@ -11,7 +11,7 @@ from typing import Any, Optional def _profiles_root() -> Path: # Plugin discovery and dependency publication must use the same home root. - from hermes_cli.runtime_paths import dependency_home_root + from pm.environments import dependency_home_root return dependency_home_root() / "profiles" @@ -88,7 +88,7 @@ def _is_directory(path: Path) -> bool: def _all_homes() -> list[Path]: """Enumerate the complete union or refuse; a partial scan cannot remove members.""" - from hermes_cli.runtime_paths import dependency_home_root + from pm.environments import dependency_home_root homes = [dependency_home_root()] root = _profiles_root() diff --git a/pm/publication.py b/pm/publication.py index 7fc46a19ef..0e75917af6 100644 --- a/pm/publication.py +++ b/pm/publication.py @@ -11,7 +11,7 @@ import io import json from pathlib import Path -from hermes_cli.runtime_paths import dependency_home_root, install_state_dir, runtime_facts_path +from pm.environments import dependency_home_root, install_state_dir, runtime_facts_path from hermes_cli.runtime_state import _atomic_bytes, _bytes, _digest from pm.workspace import enabled_plugin_dirs, _is_member_candidate diff --git a/pm/registry.py b/pm/registry.py index 18d2311be8..17378f8120 100644 --- a/pm/registry.py +++ b/pm/registry.py @@ -14,6 +14,20 @@ from pm.package import InstallError, Package _packages: dict[str, Package] = {} +def _builtins_loaded() -> dict[str, Package]: + """Register the built-in definitions on first read, not on ``import pm``. + + Process boot imports ``pm.environments`` before any dependency is importable; + pulling the whole package catalogue (downloader, network) in with it would + cost every launch ~40ms and break the stripped payloads that ship only the + pre-import files. + """ + if "pm.packages" not in sys.modules: + import pm.packages # noqa: F401 (registers the built-in definitions) + import pm.security_packages # noqa: F401 + return _packages + + def register(cls): instance = cls() if not instance.name: @@ -23,13 +37,14 @@ def register(cls): def get_package(name: str) -> Package: - if name not in _packages: + packages = _builtins_loaded() + if name not in packages: raise KeyError(f"unknown package: {name}") - return _packages[name] + return packages[name] def all_packages() -> list[str]: - return sorted(_packages) + return sorted(_builtins_loaded()) def source_install_packages(names: list[str]) -> list[str]: @@ -45,7 +60,7 @@ def package_definitions(names: list[str] | None = None) -> list[dict[str, Any]]: source bodies, or the application's import path. ``names`` selects a closure. """ definitions = [] - for package in walk(names) if names is not None else list(_packages.values()): + for package in walk(names) if names is not None else list(_builtins_loaded().values()): cls = type(package) if cls.__module__ == "pm.packages": continue @@ -122,7 +137,9 @@ def load_package_definitions(definitions: list[dict[str, Any]]) -> None: definition["name"], f"cannot load package definition {module_name}.{definition['qualname']}: {exc}", "keep the package definition and its dependencies importable in the isolated PM runtime", ) from exc - # Import-time decorators must not override the caller's final selection. + # Import-time decorators must not override the caller's final selection: + # load the built-ins first so a restored definition wins. + _builtins_loaded() _packages.update(restored) diff --git a/pm/runtime.py b/pm/runtime.py index 89a0f6f140..1788aaa5c3 100644 --- a/pm/runtime.py +++ b/pm/runtime.py @@ -31,7 +31,9 @@ def runtime_environment() -> dict[str, str]: def _python(environment: Path) -> Path: - return environment / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + from pm.environments import venv_python + + return venv_python(environment) def _inputs(project: Path, python: Path) -> str: @@ -147,7 +149,7 @@ def runtime_python(*, bootstrap: bool = True, cache: Path | None = None) -> Path """Resolve PM without selecting, repairing, or importing the app environment.""" if is_runtime(): return Path(sys.executable) - from hermes_cli.runtime_paths import install_state_dir + from pm.environments import install_state_dir from pm._uv import _toolchain from pm.paths import repo_root diff --git a/pm/store.py b/pm/store.py index 046a862cc5..5d3c523feb 100644 --- a/pm/store.py +++ b/pm/store.py @@ -105,13 +105,9 @@ def current_target() -> str: def sha256_file(path: Path) -> str: - import hashlib + from pm.downloader import _sha256_file - digest = hashlib.sha256() - with open(path, "rb") as f: - for block in iter(lambda: f.read(1024 * 1024), b""): - digest.update(block) - return digest.hexdigest() + return _sha256_file(path) def hash_url(url: str) -> str: diff --git a/scripts/_activation.py b/scripts/_activation.py index 4795880d08..d732edcbc9 100644 --- a/scripts/_activation.py +++ b/scripts/_activation.py @@ -35,7 +35,7 @@ free of both the exec bit and a ``PATH`` lookup. Activation is not re-run when the inherited environment is still current: the sentinel's value is the installed-state file the environment was composed -against (see ``hermes_cli.runtime_paths.activation_environment``), so the +against (see ``pm.environments.activation_environment``), so the prologue compares it against the inputs that decide the dependency set. Any of ``uv.lock``, ``pyproject.toml`` or ``pm/lock.json`` being newer than that file means the inherited environment predates its inputs, and the prologue diff --git a/scripts/ci/setup_toolchain.py b/scripts/ci/setup_toolchain.py index 4ee73e5b6d..17d184c1fc 100644 --- a/scripts/ci/setup_toolchain.py +++ b/scripts/ci/setup_toolchain.py @@ -182,7 +182,7 @@ def dependencies(args) -> None: return import tomllib - from hermes_cli.runtime_paths import selected_venv + from pm.environments import selected_venv from pm import build_environment, check_project_lock, sync_venv from pm.paths import repo_root diff --git a/tests/cron/test_cron_script.py b/tests/cron/test_cron_script.py index 358e6f5150..ad54a24d93 100644 --- a/tests/cron/test_cron_script.py +++ b/tests/cron/test_cron_script.py @@ -182,7 +182,7 @@ class TestRunJobScript: monkeypatch.setenv("HERMES_RUNTIME_DIR", str(store)) # The supplied payload launcher is older than the committed extension # generation. It must not override the installation's current selection. - from hermes_cli.runtime_paths import install_state_dir, site_packages as dependency_site + from pm.environments import install_state_dir, site_packages as dependency_site repo = Path(sched_script.__file__).resolve().parents[1] state = install_state_dir(repo) selected = state / "environments" / "selected" / "venv" diff --git a/tests/hermes_cli/plugin_worker_support.py b/tests/hermes_cli/plugin_worker_support.py index 62dd07996b..b0a9c6fcdc 100644 --- a/tests/hermes_cli/plugin_worker_support.py +++ b/tests/hermes_cli/plugin_worker_support.py @@ -92,7 +92,7 @@ class PluginWorld: plugins_command(parser.parse_args(argv)) def selected(self) -> Path: - from hermes_cli.runtime_paths import selected_venv + from pm.environments import selected_venv return selected_venv(self.core) def enabled(self) -> list[str]: diff --git a/tests/hermes_cli/test_data_uninstall.py b/tests/hermes_cli/test_data_uninstall.py index bc4920cf00..ec1425abe1 100644 --- a/tests/hermes_cli/test_data_uninstall.py +++ b/tests/hermes_cli/test_data_uninstall.py @@ -41,7 +41,7 @@ def layout(tmp_path, monkeypatch): @pytest.mark.parametrize("mode", ["confirmed", "cancel", "dry-run"]) def test_data_only_preserves_runtime_and_sibling_homes(layout, monkeypatch, mode): import json - from hermes_cli.runtime_paths import install_state_dir, runtime_facts_path, selected_venv + from pm.environments import install_state_dir, runtime_facts_path, selected_venv monkeypatch.delattr(Path, "is_junction", raising=False) home, witnesses, data = layout diff --git a/tests/hermes_cli/test_doctor_command_install.py b/tests/hermes_cli/test_doctor_command_install.py index 9d69faf6eb..7cfb0f9489 100644 --- a/tests/hermes_cli/test_doctor_command_install.py +++ b/tests/hermes_cli/test_doctor_command_install.py @@ -10,7 +10,7 @@ import sys import pytest from hermes_cli import _launchers, doctor, doctor_platform -from hermes_cli.runtime_paths import install_state_dir, site_packages +from pm.environments import install_state_dir, site_packages def _tree(tmp_path, monkeypatch): @@ -46,7 +46,7 @@ def _pm_source(project, home): root = Path(__file__).resolve().parents[2] for relative in ( "hermes", "hermes_bootstrap.py", "hermes_constants.py", "hermes_cli/__init__.py", - "hermes_cli/runtime_paths.py", "hermes_cli/runtime_state.py", + "pm/environments.py", "hermes_cli/runtime_state.py", "hermes_cli/_early_recovery.py", "hermes_cli/_parser.py", "hermes_cli/venv_sync.py", "hermes_cli/steward.py", "hermes_cli/stderr_timestamp.py", ): diff --git a/tests/hermes_cli/test_early_recovery.py b/tests/hermes_cli/test_early_recovery.py index a5ca8602e6..af3afc18b8 100644 --- a/tests/hermes_cli/test_early_recovery.py +++ b/tests/hermes_cli/test_early_recovery.py @@ -90,7 +90,7 @@ def test_recovery_obeys_live_owner_and_single_flight(tmp_path, monkeypatch): def test_missing_environment_cannot_write_a_retry_marker_without_lock(tmp_path, monkeypatch): - from hermes_cli.runtime_paths import install_state_dir, runtime_facts_path + from pm.environments import install_state_dir, runtime_facts_path from pm.lock import Facts root = _project(tmp_path) diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py index 194561dfaf..1522ee4382 100644 --- a/tests/hermes_cli/test_gateway_service.py +++ b/tests/hermes_cli/test_gateway_service.py @@ -1286,7 +1286,7 @@ def _seed_pm_environment(tmp_path, monkeypatch, with_venv_fact=True): facts.json whose venv fact names a committed environment generation under the install state (pyvenv.cfg included). Returns ``(project_root, environment_dir)``.""" - from hermes_cli.runtime_paths import install_state_dir + from pm.environments import install_state_dir monkeypatch.setenv("HERMES_HOME", str(tmp_path)) # installs_root() under the test root project_root = tmp_path / "payload" / "hermes-agent" diff --git a/tests/hermes_cli/test_install_lock_timeout.py b/tests/hermes_cli/test_install_lock_timeout.py index 7b4ae1626a..22ea326add 100644 --- a/tests/hermes_cli/test_install_lock_timeout.py +++ b/tests/hermes_cli/test_install_lock_timeout.py @@ -14,7 +14,7 @@ import sys import pytest -from hermes_cli.runtime_paths import install_state_dir, runtime_facts_path, site_packages +from pm.environments import install_state_dir, runtime_facts_path, site_packages _HOLDER = """ import sys @@ -69,7 +69,7 @@ def test_runtime_lock_reports_a_lost_race(locked_install): def test_boot_activation_proceeds_while_the_install_is_locked(locked_install, monkeypatch): """The issue's symptom, inverted: the backend reaches its dependency environment and can bind while a sibling holds the lock. Recovery belongs to whoever holds it, so it is skipped.""" - import hermes_cli.runtime_paths as runtime_paths + import pm.environments as runtime_paths import hermes_cli.runtime_state as runtime_state repo, site = locked_install diff --git a/tests/hermes_cli/test_launcher_runtime_selection.py b/tests/hermes_cli/test_launcher_runtime_selection.py index 51a09c8ae5..a05a4a8b1e 100644 --- a/tests/hermes_cli/test_launcher_runtime_selection.py +++ b/tests/hermes_cli/test_launcher_runtime_selection.py @@ -8,24 +8,27 @@ import sys import pytest from hermes_cli import _launchers -from hermes_cli.runtime_paths import install_state_dir, site_packages +from pm.environments import install_state_dir, site_packages @pytest.mark.platforms("windows") def test_minted_launcher_reads_current_selection_and_editable_members(tmp_path, monkeypatch): - from hermes_cli import runtime_paths, runtime_state + from pm import environments as runtime_paths, runtime_state import hermes_constants root = tmp_path / "repo" package = root / "hermes_cli" package.mkdir(parents=True) (package / "__init__.py").write_text("") + pm_package = root / "pm" + pm_package.mkdir() + (pm_package / "__init__.py").write_text("") # Real selection code, with a fixture entry point rather than a live CLI. - (package / "runtime_paths.py").write_bytes(Path(runtime_paths.__file__).read_bytes()) + (pm_package / "environments.py").write_bytes(Path(runtime_paths.__file__).read_bytes()) (package / "runtime_state.py").write_bytes(Path(runtime_state.__file__).read_bytes()) (root / "hermes_constants.py").write_bytes(Path(hermes_constants.__file__).read_bytes()) (root / "hermes_bootstrap.py").write_text( - "from pathlib import Path\nfrom hermes_cli.runtime_paths import activate_dependencies\n" + "from pathlib import Path\nfrom pm.environments import activate_dependencies\n" "activate_dependencies(Path(__file__).resolve().parent)\n" ) (package / "main.py").write_text( diff --git a/tests/hermes_cli/test_macos_tcc_anchor.py b/tests/hermes_cli/test_macos_tcc_anchor.py index 0b28f4b823..3a6c99980d 100644 --- a/tests/hermes_cli/test_macos_tcc_anchor.py +++ b/tests/hermes_cli/test_macos_tcc_anchor.py @@ -26,7 +26,7 @@ import pytest import hermes_cli.doctor as doctor import hermes_cli.macos_tcc_anchor as tcc -from hermes_constants import venv_python_path +from pm.environments import venv_python from hermes_cli import doctor_platform @@ -128,7 +128,7 @@ class TestEnsureTccAnchorNonMacos: # The host check gates before any filesystem access, so a plain # regular-file interpreter fixture is enough: ensure must return None # and leave the file byte-identical. Assert the fixture's actual path - # (venv_python_path is host-shaped: Scripts/python.exe on Windows). + # (venv_python is host-shaped: Scripts/python.exe on Windows). venv_py = tmp_path / "checkout" / ".venv" / "bin" / "python" venv_py.parent.mkdir(parents=True) venv_py.write_bytes(b"#!fake interpreter") @@ -170,7 +170,7 @@ class TestEnsureTccAnchor: store_py.write_bytes(b"#!fake generation interpreter") store_py.chmod(0o755) root = _build_checkout(tmp_path, store_bin=store_bin) - venv_py = venv_python_path(root / ".venv") + venv_py = venv_python(root / ".venv") assert venv_py.is_symlink() anchored = tcc.ensure_tcc_anchor(root) @@ -182,7 +182,7 @@ class TestEnsureTccAnchor: def test_anchors_uv_managed_interpreter(self, tmp_path): store_bin = _build_store(tmp_path) root = _build_checkout(tmp_path, store_bin=store_bin) - venv_py = venv_python_path(root / ".venv") + venv_py = venv_python(root / ".venv") assert venv_py.is_symlink() anchored = tcc.ensure_tcc_anchor(root) @@ -202,7 +202,7 @@ class TestEnsureTccAnchor: def test_idempotent(self, tmp_path): store_bin = _build_store(tmp_path) root = _build_checkout(tmp_path, store_bin=store_bin, anchored=True) - venv_py = venv_python_path(root / ".venv") + venv_py = venv_python(root / ".venv") marker = venv_py.parent / ".tcc-anchor-source" before = marker.read_text(encoding="utf-8") @@ -229,7 +229,7 @@ class TestEnsureTccAnchor: def test_reanchors_after_patch_bump(self, tmp_path): old_bin = _build_store(tmp_path, version="3.11.15") root = _build_checkout(tmp_path, store_bin=old_bin, anchored=True) - venv_py = venv_python_path(root / ".venv") + venv_py = venv_python(root / ".venv") new_bin = _build_store(tmp_path, version="3.11.16") new_py = new_bin / "python3.11" @@ -250,7 +250,7 @@ class TestEnsureTccAnchor: def test_skips_homebrew_interpreter(self, tmp_path): root = _build_checkout(tmp_path, homebrew=True) - venv_py = venv_python_path(root / ".venv") + venv_py = venv_python(root / ".venv") assert tcc.ensure_tcc_anchor(root) is None assert venv_py.is_symlink() @@ -282,7 +282,7 @@ class TestEnsureTccAnchor: def test_boot_gate_refusal_leaves_venv_untouched(self, tmp_path, monkeypatch): store_bin = _build_store(tmp_path) root = _build_checkout(tmp_path, store_bin=store_bin) - venv_py = venv_python_path(root / ".venv") + venv_py = venv_python(root / ".venv") monkeypatch.setattr(tcc, "_passes_boot_gate", lambda *a, **k: False) assert tcc.ensure_tcc_anchor(root) is None @@ -296,7 +296,7 @@ class TestEnsureTccAnchor: # The next ensure retries the whole install. store_bin = _build_store(tmp_path) root = _build_checkout(tmp_path, store_bin=store_bin) - venv_py = venv_python_path(root / ".venv") + venv_py = venv_python(root / ".venv") monkeypatch.setattr(tcc, "_copy_alias", lambda *a, **k: False) import logging @@ -477,7 +477,7 @@ class TestTccAnchorState: status, detail = tcc.tcc_anchor_state(root) assert status == "missing" - assert str(venv_python_path(root / ".venv")) in detail + assert str(venv_python(root / ".venv")) in detail tcc.ensure_tcc_anchor(root) @@ -498,7 +498,7 @@ class TestTccAnchorState: status, _ = tcc.tcc_anchor_state(root) assert status == "stale" anchored = tcc.ensure_tcc_anchor(root) - assert anchored == venv_python_path(root / ".venv") + assert anchored == venv_python(root / ".venv") assert (root / ".venv" / "bin" / "python").read_bytes() == ( new_bin / "python3.11" ).read_bytes() diff --git a/tests/hermes_cli/test_memory_dependency_admission.py b/tests/hermes_cli/test_memory_dependency_admission.py index 3dbf33419e..1f071ac1fb 100644 --- a/tests/hermes_cli/test_memory_dependency_admission.py +++ b/tests/hermes_cli/test_memory_dependency_admission.py @@ -12,7 +12,7 @@ import pytest import hermes_yaml as yaml from hermes_cli import memory_setup -from hermes_cli.runtime_paths import selected_venv +from pm.environments import selected_venv from pm import paths from tests.pm._fixtures import _wheel diff --git a/tests/hermes_cli/test_old_updater_takeover.py b/tests/hermes_cli/test_old_updater_takeover.py index 2a7cf34580..ea22976bd3 100644 --- a/tests/hermes_cli/test_old_updater_takeover.py +++ b/tests/hermes_cli/test_old_updater_takeover.py @@ -317,7 +317,10 @@ def test_completed_serve_token_is_acknowledged_without_preparation(tmp_path, enc # request without importing PM or launching any completed backend twice. (package / "_launchers.py").write_text( "import sys\nresolve_store_python = lambda root: sys.executable\n", encoding="utf-8") - (package / "runtime_paths.py").write_text( + pm_package = root / "pm" + pm_package.mkdir() + (pm_package / "__init__.py").write_text("", encoding="utf-8") + (pm_package / "environments.py").write_text( "import os\nactivation_environment = lambda root: dict(os.environ)\n", encoding="utf-8") context, result_path = tmp_path / "request.json", tmp_path / "result.json" context.write_text(json.dumps({"root": str(root), "stopped_serves": {"pending": False}}, diff --git a/tests/hermes_cli/test_plugin_update_recovery.py b/tests/hermes_cli/test_plugin_update_recovery.py index c5e447f74f..45c1cbf756 100644 --- a/tests/hermes_cli/test_plugin_update_recovery.py +++ b/tests/hermes_cli/test_plugin_update_recovery.py @@ -30,7 +30,7 @@ from pathlib import Path import os,sys from pm.publication import StagedPlugin from pm.store import tree_digest -from hermes_cli.runtime_paths import runtime_facts_path +from pm.environments import runtime_facts_path from pm.lock import Facts project,staged,target = map(Path,sys.argv[1:4]) StagedPlugin({"staged": str(staged), "target": str(target), "target_digest": tree_digest(target), diff --git a/tests/hermes_cli/test_plugin_update_transaction.py b/tests/hermes_cli/test_plugin_update_transaction.py index b29a646d2f..dc71156422 100644 --- a/tests/hermes_cli/test_plugin_update_transaction.py +++ b/tests/hermes_cli/test_plugin_update_transaction.py @@ -76,7 +76,7 @@ def installed(admission_env, monkeypatch, request): @pytest.mark.parametrize("failure", ["dependencies", "version", "publication", "manifest"]) def test_failed_update_keeps_code_metadata_config_and_environment(installed, monkeypatch, failure): from hermes_cli import plugins_cmd - from hermes_cli.runtime_paths import selected_venv + from pm.environments import selected_venv from pm import paths from pm.lock import Facts @@ -108,7 +108,7 @@ def test_failed_update_keeps_code_metadata_config_and_environment(installed, mon def test_successful_update_publishes_matching_code_and_durable_workspace(installed): import tomllib from hermes_cli import plugins_cmd - from hermes_cli.runtime_paths import selected_venv + from pm.environments import selected_venv from pm import paths from pm.lock import Facts from pm.packages import Venv diff --git a/tests/hermes_cli/test_runtime_paths.py b/tests/hermes_cli/test_runtime_paths.py index ef7fb038d4..f9da1277ff 100644 --- a/tests/hermes_cli/test_runtime_paths.py +++ b/tests/hermes_cli/test_runtime_paths.py @@ -5,7 +5,7 @@ import os import pytest -from hermes_cli.runtime_paths import site_packages, store_root, venv_python_version +from pm.environments import site_packages, store_root, venv_python_version from hermes_constants import get_default_hermes_root diff --git a/tests/hermes_cli/test_source_launcher_publication.py b/tests/hermes_cli/test_source_launcher_publication.py index 5856652567..dca4e6f6d8 100644 --- a/tests/hermes_cli/test_source_launcher_publication.py +++ b/tests/hermes_cli/test_source_launcher_publication.py @@ -10,12 +10,12 @@ import sys import pytest from hermes_cli import _launchers -from hermes_cli.runtime_paths import install_state_dir, site_packages +from pm.environments import install_state_dir, site_packages ROOT = Path(__file__).resolve().parents[2] BOOT_FILES = ( "hermes_bootstrap.py", "hermes_constants.py", "hermes_cli/__init__.py", "hermes_cli/_launchers.py", - "hermes_cli/runtime_paths.py", "hermes_cli/runtime_state.py", + "pm/environments.py", "hermes_cli/runtime_state.py", "hermes_cli/_early_recovery.py", "hermes_cli/_parser.py", "hermes_cli/venv_sync.py", "hermes_cli/steward.py", "hermes_cli/stderr_timestamp.py", diff --git a/tests/hermes_cli/test_update_completion_process.py b/tests/hermes_cli/test_update_completion_process.py index 0f1b8eacb8..d726d7b048 100644 --- a/tests/hermes_cli/test_update_completion_process.py +++ b/tests/hermes_cli/test_update_completion_process.py @@ -20,8 +20,9 @@ def transition(tmp_path): package = root / "hermes_cli" package.mkdir() (package / "__init__.py").write_text("") - (root / "pm").mkdir() - (root / "pm/__init__.py").write_text("OLD_API = True\n") + pm_package = root / "pm" + pm_package.mkdir() + (pm_package / "__init__.py").write_text("OLD_API = True\n") def git(*args): return subprocess.run(["git", *args], cwd=root, text=True, capture_output=True, check=True).stdout.strip() @@ -52,21 +53,20 @@ def transition(tmp_path): " with pathlib.Path('events.jsonl').open('a') as f:\n" " f.write(json.dumps(dict(name=name, pid=os.getpid(), python=sys.executable, **values)) + '\\n')\n" ) - (package / "runtime_paths.py").write_text( + selected = tmp_path / "selected-python" + venv.EnvBuilder(with_pip=False).create(selected) + selected_python = selected / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + (pm_package / "environments.py").write_text( "import os, sys\n" "from pathlib import Path\n" "selected_venv = lambda root: Path(sys.executable).parent.parent\n" + f"project_python = lambda root: Path({str(selected_python)!r})\n" "activation_environment = lambda root: {**os.environ, 'PYTHONPATH': str(root)}\n" "def activate_dependencies(root):\n" " from hermes_cli.probe import event\n" " event('activate')\n" ) - selected = tmp_path / "selected-python" - venv.EnvBuilder(with_pip=False).create(selected) - selected_python = selected / ("Scripts/python.exe" if os.name == "nt" else "bin/python") - (root / "hermes_constants.py").write_text( - f"venv_python_path = lambda root: {str(selected_python)!r}\n" - ) + (root / "hermes_constants.py").write_text("") (package / "venv_sync.py").write_text( "from hermes_cli.probe import event\n" "publish_launchers = lambda root: event('launchers')\n" diff --git a/tests/hermes_cli/test_update_finish.py b/tests/hermes_cli/test_update_finish.py index 8d6259df58..21733711c0 100644 --- a/tests/hermes_cli/test_update_finish.py +++ b/tests/hermes_cli/test_update_finish.py @@ -31,7 +31,7 @@ def _put(root, name, content): @pytest.fixture def completion(tmp_path, monkeypatch): from hermes_cli.config_defaults import DEFAULT_CONFIG - from hermes_cli.runtime_paths import install_state_dir, runtime_facts_path, site_packages + from pm.environments import install_state_dir, runtime_facts_path, site_packages home = tmp_path / "home" home.mkdir() @@ -215,7 +215,7 @@ def test_failure_preserves_original_receipt_before_build(completion, fault): context.write_text(json.dumps(request), encoding="utf-8") message = "application import failed before build" if fault == "activation": - from hermes_cli.runtime_paths import selected_venv, site_packages + from pm.environments import selected_venv, site_packages shutil.rmtree(site_packages(selected_venv(source))) message = "dependency environment has no site-packages" diff --git a/tests/hermes_cli/test_update_launch_completion.py b/tests/hermes_cli/test_update_launch_completion.py index 90a1ab3e6d..d28c77fbe1 100644 --- a/tests/hermes_cli/test_update_launch_completion.py +++ b/tests/hermes_cli/test_update_launch_completion.py @@ -10,7 +10,7 @@ import sys import pytest from hermes_cli import venv_sync -from hermes_cli.runtime_paths import runtime_facts_path +from pm.environments import runtime_facts_path def test_first_launch_syncs_without_marker_then_uses_completion_fact(tmp_path, monkeypatch): diff --git a/tests/hermes_cli/test_update_missing_configured_deps.py b/tests/hermes_cli/test_update_missing_configured_deps.py index 9f3de8f088..3ee0aaa8e6 100644 --- a/tests/hermes_cli/test_update_missing_configured_deps.py +++ b/tests/hermes_cli/test_update_missing_configured_deps.py @@ -15,8 +15,8 @@ import subprocess import pytest import pm -from hermes_cli.runtime_paths import activation_environment, runtime_facts_path, selected_venv, site_packages -from hermes_constants import venv_python_path +from pm.environments import activation_environment, runtime_facts_path, selected_venv, site_packages +from pm.environments import venv_python from tests.pm._fixtures import isolated_python # noqa: F401 from tests.pm.test_source_update_launch import source_launch # noqa: F401 @@ -52,7 +52,7 @@ def configured_update(source_launch, tmp_path, monkeypatch): f"source_build.build_update_products(Path({str(root)!r}), desktop=False)\n" "print('TARGET=' + json.dumps({'prefix': sys.prefix, 'python': sys.executable}))\n" ) - subprocess.run([str(venv_python_path(selected)), "-c", script], + subprocess.run([str(venv_python(selected)), "-c", script], cwd=root, env=activation_environment(root), check=True) home = tmp_path / "home" config = home / "config.yaml" diff --git a/tests/hermes_cli/test_update_zip_completion.py b/tests/hermes_cli/test_update_zip_completion.py index 72e025b432..ec4d6ea590 100644 --- a/tests/hermes_cli/test_update_zip_completion.py +++ b/tests/hermes_cli/test_update_zip_completion.py @@ -308,9 +308,9 @@ def test_zip_refuses_non_main_before_transport(zip_update, monkeypatch, capsys): @pytest.mark.parametrize("windows,folder,executable", [(True, "Scripts", "python.exe"), (False, "bin", "python")]) def test_venv_layout_explicit_and_native(tmp_path, windows, folder, executable): import os - from hermes_constants import venv_bin_dir, venv_python_path + from pm.environments import venv_bin_dir, venv_python assert venv_bin_dir(tmp_path, windows=windows) == tmp_path / folder - assert venv_python_path(str(tmp_path), windows=windows) == tmp_path / folder / executable + assert venv_python(str(tmp_path), windows=windows) == tmp_path / folder / executable if windows == (os.name == "nt"): - assert venv_python_path(tmp_path) == tmp_path / folder / executable + assert venv_python(tmp_path) == tmp_path / folder / executable diff --git a/tests/hermes_cli/test_venv_sync_currency.py b/tests/hermes_cli/test_venv_sync_currency.py index 198a71c4c2..5e8427a9f3 100644 --- a/tests/hermes_cli/test_venv_sync_currency.py +++ b/tests/hermes_cli/test_venv_sync_currency.py @@ -8,7 +8,7 @@ import subprocess import hermes_yaml as yaml from hermes_cli import venv_sync -from hermes_cli.runtime_paths import install_state_dir, selected_venv +from pm.environments import install_state_dir, selected_venv from pm import paths from pm.lock import Lockfile from tests.pm.test_plugin_survival_contract import admission_env # noqa: F401 diff --git a/tests/hermes_cli/test_web_memory_provider_setup_install.py b/tests/hermes_cli/test_web_memory_provider_setup_install.py index 80ba9af815..3af409b88f 100644 --- a/tests/hermes_cli/test_web_memory_provider_setup_install.py +++ b/tests/hermes_cli/test_web_memory_provider_setup_install.py @@ -15,8 +15,8 @@ from hermes_cli.web_routers import memory_providers as mp @pytest.mark.parametrize("declaration", ["pyproject", "python_dependencies", "pip_dependencies"]) def test_setup_admits_real_provider_union_and_keeps_selection_on_failure(tmp_path, monkeypatch, surface, declaration): import pm - from hermes_constants import venv_python_path - from hermes_cli.runtime_paths import selected_venv + from pm.environments import venv_python + from pm.environments import selected_venv from tests.pm._fixtures import _wheel from hermes_cli import memory_setup from hermes_cli.web_server_memory import _memory_provider_setup_info @@ -89,7 +89,7 @@ def test_setup_admits_real_provider_union_and_keeps_selection_on_failure(tmp_pat success = prepare() if surface == "dashboard": assert success[0]["status"] == "restart_required", success - python = venv_python_path(selected_venv(core)) + python = venv_python(selected_venv(core)) result = subprocess.run([str(python), "-I", "-c", "import existing_dep, provider_dep; print('both')"], check=True, capture_output=True, text=True, timeout=30) assert result.stdout.strip() == "both" diff --git a/tests/install/e2e-assets/source_driver.py b/tests/install/e2e-assets/source_driver.py index cd234ee6b4..3fd1443d0d 100644 --- a/tests/install/e2e-assets/source_driver.py +++ b/tests/install/e2e-assets/source_driver.py @@ -57,7 +57,7 @@ def probe_pm(root: Path, desktop: str, command: list[str]) -> None: # bootstrap: that would complete dependencies or recover markers for it. sys.path.insert(0, str(root)) from hermes_cli._launchers import runtime_command - from hermes_cli.runtime_paths import selected_venv, site_packages + from pm.environments import selected_venv, site_packages if command != runtime_command(root): raise RuntimeError("published launcher belongs to another installation or Python") diff --git a/tests/installation_launcher_fixture.py b/tests/installation_launcher_fixture.py index 6ce9102615..e15c40fc1d 100644 --- a/tests/installation_launcher_fixture.py +++ b/tests/installation_launcher_fixture.py @@ -17,7 +17,7 @@ def publish_fixture_launcher(root: Path, main_source: str) -> Path: # are real. The interpreter is external to the checkout, like PM's store. (root / "hermes_bootstrap.py").write_text("", encoding="utf-8") (root / "pm").mkdir(exist_ok=True) - for relative in ("hermes_constants.py", "hermes_cli/_launchers.py", "hermes_cli/runtime_paths.py"): + for relative in ("hermes_constants.py", "hermes_cli/_launchers.py", "pm/environments.py"): shutil.copyfile(repository / relative, root / relative) out = root / ".hermes" / "bin" out.mkdir(parents=True) diff --git a/tests/plugins/memory/test_hindsight_embedded_runtime.py b/tests/plugins/memory/test_hindsight_embedded_runtime.py index f706eb0f6f..c9c373347f 100644 --- a/tests/plugins/memory/test_hindsight_embedded_runtime.py +++ b/tests/plugins/memory/test_hindsight_embedded_runtime.py @@ -24,9 +24,9 @@ def side_root(tmp_path, monkeypatch): def test_ensure_sideenv_uses_pm_selected_python(side_root, monkeypatch): import pm - from hermes_constants import venv_python_path + from pm.environments import venv_python - python = venv_python_path(side_root / "selected") + python = venv_python(side_root / "selected") seen = {} def ensure(name, requirements, **kwargs): @@ -44,9 +44,9 @@ def test_ensure_sideenv_uses_pm_selected_python(side_root, monkeypatch): def test_sideenv_python_is_pm_passive_selection(side_root, monkeypatch): import pm - from hermes_constants import venv_python_path + from pm.environments import venv_python - python = venv_python_path(side_root / "selected") + python = venv_python(side_root / "selected") seen = [] monkeypatch.setattr(pm, "environment_python", lambda name, **kw: seen.append((name, kw)) or python) @@ -61,8 +61,8 @@ def side_python(side_root, monkeypatch, tmp_path): subprocess.run([sys.executable, "-m", "venv", "--without-pip", str(side_root)], check=True, capture_output=True, timeout=30) - from hermes_constants import venv_python_path - python = venv_python_path(side_root) + from pm.environments import venv_python + python = venv_python(side_root) site = Path(subprocess.check_output( [str(python), "-c", "import sysconfig; print(sysconfig.get_path('purelib'))"], text=True, timeout=10, diff --git a/tests/pm/test_activate_scripts.py b/tests/pm/test_activate_scripts.py index ba72b40e8b..e1c477bd4e 100644 --- a/tests/pm/test_activate_scripts.py +++ b/tests/pm/test_activate_scripts.py @@ -156,7 +156,7 @@ def _isolated_checkout(tmp_path: Path) -> Path: shutil.copytree(REPO_ROOT / "pm", root / "pm", ignore=shutil.ignore_patterns("__pycache__")) (root / "hermes_cli").mkdir() for relative in ("activate", "activate.ps1", "hermes_constants.py", "hermes_cli/__init__.py", - "hermes_cli/runtime_paths.py", "hermes_cli/runtime_state.py"): + "pm/environments.py", "hermes_cli/runtime_state.py"): shutil.copy2(REPO_ROOT / relative, root / relative) # Environment-only tests do not exercise provisioning; the runtime tests # replace these stubs with a publisher that records and applies each sync. diff --git a/tests/pm/test_activation_runtime.py b/tests/pm/test_activation_runtime.py index c80d036fbd..c157b8ff83 100644 --- a/tests/pm/test_activation_runtime.py +++ b/tests/pm/test_activation_runtime.py @@ -22,7 +22,7 @@ def _sync_checkout(tmp_path: Path): # no-op and activation must still call it, rather than cache its own answer. (root / "sync.py").write_text(textwrap.dedent('''\ import json, os, pathlib, shutil, sys - from hermes_cli.runtime_paths import runtime_facts_path, site_packages + from pm.environments import runtime_facts_path, site_packages root = pathlib.Path(__file__).parent record = {"argv": sys.argv[1:], "python_env": { key: os.environ.get(key) for key in ("PYTHONHOME", "PYTHONPATH", "VIRTUAL_ENV")}} diff --git a/tests/pm/test_bootstrap_import_closure.py b/tests/pm/test_bootstrap_import_closure.py index fac2976257..55d9f8a3a5 100644 --- a/tests/pm/test_bootstrap_import_closure.py +++ b/tests/pm/test_bootstrap_import_closure.py @@ -13,7 +13,7 @@ def test_minimal_bootstrap_closure_reaches_pm_paths_and_locks(tmp_path): shutil.copytree(repo / "pm", stage / "pm", ignore=shutil.ignore_patterns("__pycache__")) shutil.copy2(repo / "hermes_constants.py", stage / "hermes_constants.py") (stage / "hermes_cli").mkdir() - for name in ("__init__.py", "runtime_paths.py", "runtime_state.py"): + for name in ("__init__.py", "runtime_state.py"): shutil.copy2(repo / "hermes_cli" / name, stage / "hermes_cli" / name) store = stage / "tools" env = dict(os.environ, HERMES_HOME=str(tmp_path / "home"), diff --git a/tests/pm/test_cold_runtime_e2e.py b/tests/pm/test_cold_runtime_e2e.py index 16409624b0..f429da7317 100644 --- a/tests/pm/test_cold_runtime_e2e.py +++ b/tests/pm/test_cold_runtime_e2e.py @@ -187,7 +187,7 @@ assert importlib.util.find_spec('idna') is None report_code = """ import json from pathlib import Path -from hermes_cli.runtime_paths import install_state_dir, selected_venv, runtime_facts_path +from pm.environments import install_state_dir, selected_venv, runtime_facts_path root = Path(sys.path[0]) state = install_state_dir(root) print(json.dumps({'state': str(state), 'app': str(selected_venv(root)), @@ -225,7 +225,7 @@ print(json.dumps({{'yaml': ruamel.yaml.__file__, 'prefix': sys.prefix}})) app_code = """ import json from pathlib import Path -from hermes_cli.runtime_paths import activate_dependencies, selected_venv +from pm.environments import activate_dependencies, selected_venv root = Path(sys.path[0]) activate_dependencies(root) import ruamel.yaml, idna diff --git a/tests/pm/test_environment_build.py b/tests/pm/test_environment_build.py index 231558ae0d..d93f1e5edb 100644 --- a/tests/pm/test_environment_build.py +++ b/tests/pm/test_environment_build.py @@ -146,7 +146,7 @@ def test_public_build_installs_all_extras_at_explicit_destination(installable_pr assert executable.parent.parent == tmp_path / "native environment" assert not (tmp_path / "wrong-environment").exists() assert "root_app" not in sys.modules - from hermes_cli.runtime_paths import site_packages + from pm.environments import site_packages site = site_packages(executable.parent.parent) assert (site / "_virtualenv.pth").exists() is not sealed @@ -203,7 +203,7 @@ def test_group_only_build_excludes_application_dependencies(locked_project, tmp_ @pytest.mark.parametrize("lazy", [False, True]) def test_first_bundle_extension_preserves_shipped_extras(locked_project, build_worker, tmp_path, monkeypatch, lazy): import pm - from hermes_cli.runtime_paths import selected_venv, runtime_facts_path + from pm.environments import selected_venv, runtime_facts_path from pm import paths from pm.features import write_features from pm.lock import Facts @@ -258,7 +258,7 @@ def test_first_bundle_extension_preserves_shipped_extras(locked_project, build_w def test_worker_sync_reuses_unions_and_reports_real_lock_drift(locked_project, build_worker, tmp_path, monkeypatch): import pm - from hermes_cli.runtime_paths import selected_venv, runtime_facts_path + from pm.environments import selected_venv, runtime_facts_path from pm.lock import Facts, Lockfile from pm import paths @@ -689,7 +689,7 @@ def test_real_sync_retains_selection_until_commit(locked_project, tmp_path, monk import pm.extras as extras from pm import paths from pm.lock import Facts - from hermes_cli.runtime_paths import selected_venv + from pm.environments import selected_venv source, uv, env = locked_project monkeypatch.setattr(paths, "repo_root", lambda: source) @@ -729,7 +729,7 @@ def test_real_sync_retains_selection_until_commit(locked_project, tmp_path, monk def test_live_apply_keeps_selection_on_failed_union(locked_project, tmp_path, monkeypatch): - from hermes_cli.runtime_paths import runtime_facts_path, selected_venv + from pm.environments import runtime_facts_path, selected_venv from pm.lock import Facts from pm.packages import Venv import pm.paths diff --git a/tests/pm/test_feature_inventory.py b/tests/pm/test_feature_inventory.py index 4e33887117..8be34ac85c 100644 --- a/tests/pm/test_feature_inventory.py +++ b/tests/pm/test_feature_inventory.py @@ -10,7 +10,7 @@ import pytest import pm.extras as extras import pm.features as features -from hermes_cli.runtime_paths import site_packages +from pm.environments import site_packages def test_inventory_uses_the_target_and_requires_every_anchor(tmp_path, monkeypatch): diff --git a/tests/pm/test_features.py b/tests/pm/test_features.py index 37acaa1c8b..92a971a819 100644 --- a/tests/pm/test_features.py +++ b/tests/pm/test_features.py @@ -66,7 +66,7 @@ def test_sync_venv_allows_frozen_extras_when_lazy_off(rooted, monkeypatch): import sys from pm import paths from pm.lock import Facts - from hermes_cli.runtime_paths import install_state_dir, runtime_facts_path + from pm.environments import install_state_dir, runtime_facts_path ensure_mod = sys.modules["pm.ensure"] diff --git a/tests/pm/test_installed_package.py b/tests/pm/test_installed_package.py index c17fa38a20..2f059ad94c 100644 --- a/tests/pm/test_installed_package.py +++ b/tests/pm/test_installed_package.py @@ -35,7 +35,7 @@ def test_state_fact_in_shared_facts_file_is_not_a_store_install(pm_env): """``venv`` is recorded via ``record_state`` (stamp + extras, no entry) into 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`` (``hermes_cli.runtime_paths``) after a sync.""" + that crashed ``activate`` (``pm.environments``) after a sync.""" from pm.ensure import env_for from pm.lock import Facts from pm.registry import all_packages diff --git a/tests/pm/test_plugin_survival_contract.py b/tests/pm/test_plugin_survival_contract.py index e5fc70d062..a2b85c1d50 100644 --- a/tests/pm/test_plugin_survival_contract.py +++ b/tests/pm/test_plugin_survival_contract.py @@ -189,7 +189,7 @@ def test_conflicting_candidate_refused_unenabled_and_unimported(admission_env): plug_a, plug_b, *_ = _local_conflict_members(home) _write_enabled(home, [], provider="plug-a") admission.admit_plugin_set_change(set(), set(), active_plugins_dir=home / "plugins") - from hermes_cli.runtime_paths import selected_venv + from pm.environments import selected_venv working = selected_venv(tmp_path / "core") config_before = (home / "config.yaml").read_bytes() tree_before = {p: sorted(str(f) for f in p.rglob("*")) for p in (plug_a, plug_b)} @@ -250,7 +250,7 @@ def test_conflicting_candidate_refused_unenabled_and_unimported(admission_env): assert marker.read_bytes() == before child = subprocess.run([str(sidecar_python), "-c", "import sys; print(sys.prefix)"], check=True, capture_output=True, text=True, timeout=30) assert Path(child.stdout.strip()) == sidecar - from hermes_cli.runtime_paths import selected_venv + from pm.environments import selected_venv selected = selected_venv(tmp_path / "core") assert selected.is_dir() and selected != sidecar # A declared version range remains a member across the next managed rebuild. diff --git a/tests/pm/test_recovery.py b/tests/pm/test_recovery.py index 2c1a7f2955..ca8de22f37 100644 --- a/tests/pm/test_recovery.py +++ b/tests/pm/test_recovery.py @@ -29,7 +29,7 @@ def test_startup_validation_checks_real_ruamel_dependency(tmp_path, failure): import ruamel.yaml - from hermes_cli.runtime_paths import site_packages + from pm.environments import site_packages from pm.package import InstallError from pm.recovery import validate_environment @@ -86,7 +86,7 @@ def recovery_graph(tmp_path): def test_repair_restores_recorded_plugin_dependencies_without_config(tmp_path, monkeypatch, recovery_graph, failure): import pm.paths as paths import pm.workspace as workspace - from hermes_cli.runtime_paths import selected_venv, site_packages + from pm.environments import selected_venv, site_packages engine = importlib.import_module("pm.ensure") uv = shutil.which("uv") @@ -166,7 +166,7 @@ def test_repair_restores_recorded_plugin_dependencies_without_config(tmp_path, m def test_uncertain_profile_selection_refuses_sync_but_not_recorded_repair(tmp_path, monkeypatch, recovery_graph): import pm.paths as paths from hermes_cli.plugins_admission import AdmissionRefused, admit_plugin_set_change - from hermes_cli.runtime_paths import install_state_dir, selected_venv, site_packages + from pm.environments import install_state_dir, selected_venv, site_packages engine = importlib.import_module("pm.ensure") # Use the same engine for admission and repair with the offline uv fixture. diff --git a/tests/pm/test_runtime_boot_gc.py b/tests/pm/test_runtime_boot_gc.py index f9cdd8dc4a..05212e8c0e 100644 --- a/tests/pm/test_runtime_boot_gc.py +++ b/tests/pm/test_runtime_boot_gc.py @@ -6,7 +6,7 @@ import sys def test_bootstrap_lease_survives_selection_change(tmp_path, monkeypatch): - from hermes_cli.runtime_paths import install_state_dir, runtime_facts_path, site_packages + from pm.environments import install_state_dir, runtime_facts_path, site_packages from hermes_cli.runtime_state import collect_generations repo = tmp_path / "repo" @@ -30,7 +30,7 @@ def test_bootstrap_lease_survives_selection_change(tmp_path, monkeypatch): code = ''' import sys from pathlib import Path -from hermes_cli.runtime_paths import activate_dependencies +from pm.environments import activate_dependencies activate_dependencies(Path(sys.argv[1])) print("ready", flush=True) sys.stdin.readline() diff --git a/tests/pm/test_runtime_context_home.py b/tests/pm/test_runtime_context_home.py index 54421a2985..ded4661908 100644 --- a/tests/pm/test_runtime_context_home.py +++ b/tests/pm/test_runtime_context_home.py @@ -1,6 +1,6 @@ """Context-only homes use the same dependency state as their own process.""" -from hermes_cli import runtime_paths +from pm import environments as runtime_paths from pm.publication import PluginSelection from hermes_cli.runtime_state import recover_publication, runtime_lock from hermes_constants import reset_hermes_home_override, set_hermes_home_override diff --git a/tests/pm/test_runtime_journal_concurrent_edit.py b/tests/pm/test_runtime_journal_concurrent_edit.py index ca8c68ed1e..65ff38f482 100644 --- a/tests/pm/test_runtime_journal_concurrent_edit.py +++ b/tests/pm/test_runtime_journal_concurrent_edit.py @@ -6,7 +6,7 @@ def test_recovery_refuses_to_replace_newer_config(tmp_path, monkeypatch): from hermes_cli.runtime_state import recover_publication, runtime_lock from pm.publication import PluginSelection import pm.paths as paths - from hermes_cli.runtime_paths import install_state_dir + from pm.environments import install_state_dir monkeypatch.setenv("HERMES_HOME", str(tmp_path)) repo = tmp_path / "repo" diff --git a/tests/pm/test_runtime_journal_safety.py b/tests/pm/test_runtime_journal_safety.py index e235280152..7bbfd983eb 100644 --- a/tests/pm/test_runtime_journal_safety.py +++ b/tests/pm/test_runtime_journal_safety.py @@ -3,7 +3,7 @@ import json import pytest -from hermes_cli.runtime_paths import install_state_dir +from pm.environments import install_state_dir from hermes_cli.runtime_state import recover_publication, runtime_lock diff --git a/tests/pm/test_runtime_selection.py b/tests/pm/test_runtime_selection.py index 557dc07ca9..7edbcaaef4 100644 --- a/tests/pm/test_runtime_selection.py +++ b/tests/pm/test_runtime_selection.py @@ -6,7 +6,7 @@ import pytest def test_install_runtime_selection_is_scoped_and_read_only(tmp_path, monkeypatch): - from hermes_cli import runtime_paths + from pm import environments as runtime_paths home = tmp_path / "home" monkeypatch.setenv("HERMES_HOME", str(home)) @@ -33,7 +33,7 @@ def test_boot_uses_one_selected_dependency_tree_in_fresh_process(tmp_path, monke import os import subprocess import sys - from hermes_cli import runtime_paths + from pm import environments as runtime_paths root = tmp_path / "repo" base = root / "venv" @@ -53,7 +53,7 @@ def test_boot_uses_one_selected_dependency_tree_in_fresh_process(tmp_path, monke "venv": {"environment": str(selected)} }})) code = ( - "import sys; from pathlib import Path; from hermes_cli.runtime_paths import activate_dependencies; " + "import sys; from pathlib import Path; from pm.environments import activate_dependencies; " "sys.path.insert(0, sys.argv[2]); activate_dependencies(Path(sys.argv[1])); " "import probe_package, importlib.util; print(probe_package.version); " "print(importlib.util.find_spec('base_only') is None)" @@ -70,7 +70,7 @@ def test_broken_environment_keeps_explicit_repair_entry_reachable(tmp_path, monk import os import subprocess import sys - from hermes_cli.runtime_paths import runtime_facts_path + from pm.environments import runtime_facts_path monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) repo = Path(__file__).resolve().parents[2] @@ -90,7 +90,7 @@ def test_manual_repair_bypasses_damaged_generation_activation(tmp_path, monkeypa import os import subprocess import sys - from hermes_cli.runtime_paths import install_state_dir, runtime_facts_path, site_packages + from pm.environments import install_state_dir, runtime_facts_path, site_packages repo = Path(__file__).resolve().parents[2] monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) @@ -112,7 +112,7 @@ def test_manual_repair_bypasses_damaged_generation_activation(tmp_path, monkeypa @pytest.mark.parametrize("data", [[], {"packages": []}, {"packages": {"venv": []}}]) def test_malformed_selection_has_actionable_error(tmp_path, monkeypatch, data): - from hermes_cli import runtime_paths + from pm import environments as runtime_paths monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) record = runtime_paths.runtime_facts_path(tmp_path / "repo") record.parent.mkdir(parents=True) @@ -123,7 +123,7 @@ def test_malformed_selection_has_actionable_error(tmp_path, monkeypatch, data): @pytest.mark.parametrize("bad_path", ["outside", "missing"]) def test_invalid_selected_environment_never_silently_falls_back(tmp_path, monkeypatch, bad_path): - from hermes_cli import runtime_paths + from pm import environments as runtime_paths monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) root = tmp_path / "repo" diff --git a/tests/pm/test_source_update_launch.py b/tests/pm/test_source_update_launch.py index d03b4dc089..4e69a59dc8 100644 --- a/tests/pm/test_source_update_launch.py +++ b/tests/pm/test_source_update_launch.py @@ -19,7 +19,7 @@ import pytest import pm from hermes_cli import venv_sync -from hermes_cli.runtime_paths import install_state_dir, runtime_facts_path, selected_venv, site_packages +from pm.environments import install_state_dir, runtime_facts_path, selected_venv, site_packages from pm import paths from pm.lock import Facts from pm.package import InstallError @@ -313,7 +313,7 @@ def test_real_bootstrap_reexecs_before_app_imports(source_launch, tmp_path, isol "import hermes_bootstrap\n" "import json, sys\n" "from pathlib import Path\n" - "from hermes_cli.runtime_paths import selected_venv, site_packages\n" + "from pm.environments import selected_venv, site_packages\n" "from hermes_cli.venv_sync import prepare_launch\n" "root = Path(__file__).parent\n" "selected = selected_venv(root)\n" @@ -342,7 +342,7 @@ def test_real_bootstrap_reexecs_before_app_imports(source_launch, tmp_path, isol activation_probe = subprocess.run( [str(isolated_python), "-I", "-c", f"import sys; sys.path.insert(0, {str(repository)!r}); " - "from pathlib import Path; from hermes_cli.runtime_paths import activate_dependencies; " + "from pathlib import Path; from pm.environments import activate_dependencies; " f"activate_dependencies(Path({str(root)!r}))"], capture_output=True, text=True, timeout=30, ) diff --git a/tests/pm/test_startup_recovery.py b/tests/pm/test_startup_recovery.py index 5a752e6dca..5ad2263d88 100644 --- a/tests/pm/test_startup_recovery.py +++ b/tests/pm/test_startup_recovery.py @@ -34,7 +34,7 @@ def isolated_machine_home(tmp_path, monkeypatch): @pytest.mark.parametrize("marker_name", [".update-incomplete", ".lazy-refresh-incomplete", None, "manual", "baseline"]) def test_bootstrap_repairs_before_dependency_activation(tmp_path, monkeypatch, marker_name): import pm.paths as paths - from hermes_cli.runtime_paths import selected_venv, site_packages + from pm.environments import selected_venv, site_packages engine = importlib.import_module("pm.ensure") repo = Path(__file__).resolve().parents[2] @@ -49,7 +49,7 @@ def test_bootstrap_repairs_before_dependency_activation(tmp_path, monkeypatch, m cli.mkdir() # Include the real preimport protocol, including its ownership check. Do # not stub prepare_launch: the same files are also saved in PM's workspace. - for name in ("__init__.py", "runtime_paths.py", "runtime_state.py", "_early_recovery.py", + for name in ("__init__.py", "runtime_state.py", "_early_recovery.py", "_parser.py", "venv_sync.py", "steward.py"): shutil.copy2(repo / "hermes_cli" / name, cli / name) wheels = tmp_path / "wheels" diff --git a/tests/pm/test_takeover_preparation.py b/tests/pm/test_takeover_preparation.py index 6d47a3c6a7..14371ff311 100644 --- a/tests/pm/test_takeover_preparation.py +++ b/tests/pm/test_takeover_preparation.py @@ -66,7 +66,7 @@ def test_fresh_takeover_prepares_generation_and_runs_selected_python(tmp_path): "import json, sys\nfrom pathlib import Path\n" "request=json.loads(Path(sys.argv[1]).read_text())\n" "sys.path.insert(0,request['root'])\n" - "from hermes_cli.runtime_paths import activate_dependencies\n" + "from pm.environments import activate_dependencies\n" "activate_dependencies(Path(request['root']))\nimport takeover_dep\n" "assert request['pm_receipt']['update_id']==request['update_id']\n" "Path(sys.argv[2]).write_text(json.dumps({'python':sys.executable,'dep':takeover_dep.__file__}))\n", diff --git a/tests/pm/test_worker.py b/tests/pm/test_worker.py index 5dff704f07..3a85f484c2 100644 --- a/tests/pm/test_worker.py +++ b/tests/pm/test_worker.py @@ -49,7 +49,7 @@ def test_refused_or_already_paused_install_does_not_acquire_runtime(client, monk def _current_environment(tmp_path, monkeypatch, members): - from hermes_cli.runtime_paths import install_state_dir + from pm.environments import install_state_dir from pm.lock import Facts from pm.packages import Venv @@ -71,7 +71,7 @@ def _current_environment(tmp_path, monkeypatch, members): def test_currency_probe_preserves_union_and_candidate_inputs(client, tmp_path, monkeypatch, isolated_python, member_shape, route): import json - from hermes_cli.runtime_paths import runtime_facts_path, selected_venv + from pm.environments import runtime_facts_path, selected_venv from pm.lock import Facts from pm.packages import Venv @@ -158,7 +158,7 @@ def test_currency_probe_preserves_union_and_candidate_inputs(client, tmp_path, m def _assert_worker_holds_lock(repo): - from hermes_cli.runtime_paths import install_state_dir + from pm.environments import install_state_dir from hermes_cli.runtime_state import _lock with (install_state_dir(repo) / ".install.lock").open("a+b") as lock: @@ -237,7 +237,7 @@ def test_lazy_disabled_sync_does_not_bootstrap_tools(client, tmp_path, monkeypat def test_invalid_selection_waits_for_failed_receipt_and_lock_release(client, tmp_path, monkeypatch): import json - from hermes_cli.runtime_paths import install_state_dir + from pm.environments import install_state_dir from hermes_cli.runtime_state import _lock repo = _current_environment(tmp_path, monkeypatch, []) @@ -463,7 +463,7 @@ def test_resolution_conflict_survives_worker_and_receipt(client, tmp_path, monke def test_failed_facts_write_restores_exact_config_before_reporting(client, tmp_path, monkeypatch, isolated_python): from tests.pm._fixtures import worker_toolchain - from hermes_cli.runtime_paths import install_state_dir + from pm.environments import install_state_dir repo = _current_environment(tmp_path, monkeypatch, []) home = tmp_path / "home" @@ -502,7 +502,7 @@ def test_worker_death_reports_transport_failure(client, monkeypatch, isolated_py def test_foreign_checkout_sync_uses_its_own_pm_generation(client, tmp_path, monkeypatch, isolated_python): from pm import venv_is_current - from hermes_cli.runtime_paths import selected_venv, runtime_facts_path + from pm.environments import selected_venv, runtime_facts_path from tests.pm._fixtures import worker_toolchain worker_toolchain(client, monkeypatch, isolated_python) diff --git a/tests/pm/test_worker_publication.py b/tests/pm/test_worker_publication.py index 40a23f1ea0..e58611d065 100644 --- a/tests/pm/test_worker_publication.py +++ b/tests/pm/test_worker_publication.py @@ -15,7 +15,7 @@ from tests.pm._fixtures import worker_toolchain def test_worker_publishes_selection_even_when_dependencies_are_current(client, tmp_path, monkeypatch): - from hermes_cli.runtime_paths import install_state_dir + from pm.environments import install_state_dir from pm import receipt home = tmp_path / "home" @@ -47,7 +47,7 @@ def test_worker_publishes_selection_even_when_dependencies_are_current(client, t def test_staged_plugin_publication_uses_installed_identity_and_local_dependencies( client, tmp_path, monkeypatch, isolated_python, active, missing, ): - from hermes_cli.runtime_paths import install_state_dir, selected_venv + from pm.environments import install_state_dir, selected_venv from pm.store import tree_digest from tests.pm.test_environment_build import _wheel from pm import paths @@ -105,7 +105,7 @@ def test_staged_plugin_publication_uses_installed_identity_and_local_dependencie @pytest.mark.parametrize("mutation", ["sibling", "active"]) def test_selection_refuses_config_edits_during_preparation(client, tmp_path, monkeypatch, isolated_python, mutation): - from hermes_cli.runtime_paths import install_state_dir + from pm.environments import install_state_dir home = tmp_path / "home" home.mkdir() config = home / "config.yaml" @@ -178,7 +178,7 @@ def test_memory_setup_sends_candidate_paths_instead_of_discovery_callbacks(tmp_p def test_worker_death_recovers_at_each_durable_publication_boundary( client, tmp_path, monkeypatch, isolated_python, kind, rebuild, phase, ): - from hermes_cli.runtime_paths import install_state_dir, selected_venv + from pm.environments import install_state_dir, selected_venv from pm import paths from pm.package import InstallError from pm.store import tree_digest @@ -243,7 +243,7 @@ def test_worker_death_recovers_at_each_durable_publication_boundary( assert (state / "publication.json").exists() source = Path(client.__file__).resolve().parent.parent program = (f"import sys; sys.path.insert(0, {str(source)!r}); from pathlib import Path; " - "from hermes_cli.runtime_paths import activate_dependencies; " + "from pm.environments import activate_dependencies; " f"project = Path({str(project)!r})\n" "activate_dependencies(project)\nactivate_dependencies(project)\n") recovery = subprocess.run([sys.executable, "-I", "-S", "-c", program], capture_output=True, text=True, @@ -266,7 +266,7 @@ def test_worker_death_recovers_at_each_durable_publication_boundary( @pytest.mark.parametrize("mutation", ["metadata", "target", "staged", "sibling-manifest"]) def test_staged_publication_refuses_concurrent_input_edits(client, tmp_path, monkeypatch, isolated_python, mutation): - from hermes_cli.runtime_paths import install_state_dir + from pm.environments import install_state_dir from pm.store import tree_digest repo = _current_environment(tmp_path, monkeypatch, []) home = tmp_path / "home" @@ -342,7 +342,7 @@ def test_selection_preserves_yaml11_values_and_quotes_plugin_names(client, tmp_p def test_explicit_publication_keeps_its_intent_through_tool_acquisition(client, tmp_path, monkeypatch, isolated_python): from pm import paths - from hermes_cli.runtime_paths import selected_venv + from pm.environments import selected_venv project = tmp_path / "project" project.mkdir() monkeypatch.setattr(paths, "repo_root", lambda: project) diff --git a/tests/scripts/test_bundle_native.py b/tests/scripts/test_bundle_native.py index 0b88586e8e..0f3fea315b 100644 --- a/tests/scripts/test_bundle_native.py +++ b/tests/scripts/test_bundle_native.py @@ -23,7 +23,7 @@ def test_bundle_stages_git_tree_and_runs_native_children_before_manifest(tmp_pat import importlib import inspect - from hermes_cli.runtime_paths import site_packages + from pm.environments import site_packages from pm.lock import Facts from pm.registry import get_package from pm.store import tree_digest @@ -55,7 +55,7 @@ def test_bundle_stages_git_tree_and_runs_native_children_before_manifest(tmp_pat source = Path(__file__).resolve().parents[2] shutil.copytree(source / "pm", repo / "pm", ignore=shutil.ignore_patterns("__pycache__")) (repo / "hermes_cli").mkdir() - for name in ("__init__.py", "runtime_paths.py", "runtime_state.py"): + for name in ("__init__.py", "runtime_state.py"): shutil.copy2(source / "hermes_cli" / name, repo / "hermes_cli" / name) shutil.copy2(source / "hermes_constants.py", repo / "hermes_constants.py") wheels = repo / "wheels" diff --git a/tests/scripts/test_commit_stamp_identity.py b/tests/scripts/test_commit_stamp_identity.py index 5a7b71c75b..e97895ba09 100644 --- a/tests/scripts/test_commit_stamp_identity.py +++ b/tests/scripts/test_commit_stamp_identity.py @@ -15,7 +15,7 @@ def test_stamp_uses_built_commit_even_with_dispatch_sha_and_refuses_mismatch(tmp repo.mkdir() for relative in ('scripts/write_install_stamp.py', 'scripts/releases/commit_build.py', 'hermes_cli/__init__.py', 'hermes_cli/update_channel.py', 'hermes_cli/release_channels.py', - 'hermes_cli/runtime_paths.py', 'hermes_cli/steward.py', 'hermes_constants.py'): + 'pm/environments.py', 'hermes_cli/steward.py', 'hermes_constants.py'): dest = repo / relative dest.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(ROOT / relative, dest) diff --git a/tests/scripts/test_desktop_toolchain.py b/tests/scripts/test_desktop_toolchain.py index 749677fe49..273ee12dcb 100644 --- a/tests/scripts/test_desktop_toolchain.py +++ b/tests/scripts/test_desktop_toolchain.py @@ -139,7 +139,7 @@ def test_bootstrap_real_pm_resolves_only_build_owned_state(tmp_path): probe = ( "import json, sys; from pathlib import Path; sys.path.insert(0, sys.argv[1]); " "from pm import paths; from pm.packages import uv_cache_dir; " - "from hermes_cli.runtime_paths import install_state_dir; " + "from pm.environments import install_state_dir; " "print(json.dumps([str(paths.store_root()), str(paths.partials_root()), " "str(uv_cache_dir()), str(install_state_dir(Path(sys.argv[1])))]))" ) diff --git a/tests/scripts/test_mint_launchers.py b/tests/scripts/test_mint_launchers.py index 802fd2f80a..cef0847f66 100644 --- a/tests/scripts/test_mint_launchers.py +++ b/tests/scripts/test_mint_launchers.py @@ -67,7 +67,7 @@ def payload_tree(tmp_path: Path): # Exercise the real bootstrap before the fixture entry point. for relative in ("hermes_bootstrap.py", "hermes_constants.py", "hermes_cli/__init__.py", - "hermes_cli/runtime_paths.py", "hermes_cli/runtime_state.py", + "pm/environments.py", "hermes_cli/runtime_state.py", "hermes_cli/_early_recovery.py", "hermes_cli/_parser.py"): shutil.copy2(_REPO / relative, repo / relative) (repo / "hermes_cli" / "main.py").write_text( diff --git a/tests/scripts/test_pm_runtime_bundle.py b/tests/scripts/test_pm_runtime_bundle.py index 8d09b99a1c..8327da6429 100644 --- a/tests/scripts/test_pm_runtime_bundle.py +++ b/tests/scripts/test_pm_runtime_bundle.py @@ -22,7 +22,7 @@ def _exercise_relocated_pm_runtime(tmp_path, monkeypatch): source = Path(__file__).resolve().parents[2] shutil.copytree(source / "pm", repo / "pm", ignore=shutil.ignore_patterns("__pycache__", ".hermes-tmp.*")) (repo / "hermes_cli").mkdir() - for name in ("__init__.py", "runtime_paths.py", "runtime_state.py"): + for name in ("__init__.py", "runtime_state.py"): shutil.copy2(source / "hermes_cli" / name, repo / "hermes_cli" / name) shutil.copy2(source / "hermes_constants.py", repo / "hermes_constants.py") # Copy the base executable, not a venv's launcher. The test host provides diff --git a/tests/scripts/test_release_build_commit.py b/tests/scripts/test_release_build_commit.py index c4483ff631..a5d9f2e28c 100644 --- a/tests/scripts/test_release_build_commit.py +++ b/tests/scripts/test_release_build_commit.py @@ -35,7 +35,7 @@ def fixture_repo(tmp_path): 'scripts/releases/r2.py', 'scripts/releases/r2_scope.py', 'scripts/release-content-types.json', 'hermes_cli/__init__.py', 'hermes_cli/update_channel.py', 'hermes_cli/release_channels.py', - 'hermes_cli/runtime_paths.py', 'hermes_constants.py'): + 'pm/environments.py', 'hermes_constants.py'): dest = repo / relative dest.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(ROOT / relative, dest) diff --git a/tests/scripts/test_setup_toolchain.py b/tests/scripts/test_setup_toolchain.py index f7e30a8e4a..756c0bd5a7 100644 --- a/tests/scripts/test_setup_toolchain.py +++ b/tests/scripts/test_setup_toolchain.py @@ -56,7 +56,7 @@ def test_development_setup_keeps_test_groups_out_of_the_runtime(tmp_path, monkey assert result.stdout.strip() == str("dev" in extras) assert Path(outputs["venv"]).is_relative_to(home) assert not (core / ".venv").exists() - from hermes_cli.runtime_paths import runtime_facts_path + from pm.environments import runtime_facts_path assert runtime_facts_path(core).exists() == ("dev" not in extras) diff --git a/tests/scripts/test_source_build_env.py b/tests/scripts/test_source_build_env.py index 4cb84b80f1..67b53d460d 100644 --- a/tests/scripts/test_source_build_env.py +++ b/tests/scripts/test_source_build_env.py @@ -17,7 +17,7 @@ def _stamp_probe(tmp_path, shell): repo = tmp_path / "installed source" for relative in ( "scripts/write_install_stamp.py", "hermes_cli/__init__.py", - "hermes_cli/update_channel.py", "hermes_cli/runtime_paths.py", + "hermes_cli/update_channel.py", "pm/environments.py", "hermes_cli/steward.py", "hermes_constants.py", ): dest = repo / relative diff --git a/tests/scripts/test_source_driver.py b/tests/scripts/test_source_driver.py index 6a34cf82a8..224fee9e7d 100644 --- a/tests/scripts/test_source_driver.py +++ b/tests/scripts/test_source_driver.py @@ -146,7 +146,7 @@ root, store, node, deps = map(Path, sys.argv[1:]) sys.path.insert(0, str(root)) from pm.lock import Facts, Lockfile from pm.packages import Venv -from hermes_cli.runtime_paths import install_state_dir, runtime_facts_path, site_packages +from pm.environments import install_state_dir, runtime_facts_path, site_packages from hermes_cli._launchers import ensure_install_launchers lock = Lockfile(root / 'pm/lock.json') lock.set_pin('node', 'fixture', {}) diff --git a/tests/test_plugin_historic_compat.py b/tests/test_plugin_historic_compat.py index 97a20d02b5..d280af0792 100644 --- a/tests/test_plugin_historic_compat.py +++ b/tests/test_plugin_historic_compat.py @@ -64,7 +64,7 @@ def test_candidate_member_dirs_preserves_proposed_home_order_and_extras(isolated @pytest.fixture def publication(isolated_home, tmp_path): - from hermes_cli.runtime_paths import install_state_dir + from pm.environments import install_state_dir project = tmp_path / "checkout" project.mkdir() @@ -115,7 +115,7 @@ plugins_transaction.recover_plugin_publication( @pytest.mark.parametrize("commit", ["rollback", "explicit", "facts-changed"]) def test_old_publication_recovers_in_stdlib_using_supplied_row_and_journal(publication, commit): - from hermes_cli.runtime_paths import runtime_facts_path + from pm.environments import runtime_facts_path project, row, journal, canonical = publication target, backup, metadata = (Path(row[key]) for key in ("target", "backup", "metadata")) diff --git a/tests/test_source_launcher_stages.py b/tests/test_source_launcher_stages.py index 32460d9a42..8e3a1cb792 100644 --- a/tests/test_source_launcher_stages.py +++ b/tests/test_source_launcher_stages.py @@ -7,7 +7,7 @@ import subprocess import pytest -from hermes_cli.runtime_paths import install_state_dir, site_packages +from pm.environments import install_state_dir, site_packages from pm.lock import Lockfile from tests.hermes_cli.test_source_launcher_publication import fixture_tree diff --git a/tests/tools/test_local_env_blocklist.py b/tests/tools/test_local_env_blocklist.py index 9dbf02f070..126f617b2d 100644 --- a/tests/tools/test_local_env_blocklist.py +++ b/tests/tools/test_local_env_blocklist.py @@ -282,7 +282,7 @@ def test_launcher_alias_provenance(child_env, monkeypatch, link_at, profile): @pytest.mark.parametrize("has_facts", [True, False]) def test_runtime_provenance_is_independent_of_aliases_and_virtual_env(child_env, monkeypatch, has_facts): - from hermes_cli.runtime_paths import runtime_facts_path + from pm.environments import runtime_facts_path payload = child_env / "payload" runtime = payload / "state/environments/candidate/venv" site = runtime / ("Lib/site-packages" if os.name == "nt" else @@ -291,7 +291,7 @@ def test_runtime_provenance_is_independent_of_aliases_and_virtual_env(child_env, (runtime / "pyvenv.cfg").write_text("version = 3.14\n", encoding="utf-8") (payload / "tools").mkdir() (payload / "manifest.json").write_text("{}", encoding="utf-8") - monkeypatch.setattr("hermes_cli.runtime_paths.install_state_dir", lambda repo: payload / "state") + monkeypatch.setattr("pm.environments.install_state_dir", lambda repo: payload / "state") if has_facts: facts = runtime_facts_path(Path(__file__).resolve().parents[2]) facts.parent.mkdir(parents=True, exist_ok=True) diff --git a/tests/tools/test_tts_lifecycle_leases.py b/tests/tools/test_tts_lifecycle_leases.py index 2600e6f725..f0213239b3 100644 --- a/tests/tools/test_tts_lifecycle_leases.py +++ b/tests/tools/test_tts_lifecycle_leases.py @@ -123,7 +123,7 @@ def test_warm_sdk_uses_pm_availability_and_refuses_inactive_generation(tmp_path, import pm.client import pm.paths import pm.extras - from hermes_cli.runtime_paths import runtime_facts_path + from pm.environments import runtime_facts_path calls = [] root = tmp_path / "repo" diff --git a/tools/computer_use/cua_backend.py b/tools/computer_use/cua_backend.py index 8e1ea252f3..326f7ab13e 100644 --- a/tools/computer_use/cua_backend.py +++ b/tools/computer_use/cua_backend.py @@ -21,8 +21,9 @@ from hermes_cli._subprocess_compat import windows_hide_flags from tools.computer_use.backend import ActionResult, ComputerUseBackend from tools.computer_use.cua_backend_capture import _CaptureMixin from tools.computer_use.cua_backend_daemon import _EmbeddedCuaDaemon -from tools.computer_use.cua_backend_driver import ( - _CUA_DRIVER_CMD_ENV, cua_driver_binary_available, cua_driver_runtime_contract_status) +from tools.computer_use.cua_backend_driver import ( # noqa: F401 — resolve_cua_driver_cmd: frozen updater surface + _CUA_DRIVER_CMD_ENV, cua_driver_binary_available, cua_driver_runtime_contract_status, + resolve_cua_driver_cmd) from tools.computer_use.cua_backend_input import _InputMixin from tools.computer_use.cua_backend_parse import _action_result_from from tools.computer_use.cua_backend_session import _AsyncBridge, _CuaDriverSession diff --git a/tools/environments/local_pythonpath.py b/tools/environments/local_pythonpath.py index 6d92ab933c..078f777edd 100644 --- a/tools/environments/local_pythonpath.py +++ b/tools/environments/local_pythonpath.py @@ -74,7 +74,7 @@ def _validated_runtime_venv(env: dict) -> Path | None: """Producer-owned runtime venv identified by VIRTUAL_ENV, or None. The variable alone is not provenance (users carry unrelated venvs): require the legacy Windows base-Python producer's exact ``/venv`` layout AND a real ``pyvenv.cfg``.""" - from hermes_cli.runtime_paths import runtime_facts_path, selected_venv + from pm.environments import runtime_facts_path, selected_venv root = Path(__file__).resolve().parents[2] if runtime_facts_path(root).is_file(): @@ -111,7 +111,7 @@ def _get_hermes_site_packages(env: dict) -> list[Path]: runtime_venv = _validated_runtime_venv(env) if runtime_venv is not None: - from hermes_cli.runtime_paths import site_packages + from pm.environments import site_packages runtime_site_packages = site_packages(runtime_venv) if not any(_same_path(runtime_site_packages, existing) for existing in result): result.append(runtime_site_packages) diff --git a/website/docs/developer-guide/pm-audit-status.md b/website/docs/developer-guide/pm-audit-status.md index 1e721913a0..b1358c92eb 100644 --- a/website/docs/developer-guide/pm-audit-status.md +++ b/website/docs/developer-guide/pm-audit-status.md @@ -64,7 +64,7 @@ distinguishes runtime activation from independent test and editor environments. Implementation does not mean that every platform acceptance test is complete. The updater, backup, setup, voice-text helpers, and several plugins now use one implementation per reconciled concern. Runtime paths have a shared owner -in `hermes_cli/runtime_paths.py`. +in `pm/environments.py`. ## Closure implementation