Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 44 additions & 9 deletions scripts/powershell/common.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}

Expand Down
174 changes: 174 additions & 0 deletions tests/test_resolve_template_python_parity.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import json
import os
import re
import subprocess
import sys
import time
Expand All @@ -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,
Expand Down Expand Up @@ -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: <reason>`` 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"