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:
12
pm/_marker_eval.py
Normal file
12
pm/_marker_eval.py
Normal 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")
|
||||
31
pm/extras.py
31
pm/extras.py
@@ -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]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user