diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 9efcfad5e6..79656f2d35 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -399,7 +399,11 @@ check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; } check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" || echo " ✗ $2"; } _python3_command() { - if command -v python3 >/dev/null 2>&1 && + if [[ -n "${SPECKIT_PYTHON:-}" ]] && command -v "$SPECKIT_PYTHON" >/dev/null 2>&1 && + "$SPECKIT_PYTHON" -c 'import sys; raise SystemExit(sys.version_info.major != 3)' >/dev/null 2>&1 && + "$SPECKIT_PYTHON" -c 'import yaml' >/dev/null 2>&1; then + printf '%s\n' "$SPECKIT_PYTHON" + elif command -v python3 >/dev/null 2>&1 && python3 -c 'import sys; raise SystemExit(sys.version_info.major != 3)' >/dev/null 2>&1; then printf '%s\n' "python3" elif command -v python >/dev/null 2>&1 && diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index bb61f623bb..9b2279622f 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -320,6 +320,13 @@ function Format-SpecKitCommand { # 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 ($env:SPECKIT_PYTHON -and (Get-Command $env:SPECKIT_PYTHON -ErrorAction SilentlyContinue)) { + $ver = & $env:SPECKIT_PYTHON --version 2>&1 + if ($ver -match 'Python 3') { + & $env:SPECKIT_PYTHON -c 'import yaml' *> $null + if ($LASTEXITCODE -eq 0) { return @($env:SPECKIT_PYTHON) } + } + } if (Get-Command python3 -ErrorAction SilentlyContinue) { return @('python3') } if (Get-Command python -ErrorAction SilentlyContinue) { $ver = & python --version 2>&1 diff --git a/scripts/python/common.py b/scripts/python/common.py index db958dc1cb..1d15348726 100644 --- a/scripts/python/common.py +++ b/scripts/python/common.py @@ -5,6 +5,7 @@ import json import os import re +import subprocess import sys from dataclasses import dataclass from pathlib import Path @@ -380,6 +381,76 @@ def _validate_manifest_template_entry(entry: object) -> None: ) +class _DelegatedYAMLError(Exception): + """Raised when a SPECKIT_PYTHON-delegated manifest parse fails.""" + + +class _DelegatedYAML: + """``yaml.safe_load`` proxy that shells out to SPECKIT_PYTHON. + + Used when this interpreter lacks PyYAML but SPECKIT_PYTHON names one + that has it (e.g. a `uv tool install` / `pipx` venv invisible to the + bare `python3` a script is launched with). See #4443. + """ + + YAMLError = _DelegatedYAMLError + + def __init__(self, python_exe: str) -> None: + self._python_exe = python_exe + + def safe_load(self, text: str) -> object: + try: + proc = subprocess.run( + [ + self._python_exe, + "-c", + "import sys, json, yaml; " + "json.dump(yaml.safe_load(sys.stdin.read()), sys.stdout)", + ], + input=text, + capture_output=True, + text=True, + timeout=10, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise _DelegatedYAMLError( + f"SPECKIT_PYTHON could not parse the manifest: {exc}" + ) from exc + if proc.returncode != 0: + raise _DelegatedYAMLError( + proc.stderr.strip() or "SPECKIT_PYTHON could not parse the manifest" + ) + try: + return json.loads(proc.stdout) + except json.JSONDecodeError as exc: + raise _DelegatedYAMLError( + f"SPECKIT_PYTHON returned invalid JSON: {exc}" + ) from exc + + +def _import_yaml() -> object | None: + """Import PyYAML, delegating to SPECKIT_PYTHON if this interpreter lacks it.""" + try: + import yaml + + return yaml + except ImportError: + pass + + python_override = os.environ.get("SPECKIT_PYTHON") + if not python_override: + return None + try: + probe = subprocess.run( + [python_override, "-c", "import yaml"], capture_output=True, timeout=10 + ) + except (OSError, subprocess.TimeoutExpired): + return None + if probe.returncode != 0: + return None + return _DelegatedYAML(python_override) + + def _preset_template_layer( preset_dir: Path, template_name: str ) -> tuple[Path, str] | None: @@ -387,13 +458,12 @@ def _preset_template_layer( manifest_path = preset_dir / "preset.yml" conventional = _conventional_template(preset_dir, template_name) - try: - import yaml - except ImportError as exc: + yaml = _import_yaml() + if yaml is None: if manifest_path.is_file(): raise TemplateResolutionError( "PyYAML is required to resolve preset template composition" - ) from exc + ) return (conventional, "replace") if conventional is not None else None if manifest_path.is_file(): diff --git a/tests/test_resolve_template_python_parity.py b/tests/test_resolve_template_python_parity.py index 2bf9977e14..d5846b67ee 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 subprocess +import sys from pathlib import Path import pytest @@ -648,6 +650,98 @@ def test_all_variants_fail_when_yaml_parser_is_unavailable( assert all(result.stdout == "" for result in results) +@requires_bash +def test_all_variants_honor_speckit_python_override_when_yaml_missing( + tmp_path: Path, +) -> None: + """SPECKIT_PYTHON can name an interpreter with PyYAML when the default + one lacks it, e.g. a `uv tool install` venv invisible to bare `python3` + on PATH (#4443).""" + repo, expected = _setup_repo(tmp_path) + + no_yaml_python = tmp_path / "no-yaml-venv" + subprocess.run( + [sys.executable, "-m", "venv", "--without-pip", str(no_yaml_python)], + check=True, + capture_output=True, + ) + no_yaml_bin = no_yaml_python / "bin" + no_yaml_exe = no_yaml_bin / "python3" + assert no_yaml_exe.is_file() + + py_script = repo / ".specify" / "scripts" / "python" / "resolve_template.py" + + baseline_env = clean_env() + baseline_env["PATH"] = f"{no_yaml_bin}{os.pathsep}{baseline_env.get('PATH', '')}" + baseline_results = [ + run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo, baseline_env), + run([str(no_yaml_exe), str(py_script), TEMPLATE, "--json"], repo, baseline_env), + ] + if HAS_POWERSHELL: + baseline_results.append( + run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo, baseline_env) + ) + assert all(result.returncode != 0 for result in baseline_results) + + override_env = dict(baseline_env) + override_env["SPECKIT_PYTHON"] = sys.executable + override_results = [ + run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo, override_env), + run([str(no_yaml_exe), str(py_script), TEMPLATE, "--json"], repo, override_env), + ] + if HAS_POWERSHELL: + override_results.append( + run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo, override_env) + ) + + assert all(result.returncode == 0 for result in override_results) + assert all( + json_stdout(result) + == {"TEMPLATE_NAME": TEMPLATE, "TEMPLATE_CONTENT": expected} + for result in override_results + ) + + +@requires_bash +def test_all_variants_fall_back_when_speckit_python_lacks_pyyaml( + tmp_path: Path, +) -> None: + """SPECKIT_PYTHON naming a Python-3 interpreter without PyYAML must not + break composition that a PATH interpreter can already serve. + + SPECKIT_PYTHON is an override for *expanding* what's available (#4443), + not a way to narrow it: falling through to a working PATH interpreter + when the override lacks PyYAML must behave the same as if SPECKIT_PYTHON + had never been set. + """ + repo, expected = _setup_repo(tmp_path) + + no_yaml_python = tmp_path / "no-yaml-venv" + subprocess.run( + [sys.executable, "-m", "venv", "--without-pip", str(no_yaml_python)], + check=True, + capture_output=True, + ) + no_yaml_exe = no_yaml_python / "bin" / "python3" + assert no_yaml_exe.is_file() + + env = clean_env() + env["SPECKIT_PYTHON"] = str(no_yaml_exe) + + results = [ + run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo, env), + ] + if HAS_POWERSHELL: + results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo, env)) + + assert all(result.returncode == 0 for result in results) + assert all( + json_stdout(result) + == {"TEMPLATE_NAME": TEMPLATE, "TEMPLATE_CONTENT": expected} + for result in results + ) + + @requires_bash def test_bash_fails_when_override_read_fails(tmp_path: Path) -> None: repo = make_repo(tmp_path)