diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index c90308ef9c..cf186ecdfd 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -324,6 +324,44 @@ 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 + # Capture the exit status IMMEDIATELY, before any other statement can + # disturb it. Selection is by execution *success*, so a non-zero status + # disqualifies the candidate no matter what it printed: a broken wrapper + # that echoes its requested version and then exits non-zero would + # otherwise be selected here and fail at the point of use. The bash twin + # (_python3_command in scripts/bash/common.sh) gates purely on exit + # status, so requiring it here keeps the two in step. + $exitCode = $LASTEXITCODE + return (($exitCode -eq 0) -and (($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 { @@ -337,15 +375,12 @@ function Get-Python3Command { if ($LASTEXITCODE -eq 0) { return @($override) } } } - 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 7e5f3a2088..e96ab4e30b 100644 --- a/tests/test_resolve_template_python_parity.py +++ b/tests/test_resolve_template_python_parity.py @@ -4,6 +4,7 @@ import json import os +import re import subprocess import sys import time @@ -15,6 +16,8 @@ from tests.conftest import requires_bash from tests.parity_helpers import ( HAS_POWERSHELL, + PROJECT_ROOT, + POWERSHELL_EXE, bash_cmd, clean_env, install_composition_stack, @@ -1508,3 +1511,174 @@ 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 ----------------------------- + +# Fake interpreters, in both shim forms. ``HAS_POWERSHELL`` is true whenever +# ``pwsh`` is on PATH — including on Linux and macOS — where a ``.cmd`` file is +# not executable and would never resolve from PATH. So each shim is written as +# an executable shebang script (the form PowerShell resolves on POSIX) plus a +# ``.cmd`` on Windows, mirroring ``tests/extensions/test_extension_agent_context.py``. +# +# The shebang is the absolute ``#!/bin/sh``, NOT ``#!/usr/bin/env sh``: the +# probe driver replaces PATH with the shim directory alone, so ``env`` cannot +# find ``sh`` and every shim exits 127 -- which made all three selection tests +# fail on macOS CI while passing on Windows, where the ``.cmd`` path is used. +_STORE_ALIAS_STUB = { + "cmd": ( + "@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" + ), + "sh": ( + "#!/bin/sh\n" + "echo 'Python was not found; run without arguments to install from the " + "Microsoft Store.' >&2\n" + "exit 9009\n" + ), +} +_WORKING_PYTHON3 = { + "cmd": "@echo off\r\necho Python 3.12.0\r\nexit /b 0\r\n", + "sh": "#!/bin/sh\necho 'Python 3.12.0'\nexit 0\n", +} +_WORKING_PY_LAUNCHER = { + "cmd": ( + "@echo off\r\n" + 'if "%1"=="-3" (echo Python 3.12.0 & exit /b 0)\r\n' + "exit /b 1\r\n" + ), + "sh": ( + "#!/bin/sh\n" + "if [ \"$1\" = \"-3\" ]; then echo 'Python 3.12.0'; exit 0; fi\n" + "exit 1\n" + ), +} +# Prints a perfectly valid version banner and then fails. Only an exit-status +# check can reject it. +_LYING_WRAPPER = { + "cmd": "@echo off\r\necho Python 3.12.0\r\nexit /b 1\r\n", + "sh": "#!/bin/sh\necho 'Python 3.12.0'\nexit 1\n", +} + + +def _run_get_python3_command(tmp_path: Path, shims: dict[str, 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, spec in shims.items(): + posix_shim = shim_dir / name + posix_shim.write_text(spec["sh"], encoding="ascii", newline="\n") + posix_shim.chmod(0o755) + if os.name == "nt": + (shim_dir / f"{name}.cmd").write_text(spec["cmd"], encoding="ascii") + + # Both paths land inside single-quoted PowerShell literals, where a ``'`` + # (e.g. a ``C:\Users\O'Brien`` profile) must be doubled to ``''``. + shim_dir_ps = str(shim_dir).replace("'", "''") + common_ps = str(PROJECT_ROOT / "scripts" / "powershell" / "common.ps1").replace( + "'", "''" + ) + driver = tmp_path / "probe.ps1" + driver.write_text( + "$ErrorActionPreference = 'Stop'\r\n" + f"$env:PATH = '{shim_dir_ps}'\r\n" + f". '{common_ps}'\r\n" + "try { $r = Get-Python3Command; 'RESULT=[' + ($r -join ' ') + ']' }\r\n" + "catch { 'RESULT=[THREW: ' + $_.CategoryInfo.Reason + ']' }\r\n", + # UTF-8 *with* a BOM: the embedded paths can be non-ASCII (a username, + # temp dir or checkout path containing e.g. ``é``), and Windows + # PowerShell 5.1 reads a BOM-less script in the ANSI code page. + encoding="utf-8-sig", + ) + + # The SPECKIT_PYTHON_EXECUTABLE / SPECKIT_PYTHON override is consulted + # before any candidate, and clean_env() only strips SPECIFY_* keys -- so a + # runner that sets either would bypass the shims and test the override. + env = clean_env() + env.pop("SPECKIT_PYTHON_EXECUTABLE", None) + env.pop("SPECKIT_PYTHON", None) + + result = subprocess.run( + [POWERSHELL_EXE, "-NoProfile", "-File", str(driver)], + capture_output=True, + text=True, + check=False, + env=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" + + +@pytest.mark.skipif(not HAS_POWERSHELL, reason="PowerShell not available") +def test_get_python3_command_rejects_a_candidate_that_exits_nonzero( + tmp_path: Path, +) -> None: + """A candidate that prints a valid version but fails must be rejected. + + Selection is by execution *success*. Matching only on the version banner + accepted any process whose output happened to contain "Python 3", so a + broken wrapper that echoes its requested version and then exits non-zero + was selected here and failed at the point of use instead. The Bash twin + (``_python3_command``) gates purely on exit status, so matching output + alone also put the two implementations out of step. + """ + selected = _run_get_python3_command( + tmp_path, + {"python3": _LYING_WRAPPER, "python": _WORKING_PYTHON3}, + ) + assert selected == "python"