fix(pm): judge extra platform gates without packaging in the caller

The historical update takeover runs PM in the OLD venv's interpreter, which
need not ship `packaging`. Carrying lazily installed extras into the first PM
generation made legacy_selection() evaluate every platform gate there, so
every update from an old release failed with "No module named 'packaging'".

When `packaging` is missing, evaluate the marker in PM's runtime (which owns
it), and only judge gates for extras the old venv actually carried. The
takeover fixture now carries a gated extra under -S, reproducing the failure.
This commit is contained in:
ethernet
2026-09-24 15:13:18 -04:00
parent 3b6bafefdf
commit ab456786a1
4 changed files with 61 additions and 5 deletions

12
pm/_marker_eval.py Normal file
View File

@@ -0,0 +1,12 @@
"""Evaluate one PEP 508 marker inside PM's runtime: `_marker_eval.py MARKER ENV_JSON`.
Prints "1" or "0". Callers whose interpreter has no `packaging` (a historical
takeover running under an old venv) delegate here; the PM runtime owns it.
"""
import json
import sys
from packaging.markers import Marker
print("1" if Marker(sys.argv[1]).evaluate(environment=json.loads(sys.argv[2])) else "0")

View File

@@ -142,8 +142,6 @@ def extra_supported(extra: str, *, environment: dict[str, str] | None = None,
import platform
import sys
from packaging.markers import Marker
if environment is None:
environment = {
"sys_platform": sys.platform,
@@ -151,6 +149,12 @@ def extra_supported(extra: str, *, environment: dict[str, str] | None = None,
"platform_machine": platform.machine(),
"os_name": os.name,
}
try:
from packaging.markers import Marker
except ImportError:
# The historical update takeover runs PM in the OLD venv's
# interpreter, which need not ship `packaging`. PM's runtime does.
return _evaluate_in_runtime(marker, environment)
try:
return bool(Marker(marker).evaluate(environment=environment))
except Exception:
@@ -159,6 +163,24 @@ def extra_supported(extra: str, *, environment: dict[str, str] | None = None,
return True
def _evaluate_in_runtime(marker: str, environment: dict[str, str]) -> bool:
import json
import subprocess
from pm.runtime import runtime_command, runtime_environment
try:
command = runtime_command(Path(__file__).with_name("_marker_eval.py"),
[marker, json.dumps(environment)])
result = subprocess.run(command, env=runtime_environment(), capture_output=True,
text=True, timeout=60)
except Exception:
return True # same fallback as a malformed marker: the resolver decides
if result.returncode != 0:
return True
return result.stdout.strip() == "1"
def install_hint(extra: str) -> str:
"""The one command users are told to run for a missing extra."""
return f"hermes pm install --extra {extra}"
@@ -269,7 +291,8 @@ def legacy_selection(project_root: Path) -> list[str]:
# would install every sibling the user never chose.
extra for extra in ANCHORS if extra not in {"messaging", "voice", "wake"}
# PM refuses a gated extra outside its platform even if a hand-synced venv carried it.
if extra_supported(extra, importable=lambda _anchor: False)
and any(all(_installed_in(tree, anchor) for anchor in _anchors(extra)) for tree in trees)
# Installed first: judging a gate may cost a PM-runtime subprocess.
if any(all(_installed_in(tree, anchor) for anchor in _anchors(extra)) for tree in trees)
and extra_supported(extra, importable=lambda _anchor: False)
)
return ["all", *carried]

View File

@@ -238,3 +238,16 @@ def test_legacy_selection_carries_extras_the_main_era_venv_lazily_installed(monk
assert "messaging" not in selection
assert "piper" not in selection
assert extras.legacy_selection(tmp_path / "no-venv") == ["all"]
def test_runtime_marker_evaluation_answers_for_the_given_environment():
"""The delegate really evaluates the marker (in PM's runtime interpreter)."""
import subprocess
from pathlib import Path
helper = Path(extras.__file__).with_name("_marker_eval.py")
env = '{"sys_platform": "linux"}'
out = [subprocess.run([sys.executable, str(helper), marker, env], capture_output=True,
text=True, timeout=60, check=True).stdout.strip()
for marker in ("sys_platform == 'linux'", "sys_platform == 'win32'")]
assert out == ["1", "0"]

View File

@@ -30,7 +30,8 @@ def test_fresh_takeover_prepares_generation_and_runs_selected_python(tmp_path):
_wheel(wheels, "takeover_dep", "1.0")
(root / "pyproject.toml").write_text(
'[project]\nname="takeover-fixture"\nversion="1"\nrequires-python=">=3.14"\n'
'dependencies=["takeover-dep==1.0"]\n[project.optional-dependencies]\nall=[]\n'
'dependencies=["takeover-dep==1.0"]\n[project.optional-dependencies]\nall=[]\nmatrix=[]\n'
'[tool.hermes.extras-platforms]\nmatrix="sys_platform == \'no-such-platform\'"\n'
'[tool.uv]\npackage=false\nno-index=true\n'
f'find-links=[{json.dumps(wheels.as_posix())}]\n', encoding="utf-8")
(root / ".git").mkdir()
@@ -41,6 +42,11 @@ def test_fresh_takeover_prepares_generation_and_runs_selected_python(tmp_path):
if not key.startswith(("HERMES_", "PYTHON", "UV_"))}
env.update(HOME=str(home), HERMES_HOME=str(home), HERMES_RUNTIME_DIR=str(store), UV_PYTHON_DOWNLOADS="never")
subprocess.run([uv, "lock", "--offline", "--python", sys.executable], cwd=root, env=env, check=True, capture_output=True)
# A main-era venv that carried a gated extra: the takeover interpreter
# (-S, so no `packaging`) must still judge the gate and drop it.
legacy_site = root / ".venv" / "lib" / "python3.11" / "site-packages"
for anchor in ("mautrix", "asyncpg", "aiosqlite", "markdown", "aiohttp_socks"):
(legacy_site / anchor).mkdir(parents=True)
# Only the interpreter and private manager tool are needed by this tiny
# application. They are real host tools; PM's worker and resolver stay real.
(root / "pm/lock.json").write_text('{"schema":1,"packages":{}}', encoding="utf-8")
@@ -82,6 +88,8 @@ def test_fresh_takeover_prepares_generation_and_runs_selected_python(tmp_path):
assert (root / ".hermes/bin/hermes").is_file()
assert not (root / "venv").exists()
assert Facts(store / "facts.json").get("dmgbuild") is None
venv_fact = json.loads(next((home / "installs").glob("*/facts.json")).read_text())["packages"]["venv"]
assert "matrix" not in venv_fact["extras"]
# Repair restores the recorded graph; a checkout update must also advance
# that graph to the new source inputs before normal bootstrap checks it.