diff --git a/pm/_marker_eval.py b/pm/_marker_eval.py new file mode 100644 index 0000000000..c64021178c --- /dev/null +++ b/pm/_marker_eval.py @@ -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") diff --git a/pm/extras.py b/pm/extras.py index f123831e39..f890086a71 100644 --- a/pm/extras.py +++ b/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] diff --git a/tests/pm/test_extras.py b/tests/pm/test_extras.py index 67289a1ee8..727583094a 100644 --- a/tests/pm/test_extras.py +++ b/tests/pm/test_extras.py @@ -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"] diff --git a/tests/pm/test_takeover_preparation.py b/tests/pm/test_takeover_preparation.py index 14371ff311..7cfaaac14e 100644 --- a/tests/pm/test_takeover_preparation.py +++ b/tests/pm/test_takeover_preparation.py @@ -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.