fix(update): cap boot recovery retries and derive self-lock native modules

Two residual Windows reinstall-loop paths behind #81594:

- The post-import recovery (_recover_core_update_marker_locked) had no
  attempt cap: once the early pass stood down after 3 failures (e.g. a
  .pyd mapped by a live Desktop backend or gateway), every launch re-ran
  the full .[all] reinstall from a process that already maps native venv
  extensions. It now shares the early pass's attempt budget stored in the
  marker body: failures bump it, and past the cap boots stop reinstalling
  and print the exact manual recovery commands (including clearing the
  marker). A single successful recovery still clears the marker.

- The updater's self-lock detector only knew cryptography._rust and
  yaml._yaml; the report's locked file was _cffi_backend.pyd. The set is
  now derived from sys.modules (every loaded extension under
  site-packages, mapped to its distribution). An unpinned transitive such
  as cffi counts as rewritten when uv.lock resolves it elsewhere AND a
  base-dependency parent is moving to its pin, so the deferral is cleared
  by the sync it defers (no #86735 always-fire loop); anything else stays
  fail-open.
This commit is contained in:
brooklyn!
2026-09-24 04:05:24 -05:00
parent be59f0411e
commit d892451a45
7 changed files with 316 additions and 97 deletions

View File

@@ -732,9 +732,9 @@ def recover_if_needed(project_root: Path | None = None, argv: list[str] | None =
# updater inside the marker-to-install window — never race it. A dead owner MUST be
# recovered even when this launch is itself `hermes update`: CLI and Desktop retries keep
# that argv, and skipping solely on argv recreates the self-lock loop.
# Bounded retries: a persistently failing install must not hammer every launch, so attempts past the
# ceiling are left for main.py's post-import recovery path (which can safely probe-import after this
# process already holds whatever extensions it needs). See #83569.
# Bounded retries: a persistently failing install must not hammer every launch. The budget
# is shared with main.py's post-import recovery, which past it prints the manual command
# instead of reinstalling. See #83569, #81594.
if core_marker.exists():
if _marker_owner_is_live(core_marker):
return
@@ -771,10 +771,12 @@ def recover_if_needed(project_root: Path | None = None, argv: list[str] | None =
pass # Never block launch — the import of main.py will surface the truth.
# Cap on automatic early-pass install retries: a persistently failing install (network down) must
# not reinstall-hammer every launch. Past this the marker is left to main.py's post-import recovery,
# which presents the manual command. The counter lives in the marker's JSON body.
_EARLY_CORE_INSTALL_MAX_ATTEMPTS = 3
# Cap on automatic core-install retries, shared by this early pass and main.py's post-import
# recovery: a persistently failing install (network down, a .pyd another Hermes process maps) must
# not reinstall-hammer every launch. Past it the post-import path presents the manual command. The
# counter lives in the marker's JSON body; an update that reaches the dependency sync rewrites
# the marker, re-arming it.
_CORE_INSTALL_MAX_ATTEMPTS = 3
def _claim_recovery_lock(root: Path) -> bool:
@@ -822,10 +824,9 @@ def _complete_pending_core_install(root: Path, core_marker: Path) -> bool:
# Read attempts before claiming the lock so a persistently-failing install stops early.
attempts = _read_marker_attempts(core_marker)
if attempts >= _EARLY_CORE_INSTALL_MAX_ATTEMPTS:
if attempts >= _CORE_INSTALL_MAX_ATTEMPTS:
print("⚠ Pending interrupted-update install has already failed "
f"{attempts} times in the early pass — leaving it for the "
"post-import recovery path.", file=sys.stderr)
f"{attempts} times — skipping the automatic retry.", file=sys.stderr)
return False
if not _claim_recovery_lock(root):
return False
@@ -837,9 +838,10 @@ def _complete_pending_core_install(root: Path, core_marker: Path) -> bool:
except Exception as exc:
new_attempts = ir.bump_marker_attempts(core_marker)
print(f" ✗ Early interrupted-install completion failed (attempt "
f"{new_attempts}/{_EARLY_CORE_INSTALL_MAX_ATTEMPTS}): {exc}", file=sys.stderr)
print(" The next launch will retry; hermes will keep working from "
"the current venv in the meantime.", file=sys.stderr)
f"{new_attempts}/{_CORE_INSTALL_MAX_ATTEMPTS}): {exc}", file=sys.stderr)
if new_attempts < _CORE_INSTALL_MAX_ATTEMPTS:
print(" The next launch will retry; hermes will keep working from "
"the current venv in the meantime.", file=sys.stderr)
return False
finally:
_release_recovery_lock(root)

View File

@@ -241,8 +241,22 @@ def _recover_core_update_marker_locked() -> None:
Narrow lazy-refresh import probes are not proof that a generic interrupted core
install finished — a missing dep outside that probe set would look healthy and
clear the breadcrumb too early.
Shares the early pass's attempt budget (the marker's JSON body). Past it, this path
used to reinstall on EVERY boot from a process that already maps native venv
extensions — the reinstall loop of #81594; now it prints the manual recovery instead.
"""
from hermes_cli.main import PROJECT_ROOT
marker = _update_marker_path()
attempts = _early_recovery_mod._read_marker_attempts(marker)
max_attempts = _early_recovery_mod._CORE_INSTALL_MAX_ATTEMPTS
if attempts >= max_attempts:
print(
f"✗ Finishing an interrupted `hermes update` failed {attempts} times; automatic "
"retries are paused so Hermes stops reinstalling on every launch.")
for line in _manual_core_recovery_lines(PROJECT_ROOT, marker, windows=_is_windows()):
print(line)
return
print(
"⚠ A previous `hermes update` was interrupted mid-install — "
"finishing dependency installation now...")
@@ -259,9 +273,8 @@ def _recover_core_update_marker_locked() -> None:
"then quarantined full reinstall (core marker stays until that "
"succeeds)...")
_repair_venv_via_import_probes(install_prefix, env=install_env)
from hermes_cli import _install_repair as _ir
try:
from hermes_cli import _install_repair as _ir
# ensure_uv bootstraps uv itself when missing (the early pass's stdlib-only lookup
# cannot), so a venv whose uv vanished mid-update still heals.
from hermes_cli.managed_uv import ensure_uv
@@ -272,25 +285,41 @@ def _recover_core_update_marker_locked() -> None:
_clear_update_incomplete_marker()
print("✓ Dependency installation recovered — your install is healthy again.")
except Exception as exc:
# Leave the marker so the next launch retries; give the exact manual command.
# Leave the marker so the next launch retries (within the shared budget); give the
# exact manual command.
attempts = _ir.bump_marker_attempts(marker)
logger.debug("Interrupted-install recovery failed: %s", exc)
print("✗ Could not auto-recover the interrupted install.")
manual = (
" Hermes is still running from the launcher that needs "
"replacing. Close other Hermes windows, restart from a "
"different terminal, then run:",
f' cd /d "{PROJECT_ROOT}"',
f' "{sys.executable}" -m pip install -e ".[all]"',
) if self_locked else (
" Recover manually with:",
f" cd {PROJECT_ROOT}",
f" {sys.executable} -m ensurepip --upgrade",
f" {sys.executable} -m pip install -e '.[all]'",
)
for line in manual:
retry = ("the next launch will retry" if attempts < max_attempts
else "automatic retries are now paused")
print(f"✗ Could not auto-recover the interrupted install "
f"(attempt {attempts}/{max_attempts}; {retry}).")
for line in _manual_core_recovery_lines(
PROJECT_ROOT, marker, windows=self_locked or _is_windows()):
print(line)
def _manual_core_recovery_lines(root: Path, marker: Path, *, windows: bool) -> tuple[str, ...]:
"""The exact commands that finish a pending core install by hand and clear its marker.
On Windows another Hermes process (Desktop backend, gateway, a second terminal) mapping a
venv ``.pyd`` is what keeps the automatic install failing, so they must be closed first.
"""
if windows:
return (
" Close every Hermes window (Desktop app, gateways, other terminals), then from a "
"new terminal run:",
f' cd /d "{root}"',
f' "{sys.executable}" -m ensurepip --upgrade',
f' "{sys.executable}" -m pip install -e ".[all]"',
f' del "{marker}"')
return (
" Recover manually with:",
f" cd {shlex.quote(str(root))}",
f" {sys.executable} -m ensurepip --upgrade",
f" {sys.executable} -m pip install -e '.[all]'",
f" rm -f {shlex.quote(str(marker))}")
def _norm_exe_path(path) -> str:
"""Case-folded resolved path, for comparing executables on Windows."""
try:

View File

@@ -74,7 +74,7 @@ from hermes_cli.update_cmd_config import ( # noqa: F401
_LAST_SIBLING_SNAPSHOTS, _check_and_apply_config_migration, _migrate_sibling_profile_configs,
_print_items, _run_config_check_fresh, _run_migrate_config_fresh)
from hermes_cli.update_cmd_deps import ( # noqa: F401
_INSTALL_DEFINING_FILES, _SELF_LOCKING_NATIVE_MODULES, _UPDATE_CRITICAL_MODULES,
_INSTALL_DEFINING_FILES, _UPDATE_CRITICAL_MODULES,
_abort_dependency_sync_if_self_locked, _capture_active_lazy_features,
_capture_active_tool_dependencies, _critical_module_import_failures,
_defer_update_for_self_lock, _dependency_sync_would_rewrite, _desktop_app_present,

View File

@@ -880,38 +880,94 @@ def _venv_dependency_set_stale() -> tuple[bool, str]:
return stale, f"installed hermes-agent {installed}, checkout is {expected}" if stale else ""
# Native extensions that pin venv files once imported: if the updater holds one, Windows blocks
# REPLACE on the mapped ``.pyd`` and the sync dies with ``os error 5``. PyYAML's ``_yaml`` is in
# every CLI process, so the guard must be HONEST: fire only when the sync would actually REWRITE
# the dist, and only AFTER the code swap so a deferral leaves new code with just the install
# pending. Keys are ``sys.modules`` prefixes; values are ``(display name, PyPI dist)``.
# If the updater process itself has any of these loaded, the dependency sync below cannot rewrite the
# backing ``.pyd``/``.dll`` — Windows blocks REPLACE on a mapped image — and the update dies with ``os error
# 5`` between uninstall and reinstall, stranding the venv half-updated (#83569). ``cryptography`` is the
# canonical case: ``hermes_cli.main`` used to import it at startup while resolving external secret sources;
# ``PyYAML``'s ``_yaml`` C extension is loaded by every CLI process (config parsing). Keep this guard as
# defence-in-depth against future eager imports (new secret sources, plugins absorbed into core, refactors
# of the startup order) — but the guard must be HONEST (#86735/#86780/#86781: a preflight that fired on
# every run, before the fetch, re-bricked the exact flow it was meant to protect). Two honesty gates: 1. It
# only fires when the dependency sync would actually REWRITE the loaded distribution
# (``_dependency_sync_would_rewrite``): if the installed version already satisfies the on-disk pyproject
# pins, uv/pip will not touch the mapped ``.pyd``, so there is no lock to trip. 2. It runs AFTER the code
# swap (git pull / ZIP commit), immediately before the venv rewrite — so the on-disk pyproject is the NEW
# one (gate 1 compares against the right target) and a deferral no longer strands the user on the old
# checkout: the next launch's marker recovery completes the dependency install against the already-updated
# pyproject.
_SELF_LOCKING_NATIVE_MODULES: dict[str, tuple[str, str]] = {
"cryptography.hazmat.bindings._rust": ("cryptography (_rust.pyd)", "cryptography"),
"yaml._yaml": ("PyYAML (_yaml.pyd)", "pyyaml")}
# A native extension the updater process has mapped pins its venv file: Windows blocks REPLACE on a
# mapped ``.pyd`` and the sync dies with ``os error 5`` between uninstall and reinstall, stranding the
# venv half-updated (#83569). Which extensions are mapped is DERIVED from ``sys.modules`` (every loaded
# module whose file is an extension under site-packages), never hand-listed: the #81594 report's lock
# was ``_cffi_backend.pyd``, which the old two-entry list missed. PyYAML's ``_yaml`` is in every CLI
# process, so the guard must stay HONEST (#86735/#86780/#86781: a preflight that fired on every run
# re-bricked the flow it protects): 1. it fires only when the sync would actually REWRITE the loaded
# distribution (``_dependency_sync_would_rewrite``); 2. it runs AFTER the code swap, right before the
# venv rewrite, so the on-disk pyproject/uv.lock are the NEW ones and a deferral leaves new code with
# only the install pending for the next launch's marker recovery.
def _loaded_native_extension_dists(
modules, site_dirs, extension_suffixes, top_level_dists) -> dict[str, list[str]]:
"""``{distribution: [extension file names]}`` for loaded modules backed by an extension file
under one of *site_dirs*. Stdlib extensions (``DLLs``/``lib-dynload``) and pure-Python modules
never match; a module whose top-level name maps to no distribution is skipped."""
roots = [os.path.normcase(os.path.abspath(d)) for d in site_dirs]
suffixes = tuple(extension_suffixes)
loaded: dict[str, list[str]] = {}
for name, module in list(modules.items()):
path = getattr(module, "__file__", None)
if not isinstance(path, str) or not path.endswith(suffixes):
continue
normalized = os.path.normcase(os.path.abspath(path))
if not any(normalized.startswith(root + os.sep) for root in roots):
continue
for dist in top_level_dists.get(name.partition(".")[0], ()):
files = loaded.setdefault(dist, [])
if os.path.basename(path) not in files:
files.append(os.path.basename(path))
return loaded
def _pyproject_pin_verdict(target: str, installed: str | None, req_strings: list[str]) -> bool | None:
"""True: an applicable pin is unsatisfied (or the dist is missing); False: pinned and
satisfied; None: *target* (canonical name) has no applicable pyproject requirement."""
from packaging.requirements import Requirement
from packaging.utils import canonicalize_name
from packaging.version import Version
saw_pin = False
for req_str in req_strings:
try:
req = Requirement(req_str)
except Exception:
continue
if canonicalize_name(req.name) != target:
continue
if req.marker is not None and not req.marker.evaluate():
continue
if installed is None or Version(installed) not in req.specifier:
return True
saw_pin = True
return False if saw_pin else None
def _transitive_sync_would_rewrite(target: str, installed: str, root: Path, base_reqs: list[str]) -> bool | None:
"""An unpinned dist moves only when something requiring it moves. True when uv.lock resolves it
to a version other than the installed one AND a lock parent is being moved by a BASE
``dependencies`` pin. Only base pins count: the sync cannot skip them (a failing optional extra is
skipped by the fallback ladder and would re-defer every update, #86735), so the sync this defers
always clears the condition. None otherwise."""
from importlib import metadata as _ilmd
from packaging.utils import canonicalize_name
from packaging.version import Version
packages = tomllib.loads((root / "uv.lock").read_text(encoding="utf-8")).get("package") or []
locked = {Version(p["version"]) for p in packages
if canonicalize_name(p.get("name", "")) == target and p.get("version")}
if not locked or Version(installed) in locked:
return None
for parent in packages:
if not any(canonicalize_name(dep.get("name", "")) == target for dep in parent.get("dependencies") or ()):
continue
try:
parent_installed = _ilmd.version(parent["name"])
except Exception:
parent_installed = None
if _pyproject_pin_verdict(canonicalize_name(parent["name"]), parent_installed, base_reqs):
return True
return None
def _dependency_sync_would_rewrite(dist_name: str) -> bool | None:
"""Whether the ``.[all]`` install would replace *dist_name*'s files, judged against every
applicable pin in on-disk ``pyproject.toml`` (base + extras). False: all pins satisfied;
True: pin unsatisfied or dist missing; None: undeterminable. Never raises. Callers treat
None as fail-OPEN — PyYAML is in every process, so deferring on uncertainty always fires.
applicable pin in on-disk ``pyproject.toml`` (base + extras) and, for an unpinned transitive,
its uv.lock parents. False: all pins satisfied; True: pin unsatisfied, dist missing, or moved
by a rewritten parent; None: undeterminable. Never raises. Callers treat None as fail-OPEN —
PyYAML is in every process, so deferring on uncertainty always fires.
See #86735.
See #86735, #81594.
"""
from hermes_cli.update_cmd import _m
try:
@@ -920,34 +976,18 @@ def _dependency_sync_would_rewrite(dist_name: str) -> bool | None:
except Exception:
return True # not installed → the sync will definitely install it
try:
import tomllib
from packaging.requirements import Requirement
from packaging.utils import canonicalize_name
from packaging.version import Version
pyproject = _m().PROJECT_ROOT / "pyproject.toml"
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
project = data.get("project") or {}
req_strings: list[str] = list(project.get("dependencies") or [])
root = _m().PROJECT_ROOT
project = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8")).get("project") or {}
base_reqs: list[str] = list(project.get("dependencies") or [])
req_strings = list(base_reqs)
for extra_reqs in (project.get("optional-dependencies") or {}).values():
req_strings.extend(extra_reqs or [])
target = canonicalize_name(dist_name)
installed_v = Version(installed)
saw_pin = False
for req_str in req_strings:
try:
req = Requirement(req_str)
except Exception:
continue
if canonicalize_name(req.name) != target:
continue
if req.marker is not None and not req.marker.evaluate():
continue
saw_pin = True
if installed_v not in req.specifier:
return True
# Not pinned in pyproject: the resolver may still move it as a transitive — unknown.
return False if saw_pin else None
verdict = _pyproject_pin_verdict(target, installed, req_strings)
if verdict is not None:
return verdict
return _transitive_sync_would_rewrite(target, installed, root, base_reqs)
except Exception:
return None
@@ -965,12 +1005,20 @@ def _detect_self_loaded_native_modules() -> list[str]:
from hermes_cli.update_cmd import _m
if not _m()._is_windows():
return []
import sysconfig
from importlib import machinery, metadata
try:
loaded = _loaded_native_extension_dists(
sys.modules, {sysconfig.get_path("purelib"), sysconfig.get_path("platlib")},
machinery.EXTENSION_SUFFIXES, metadata.packages_distributions())
except Exception: # unreadable dist metadata: fail open, like an unknown rewrite below
return []
# Defer ONLY on a CONFIRMED rewrite; unknown fails OPEN (PyYAML is in every process, so
# unknown-as-at-risk always fires). A missed deferral only yields the mid-sync os error 5
# that marker recovery already handles — far less harmful than an update that never runs.
return sorted({
display for prefix, (display, dist) in _SELF_LOCKING_NATIVE_MODULES.items()
if prefix in sys.modules and _m()._dependency_sync_would_rewrite(dist) is True})
return sorted(
f"{dist} ({', '.join(files)})" for dist, files in loaded.items()
if _m()._dependency_sync_would_rewrite(dist) is True)
def _abort_dependency_sync_if_self_locked(gateway_resume=None) -> None:

View File

@@ -427,7 +427,7 @@ def test_core_marker_retry_ceiling_hands_off_to_late_recovery(
root = _project(tmp_path)
core_marker = root / ".update-incomplete"
core_marker.write_text(
f'{{"attempts": {er._EARLY_CORE_INSTALL_MAX_ATTEMPTS}}}', encoding="utf-8"
f'{{"attempts": {er._CORE_INSTALL_MAX_ATTEMPTS}}}', encoding="utf-8"
)
from hermes_cli import _install_repair as ir

View File

@@ -1,12 +1,18 @@
"""Tests for interrupted-install self-heal (the ``.update-incomplete`` marker).
Covers the breadcrumb lifecycle so a ``hermes update`` killed mid-install
(Ctrl-C, terminal close, WSL OOM) leaves a marker the next launch can act on.
(Ctrl-C, terminal close, WSL OOM) leaves a marker the next launch can act on,
and the boot recovery that finishes it (#81594: bounded, never a reinstall per boot).
"""
from __future__ import annotations
import pytest
import hermes_cli.main as m
from hermes_cli import _early_recovery as er
from hermes_cli import _install_repair as ir
from hermes_cli import main_install_repair as mir
def test_marker_round_trip(tmp_path, monkeypatch):
@@ -23,3 +29,66 @@ def test_marker_round_trip(tmp_path, monkeypatch):
m._clear_update_incomplete_marker()
assert not marker.exists()
@pytest.fixture
def pending_core_install(tmp_path, monkeypatch):
"""A source checkout whose last ``hermes update`` (owner now dead) left the core install
pending; ``installs`` records every reinstall the boot recovery starts."""
(tmp_path / "pyproject.toml").write_text('[project]\nname = "x"\n', encoding="utf-8")
marker = tmp_path / ".update-incomplete"
marker.write_text("started=1\npid=999999\n", encoding="utf-8")
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
monkeypatch.setattr(er, "_marker_owner_is_live", lambda _marker: False)
monkeypatch.setattr(mir, "_windows_running_hermes_launcher_locked", lambda: False)
monkeypatch.setattr("hermes_cli.managed_uv.ensure_uv", lambda *a, **k: None)
installs: list[str] = []
outcome = {"fail": True}
def install(root):
installs.append(str(root))
if outcome["fail"]:
raise RuntimeError("os error 5: _cffi_backend.pyd is in use")
monkeypatch.setattr(ir, "run_core_install", install)
return tmp_path, marker, installs, outcome
def _launch(root):
"""One ``hermes`` boot: the pre-import early pass, then main()'s post-import recovery."""
er.recover_if_needed(project_root=root, argv=[])
m._recover_from_interrupted_install()
def test_persistently_failing_install_stops_rerunning_every_boot(pending_core_install, capsys):
"""#81594: once the early pass stood down, the post-import path reinstalled on every
launch forever. Both paths share one attempt budget: after it, boots stop reinstalling
and print the exact recovery command instead."""
root, marker, installs, _ = pending_core_install
for _ in range(4):
_launch(root)
spent = len(installs)
capsys.readouterr()
for _ in range(4):
_launch(root)
assert len(installs) == spent, "boots past the attempt budget must not reinstall"
assert marker.exists()
output = capsys.readouterr()
shown = output.out + output.err
assert "pip install -e" in shown
assert str(marker) in shown, "the recovery command must say how to clear the marker"
def test_post_import_recovery_counts_failures_and_clears_marker_on_success(pending_core_install):
root, marker, installs, outcome = pending_core_install
m._recover_from_interrupted_install()
assert marker.exists()
assert er._read_marker_attempts(marker) == 1, "a late-path failure spends one attempt"
outcome["fail"] = False
m._recover_from_interrupted_install()
assert len(installs) == 2
assert not marker.exists(), "a successful recovery clears the marker"

View File

@@ -26,6 +26,7 @@ from __future__ import annotations
import subprocess
import sys
import textwrap
import types
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -102,12 +103,79 @@ class TestDependencySyncWouldRewrite:
):
assert cli_main._dependency_sync_would_rewrite("cryptography") is None
def test_unpinned_transitive_moves_with_its_pinned_parent(self, tmp_path):
"""#81594: ``_cffi_backend`` belongs to cffi, which no pyproject line pins; it moves
only because cryptography's base pin moved and uv.lock resolves cffi elsewhere. Once the
parent is at its pin the transitive stays put (fail-open, #86735) — even while an
optional-extra parent (brotlicffi) is missing, which a skipped extra can leave forever."""
(tmp_path / "uv.lock").write_text(
textwrap.dedent(
"""
version = 1
[[package]]
name = "brotlicffi"
version = "1.2.0.2"
dependencies = [{ name = "cffi" }]
[[package]]
name = "cffi"
version = "2.0.0"
[[package]]
name = "cryptography"
version = "50.0.0"
dependencies = [{ name = "cffi" }]
"""
),
encoding="utf-8",
)
pyproject = textwrap.dedent(
"""
[project]
name = "x"
dependencies = ["cryptography==50.0.0"]
[project.optional-dependencies]
messaging = ["brotlicffi==1.2.0.2"]
"""
)
for crypto, expected in (("49.0.0", True), ("50.0.0", None)):
installed = {"cffi": "1.17.1", "cryptography": crypto}
with self._with_pyproject(tmp_path, pyproject), patch(
"importlib.metadata.version", side_effect=installed.__getitem__
):
assert cli_main._dependency_sync_would_rewrite("cffi") is expected
# ---------------------------------------------------------------------------
# _detect_self_loaded_native_modules — version-gated detection
# ---------------------------------------------------------------------------
def _native_module(site: Path, name: str, suffix: str) -> types.ModuleType:
module = types.ModuleType(name)
module.__file__ = str(site.joinpath(*name.split(".")).with_name(name.rsplit(".", 1)[-1] + suffix))
return module
def test_native_extension_scan_derives_every_loaded_venv_extension(tmp_path):
"""#81594: the report's locked file was ``_cffi_backend.pyd``, which no hand list named.
Every loaded extension under site-packages maps to its distribution; stdlib extensions
and pure-Python modules never do."""
from hermes_cli.update_cmd_deps import _loaded_native_extension_dists
site, stdlib = tmp_path / "site-packages", tmp_path / "DLLs"
modules = {
"_cffi_backend": _native_module(site, "_cffi_backend", ".pyd"),
"yaml._yaml": _native_module(site, "yaml._yaml", ".pyd"),
"yaml": _native_module(site, "yaml", ".py"),
"_ssl": _native_module(stdlib, "_ssl", ".pyd"),
"builtin_like": types.ModuleType("builtin_like"),
}
loaded = _loaded_native_extension_dists(
modules, [site], (".pyd",),
{"_cffi_backend": ["cffi"], "yaml": ["PyYAML"], "_ssl": ["nope"]})
assert loaded == {"cffi": ["_cffi_backend.pyd"], "PyYAML": ["_yaml.pyd"]}
@pytest.mark.linux_only
def test_self_lock_detection_is_noop_off_windows():
with patch.dict(sys.modules, {"cryptography.hazmat.bindings._rust": MagicMock()}):
@@ -116,10 +184,15 @@ def test_self_lock_detection_is_noop_off_windows():
@pytest.mark.windows_only
def test_loaded_module_with_pending_version_change_is_flagged():
import sysconfig
site = Path(sysconfig.get_path("purelib"))
with patch.dict(
sys.modules, {"cryptography.hazmat.bindings._rust": MagicMock()}
sys.modules, {"_cffi_backend": _native_module(site, "_cffi_backend", ".pyd")}
), patch(
"importlib.metadata.packages_distributions", return_value={"_cffi_backend": ["cffi"]}
), patch.object(cli_main, "_dependency_sync_would_rewrite", return_value=True):
assert "cryptography (_rust.pyd)" in cli_main._detect_self_loaded_native_modules()
assert "cffi (_cffi_backend.pyd)" in cli_main._detect_self_loaded_native_modules()
@pytest.mark.windows_only
@@ -248,16 +321,14 @@ class TestUpdateEntrypointImportHygiene:
"""
import sys
import hermes_cli.main
from hermes_cli.update_cmd import _SELF_LOCKING_NATIVE_MODULES
loaded = [
p for p in _SELF_LOCKING_NATIVE_MODULES
if p in sys.modules and not p.startswith("yaml")
]
# PyYAML is a base dep every CLI process needs (config parsing);
# it is version-gated instead of import-gated. Everything else
# in the registry must stay lazy.
# it is version-gated instead of import-gated. The crypto stack
# (cryptography's _rust.pyd and cffi's _cffi_backend.pyd) must stay lazy.
loaded = [
m for m in ("cryptography.hazmat.bindings._rust", "_cffi_backend")
if m in sys.modules
]
assert not loaded, f"eagerly loaded self-locking modules: {loaded}"
assert "cryptography.hazmat.bindings._rust" not in sys.modules
print("OK")
"""
)