From 23a170d8eefe7c809d57c1a2dda344c47b128778 Mon Sep 17 00:00:00 2001 From: jawwad-ali Date: Sat, 15 Aug 2026 22:03:43 +0500 Subject: [PATCH] fix(powershell): probe python3 before selecting it in Get-Python3Command Get-Python3Command's first branch returned @('python3') on mere Get-Command presence, with NO execution probe -- while its own second and third branches (python, py -3) DO probe with --version and match 'Python 3'. Its docstring promises "a usable Python 3 executable". On Windows, python3 almost always resolves to the Microsoft Store App Execution Alias stub, which Get-Command finds but which fails at runtime: found=True source=C:\...\AppData\Local\Microsoft\WindowsApps\python3.exe ver=[Python was not found; run without arguments to install from the Microsoft Store...] LASTEXITCODE=9009 match=False This is the same hazard scripts/bash/common.sh documents by name (issue #3304) and defends against: its _python3_command probes all three candidates, not just the last two. Second half of the same root cause: the existing probes use `& python --version 2>&1`. In Windows PowerShell, redirecting a native command's stderr into the success stream wraps each line in an ErrorRecord, so under the `$ErrorActionPreference = 'Stop'` every caller sets, the probe raised a terminating NativeCommandError instead of simply failing the match. Verified against upstream/main with shimmed interpreters on PATH: python3 = dead stub, python = working -> RESULT=[python3] (dead) python = dead stub, nothing else -> THREW: RemoteException With the fix: python3 = dead stub, python = working -> RESULT=[python] python = dead stub, nothing else -> RESULT=[] python3 + python dead, py -3 working -> RESULT=[py -3] Co-Authored-By: Claude Opus 5 (1M context) --- scripts/powershell/common.ps1 | 45 ++++++-- tests/test_resolve_template_python_parity.py | 102 +++++++++++++++++++ 2 files changed, 138 insertions(+), 9 deletions(-) diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index 585e884702..cb5e45e8b7 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -317,18 +317,45 @@ function Format-SpecKitCommand { return "/speckit$separator$name" } +# Probe a candidate interpreter by running it, returning $true only when it +# really is a Python 3. Selection must be by execution success, not by mere +# availability: on Windows 'python3' (and often 'python') resolves to the +# Microsoft Store App Execution Alias stub, which Get-Command finds but which +# fails at runtime -- the same hazard scripts/bash/common.sh documents and +# defends against in _python3_command. +# +# The probe is deliberately non-throwing. Callers set +# $ErrorActionPreference = 'Stop', and in Windows PowerShell redirecting a +# native command's stderr into the success stream wraps each line in an +# ErrorRecord, so '& python --version 2>&1' raised a terminating +# NativeCommandError against the stub rather than simply failing the match. +function Test-Python3Command { + param( + [Parameter(Mandatory = $true)][string]$Executable, + [string[]]$Arguments = @() + ) + + $previousPreference = $ErrorActionPreference + $ErrorActionPreference = 'SilentlyContinue' + try { + $versionOutput = & $Executable @Arguments --version 2>&1 + return (($versionOutput -join ' ') -match 'Python 3') + } catch { + return $false + } finally { + $ErrorActionPreference = $previousPreference + } +} + # Find a usable Python 3 executable (python3, python, or py -3). # Returns the command/arguments as an array, or $null if none found. function Get-Python3Command { - if (Get-Command python3 -ErrorAction SilentlyContinue) { return @('python3') } - if (Get-Command python -ErrorAction SilentlyContinue) { - $ver = & python --version 2>&1 - if ($ver -match 'Python 3') { return @('python') } - } - if (Get-Command py -ErrorAction SilentlyContinue) { - $ver = & py -3 --version 2>&1 - if ($ver -match 'Python 3') { return @('py', '-3') } - } + if ((Get-Command python3 -ErrorAction SilentlyContinue) -and + (Test-Python3Command -Executable 'python3')) { return @('python3') } + if ((Get-Command python -ErrorAction SilentlyContinue) -and + (Test-Python3Command -Executable 'python')) { return @('python') } + if ((Get-Command py -ErrorAction SilentlyContinue) -and + (Test-Python3Command -Executable 'py' -Arguments @('-3'))) { return @('py', '-3') } return $null } diff --git a/tests/test_resolve_template_python_parity.py b/tests/test_resolve_template_python_parity.py index 9af5554b44..dbf7d81adb 100644 --- a/tests/test_resolve_template_python_parity.py +++ b/tests/test_resolve_template_python_parity.py @@ -4,6 +4,8 @@ import json import os +import re +import subprocess from pathlib import Path import pytest @@ -11,6 +13,8 @@ from tests.conftest import requires_bash from tests.parity_helpers import ( HAS_POWERSHELL, + POWERSHELL_EXE, + PROJECT_ROOT, bash_cmd, clean_env, install_composition_stack, @@ -751,3 +755,101 @@ def test_all_variants_fail_for_malformed_preset_manifest( assert all(result.returncode != 0 for result in results) assert all(result.stdout == "" for result in results) + + +# -- Get-Python3Command interpreter selection ----------------------------- + +_STORE_ALIAS_STUB = ( + "@echo off\r\n" + "echo Python was not found; run without arguments to install from the " + "Microsoft Store. 1>&2\r\n" + "exit /b 9009\r\n" +) +_WORKING_PYTHON3 = "@echo off\r\necho Python 3.12.0\r\nexit /b 0\r\n" +_WORKING_PY_LAUNCHER = ( + "@echo off\r\n" + 'if "%1"=="-3" (echo Python 3.12.0 & exit /b 0)\r\n' + "exit /b 1\r\n" +) + + +def _run_get_python3_command(tmp_path: Path, shims: dict[str, str]) -> str: + """Dot-source common.ps1 with a PATH of *shims* and report the selection. + + Returns the selected command joined by spaces, ``""`` when nothing usable + was found, or ``THREW: `` if the call raised. Callers run with + ``$ErrorActionPreference = 'Stop'``, so this mirrors real usage. + """ + shim_dir = tmp_path / "shims" + shim_dir.mkdir() + for name, body in shims.items(): + (shim_dir / f"{name}.cmd").write_text(body, encoding="ascii") + + common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1" + driver = tmp_path / "probe.ps1" + driver.write_text( + "$ErrorActionPreference = 'Stop'\r\n" + f"$env:PATH = '{shim_dir}'\r\n" + f". '{common_ps}'\r\n" + "try { $r = Get-Python3Command; 'RESULT=[' + ($r -join ' ') + ']' }\r\n" + "catch { 'RESULT=[THREW: ' + $_.CategoryInfo.Reason + ']' }\r\n", + encoding="ascii", + ) + + result = subprocess.run( + [POWERSHELL_EXE, "-NoProfile", "-File", str(driver)], + capture_output=True, + text=True, + check=False, + env=clean_env(), + ) + match = re.search(r"RESULT=\[(.*)\]", result.stdout) + assert match, f"stdout={result.stdout!r} stderr={result.stderr!r}" + return match.group(1) + + +@pytest.mark.skipif(not HAS_POWERSHELL, reason="PowerShell not available") +def test_get_python3_command_skips_unusable_python3(tmp_path: Path) -> None: + """A 'python3' that Get-Command finds but that fails to run must be skipped. + + On Windows 'python3' commonly resolves to the Microsoft Store App Execution + Alias stub. The first branch used to return @('python3') on mere + Get-Command presence, with no execution probe -- unlike its own second and + third branches -- so callers invoked the dead stub instead of falling + through to a working interpreter. + """ + selected = _run_get_python3_command( + tmp_path, + {"python3": _STORE_ALIAS_STUB, "python": _WORKING_PYTHON3}, + ) + assert selected == "python" + + +@pytest.mark.skipif(not HAS_POWERSHELL, reason="PowerShell not available") +def test_get_python3_command_probe_does_not_throw_under_stop( + tmp_path: Path, +) -> None: + """Probing must fail the match, not raise, when a candidate writes stderr. + + Callers set $ErrorActionPreference = 'Stop', and redirecting a native + command's stderr into the success stream wraps each line in an ErrorRecord, + so the probe raised a terminating NativeCommandError instead of moving on. + """ + selected = _run_get_python3_command(tmp_path, {"python": _STORE_ALIAS_STUB}) + assert selected == "" + + +@pytest.mark.skipif(not HAS_POWERSHELL, reason="PowerShell not available") +def test_get_python3_command_falls_through_to_py_launcher( + tmp_path: Path, +) -> None: + """A working 'py -3' must still be reached past two unusable candidates.""" + selected = _run_get_python3_command( + tmp_path, + { + "python3": _STORE_ALIAS_STUB, + "python": _STORE_ALIAS_STUB, + "py": _WORKING_PY_LAUNCHER, + }, + ) + assert selected == "py -3"