diff --git a/hermes_bootstrap.py b/hermes_bootstrap.py index 01d69f3197..5d86a17b9b 100644 --- a/hermes_bootstrap.py +++ b/hermes_bootstrap.py @@ -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 diff --git a/hermes_cli/__init__.py b/hermes_cli/__init__.py index 62e92c6f3f..876efa9de8 100644 --- a/hermes_cli/__init__.py +++ b/hermes_cli/__init__.py @@ -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. diff --git a/tests/test_hermes_bootstrap.py b/tests/test_hermes_bootstrap.py index 137b10ae21..d2debacf9d 100644 --- a/tests/test_hermes_bootstrap.py +++ b/tests/test_hermes_bootstrap.py @@ -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