From 1b20ffa7387e71983c8dd09eba6a3359fe864911 Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Tue, 7 Jul 2026 01:17:13 +0200 Subject: [PATCH 1/3] 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> --- src/specify_cli/integrations/base.py | 31 +++++++++++-- tests/integrations/test_base.py | 66 ++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 37d3cdf965..153cbf2ed6 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -17,6 +17,7 @@ import re import shlex import shutil +import subprocess import sys from abc import ABC from dataclasses import dataclass @@ -576,10 +577,35 @@ def resolve_python_interpreter(project_root: Path | None = None) -> str: 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.""" + try: + return ( + subprocess.run( + [path, "-c", ""], + capture_output=True, + timeout=15, + ).returncode + == 0 + ) + except (OSError, subprocess.SubprocessError): + return False + @staticmethod def process_template( content: str, @@ -1059,7 +1085,6 @@ def setup( # 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 fe531e6245..a450418846 100644 --- a/tests/integrations/test_base.py +++ b/tests/integrations/test_base.py @@ -375,6 +375,72 @@ def test_ignores_missing_venv(self, monkeypatch, tmp_path): ) 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 = ( From 5ddcbf198126615daa5f20ff96ffc035f70bf8cb Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Tue, 7 Jul 2026 01:24:31 +0200 Subject: [PATCH 2/3] fix(integrations): probe interpreter isolated and without site, discard I/O Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/integrations/base.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 153cbf2ed6..eda094209c 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -593,12 +593,19 @@ def resolve_python_interpreter(project_root: Path | None = None) -> str: @staticmethod def _interpreter_runs(path: str) -> bool: - """Return True when *path* executes as a Python interpreter.""" + """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, "-c", ""], - capture_output=True, + [path, "-I", "-S", "-c", ""], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, timeout=15, ).returncode == 0 From 9704f4931ec5d5f7e7a2572cbafaf690ff0aa42d Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Tue, 7 Jul 2026 23:24:04 +0200 Subject: [PATCH 3/3] 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> --- tests/integrations/test_base.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/integrations/test_base.py b/tests/integrations/test_base.py index a450418846..d03ea0cb25 100644 --- a/tests/integrations/test_base.py +++ b/tests/integrations/test_base.py @@ -306,9 +306,12 @@ def test_placeholder_with_digits(self): 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 @@ def test_falls_back_to_python_when_no_python3(self, monkeypatch): 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,6 +373,7 @@ def test_prefers_project_venv_windows(self, monkeypatch, tmp_path): 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, @@ -456,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,