fix(bootstrap): start the PM tree from a venv editable-installed before pm existed

An install editable-built from main maps only the top-level packages it
saw then (setuptools' flat-layout finder: no `pm`) and never puts the
checkout on sys.path. After moving that checkout to this tree, `hermes`
died in hermes_cli/__init__.py: `__version__` was evaluated at import
through pm.paths, before anything had put the root on sys.path.

Two layers of the same break:

- hermes_cli.__version__ is served lazily (PEP 562). Shipped updaters
  still get it from `from hermes_cli import __version__`; importing the
  package no longer needs pm or reads the stamp.
- hermes_bootstrap hardens the import path before its first Hermes
  import, not only on the legacy post-swap branch. Without that, its
  `from pm.environments import ...` raised ModuleNotFoundError, which
  hermes_cli.main swallows, so the launch silently skipped
  prepare_launch: no blessed-checkout adoption and no PM sync, and the
  tree ran on main's dependencies until it hit ruamel.

Reproduced with a real main editable venv swapped to this tree: exact
user traceback before; after, a stamp-less blessed checkout adopts, PM
syncs, relaunches, and the command answers. The new test drives both
imports through a pre-PM style finder and is red with either half
reverted.
This commit is contained in:
ethernet
2026-09-23 14:09:21 -04:00
parent e3a53a9373
commit e7853c1260
3 changed files with 53 additions and 7 deletions

View File

@@ -349,12 +349,16 @@ def _legacy_post_swap_invocation(argv: list[str]) -> tuple[Path, list[str]] | No
return Path(argv[marker + 1]), argv[1:marker]
# Everything below imports Hermes packages, so the root goes on sys.path first. A venv
# editable-installed from a pre-PM tree maps only the top-level packages it knew then:
# without this, ``pm`` is unimportable and the launch silently skips PM adoption.
harden_import_path(str(_root))
_legacy_post_swap = _legacy_post_swap_invocation(sys.argv[1:])
if _legacy_post_swap is not None:
# This continuation exists precisely because the replacement tree may not
# run under the old release's dependency graph. Take it over before PM
# activation, launch preparation, or argparse imports any of that graph.
harden_import_path(str(_root))
from hermes_cli.update_handoff import _continue_legacy_post_swap
_handoff_path, _argv_tail = _legacy_post_swap

View File

@@ -4,25 +4,31 @@ import os
import sys
__release_date__ = "2026.9.21"
# Declared for type checkers and the old-updater surface audit; served lazily by __getattr__.
__version__: str
def _stamped_version() -> str:
def __getattr__(name: str) -> str:
"""Old-updater compat: shipped updaters import ``__version__`` after the checkout swap.
tests/compat/old_updater_surface.json freezes that import. In-tree code resolves
identity through hermes_cli.version_info.get_version_info(); this reads only the
install stamp -- never git, since it runs on every import -- and keeps the
pre-stamp placeholder when a checkout has no stamp.
install stamp -- never git -- and keeps the pre-stamp placeholder when a checkout
has no stamp.
Lazy because ``pm`` is not importable when this package loads: a venv
editable-installed from a pre-PM tree maps only the top-level packages it knew
at install time, and the repo root reaches ``sys.path`` only once
``hermes_bootstrap`` runs -- after this ``__init__``, from ``hermes_cli.main``.
"""
if name != "__version__":
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
from hermes_cli.steward import read_install_stamp
from pm.paths import repo_root
return str(read_install_stamp(repo_root()).get("baseVersion") or "0.0.0")
__version__ = _stamped_version()
def _ensure_utf8():
"""Force UTF-8 stdout/stderr to prevent UnicodeEncodeError crashes.

View File

@@ -229,6 +229,42 @@ else:
assert result.stdout.strip() == "bootstrap-before-app"
def test_pre_pm_editable_venv_reaches_pm_through_the_bootstrap(tmp_path):
"""A venv editable-installed from a pre-PM tree must still start the PM-era tree.
setuptools' flat-layout editable finder maps only the top-level names it saw at
install time (no ``pm``) and never puts the checkout on ``sys.path``. The console
script imports ``hermes_cli`` first, then ``hermes_cli.main`` imports the bootstrap;
both must load, and the bootstrap must reach ``pm``, or PM adoption never runs.
"""
root = Path(__file__).resolve().parents[1]
program = r"""
import importlib.util, os, sys
from importlib.abc import MetaPathFinder
root = sys.argv[1]
class PrePMEditableFinder(MetaPathFinder):
def find_spec(self, name, path=None, target=None):
if name == 'hermes_cli':
pkg = os.path.join(root, 'hermes_cli')
return importlib.util.spec_from_file_location(
name, os.path.join(pkg, '__init__.py'), submodule_search_locations=[pkg])
if name == 'hermes_bootstrap':
return importlib.util.spec_from_file_location(name, os.path.join(root, 'hermes_bootstrap.py'))
return None
sys.meta_path.append(PrePMEditableFinder())
sys.argv = ['hermes', 'pm', 'repair']
import hermes_cli
import hermes_bootstrap
assert hermes_bootstrap._pm_repair is True
print('reached-pm')
"""
result = subprocess.run([sys.executable, "-I", "-S", "-c", program, str(root)],
cwd=tmp_path, env={**os.environ, "HERMES_HOME": str(tmp_path / "home")},
capture_output=True, text=True, timeout=30)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "reached-pm"
class TestHardenImportPath:
"""harden_import_path() must keep a same-named package in the launch
directory from shadowing Hermes's own top-level modules — covering both