From 15ac745e8d7034bea1a95fe56b461a7e6952d88a Mon Sep 17 00:00:00 2001 From: Marsel Safin <179933638+marcelsafin@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:14:29 +0200 Subject: [PATCH] fix(integrations): skip Windows Store python3 alias stub in resolve_python_interpreter (#3385) * fix(integrations): skip Windows Store python3 alias stub in resolve_python_interpreter On stock Windows, python3 on PATH is the Microsoft Store App Execution Alias stub: it exists but only prints an installer hint and exits non-zero, so generated {SCRIPT} invocations for the py script type were broken. Verify the found interpreter actually runs before accepting it, on Windows only, mirroring the parse-success-not-availability approach of #3312/#3320 for the sh scripts. POSIX keeps the plain existence check. sys.executable remains the fallback and is always live. Fixes #3383 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(integrations): probe interpreter isolated and without site, discard I/O Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: pin POSIX platform in PATH-resolution tests The tests fake shutil.which with POSIX paths; on Windows CI the real sys.platform made the stub probe run against those fake paths and fall through to sys.executable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/integrations/base.py | 38 +++++++++++++-- tests/integrations/test_base.py | 72 ++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 8cc318064..bfbb81b85 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -17,6 +17,7 @@ import os import re import shlex import shutil +import subprocess import sys from abc import ABC from dataclasses import dataclass @@ -604,10 +605,42 @@ class IntegrationBase(ABC): if candidate.exists(): return relative for name in ("python3", "python"): - if shutil.which(name): - return name + found = shutil.which(name) + if not found: + continue + # On Windows, python3/python on PATH may be the Microsoft + # Store App Execution Alias stub: it exists but only prints + # an installer hint and exits non-zero, so existence is not + # enough (see #3304 for the same defect in the sh scripts). + if sys.platform == "win32" and not IntegrationBase._interpreter_runs( + found + ): + continue + return name return sys.executable or "python3" + @staticmethod + def _interpreter_runs(path: str) -> bool: + """Return True when *path* executes as a Python interpreter. + + Runs isolated (``-I``) without ``site`` (``-S``) and discards + I/O so the probe is a fast liveness check that cannot trigger + ``sitecustomize``/user startup hooks. + """ + try: + return ( + subprocess.run( + [path, "-I", "-S", "-c", ""], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=15, + ).returncode + == 0 + ) + except (OSError, subprocess.SubprocessError): + return False + @staticmethod def process_template( content: str, @@ -1089,7 +1122,6 @@ class TomlIntegration(IntegrationBase): # YamlIntegration — YAML-format agents (Goose) # --------------------------------------------------------------------------- - class YamlIntegration(IntegrationBase): """Concrete base for integrations that use YAML recipe format. diff --git a/tests/integrations/test_base.py b/tests/integrations/test_base.py index fe531e624..d03ea0cb2 100644 --- a/tests/integrations/test_base.py +++ b/tests/integrations/test_base.py @@ -306,9 +306,12 @@ class TestResolveCommandRefs: class TestResolvePythonInterpreter: def test_returns_python_on_path(self, monkeypatch): # Positive: when python3 is on PATH it is preferred over python. + # Pin a POSIX platform so the Windows stub probe (tested separately + # below) does not reject the fake PATH entries on Windows CI. def fake_which(name): return f"/usr/bin/{name}" if name in ("python3", "python") else None + monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "linux") monkeypatch.setattr( "specify_cli.integrations.base.shutil.which", fake_which ) @@ -318,6 +321,7 @@ class TestResolvePythonInterpreter: def fake_which(name): return "/usr/bin/python" if name == "python" else None + monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "linux") monkeypatch.setattr( "specify_cli.integrations.base.shutil.which", fake_which ) @@ -369,12 +373,79 @@ class TestResolvePythonInterpreter: def test_ignores_missing_venv(self, monkeypatch, tmp_path): # Negative: no venv directory -> PATH resolution is used instead. + monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "linux") monkeypatch.setattr( "specify_cli.integrations.base.shutil.which", lambda name: "/usr/bin/python3" if name == "python3" else None, ) assert IntegrationBase.resolve_python_interpreter(tmp_path) == "python3" + def test_windows_skips_store_alias_stub(self, monkeypatch): + # On Windows, python3 on PATH may be the Microsoft Store App + # Execution Alias stub: it exists but only prints an installer + # hint and exits non-zero. Existence is not enough; the + # interpreter must actually run (mirrors #3304 for the CLI). + monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "win32") + monkeypatch.setattr( + "specify_cli.integrations.base.shutil.which", + lambda name: f"C:\\WindowsApps\\{name}.exe" + if name in ("python3", "python") + else None, + ) + monkeypatch.setattr( + IntegrationBase, "_interpreter_runs", staticmethod(lambda path: False) + ) + monkeypatch.setattr( + "specify_cli.integrations.base.sys.executable", "C:\\Python\\python.exe" + ) + result = IntegrationBase.resolve_python_interpreter() + assert result == "C:\\Python\\python.exe" + + def test_windows_keeps_working_interpreter(self, monkeypatch): + # Positive: a real python3 on Windows PATH passes the run check. + monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "win32") + monkeypatch.setattr( + "specify_cli.integrations.base.shutil.which", + lambda name: f"C:\\Python\\{name}.exe" if name == "python3" else None, + ) + monkeypatch.setattr( + IntegrationBase, "_interpreter_runs", staticmethod(lambda path: True) + ) + assert IntegrationBase.resolve_python_interpreter() == "python3" + + def test_windows_stub_python3_falls_through_to_working_python(self, monkeypatch): + # python3 is the stub but python is a real install: pick python. + monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "win32") + monkeypatch.setattr( + "specify_cli.integrations.base.shutil.which", + lambda name: f"C:\\somewhere\\{name}.exe" + if name in ("python3", "python") + else None, + ) + monkeypatch.setattr( + IntegrationBase, + "_interpreter_runs", + staticmethod(lambda path: path.endswith("python.exe")), + ) + assert IntegrationBase.resolve_python_interpreter() == "python" + + def test_posix_does_not_spawn_run_check(self, monkeypatch): + # Non-Windows platforms have no App Execution Alias; existence + # on PATH stays sufficient and no subprocess is spawned. + monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "linux") + monkeypatch.setattr( + "specify_cli.integrations.base.shutil.which", + lambda name: "/usr/bin/python3" if name == "python3" else None, + ) + + def boom(path): + raise AssertionError("run check must not execute on POSIX") + + monkeypatch.setattr( + IntegrationBase, "_interpreter_runs", staticmethod(boom) + ) + assert IntegrationBase.resolve_python_interpreter() == "python3" + class TestProcessTemplatePyScriptType: CONTENT = ( @@ -390,6 +461,7 @@ class TestProcessTemplatePyScriptType: def test_py_prefixes_interpreter(self, monkeypatch): # Positive: py script type prefixes a resolved interpreter and the # script path is rewritten to the .specify location. + monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "linux") monkeypatch.setattr( "specify_cli.integrations.base.shutil.which", lambda name: "/usr/bin/python3" if name == "python3" else None,