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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user