diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index ce1171e28f..1a1240da9a 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -399,7 +399,10 @@ 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; 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 && @@ -407,7 +410,29 @@ _python3_command() { printf '%s\n' "python" elif command -v py >/dev/null 2>&1 && py -3 -c 'import sys' >/dev/null 2>&1; then - printf '%s\n' "py -3" + printf '%s\n' "py" "-3" + else + return 1 + fi +} + +# SPECKIT_YAML_RUNTIME_FALLBACK=1 +_python3_yaml_command() { + if [[ -n "${SPECKIT_PYTHON:-}" ]] && command -v "$SPECKIT_PYTHON" >/dev/null 2>&1 && + PYTHONSAFEPATH=1 "$SPECKIT_PYTHON" -c 'import sys, yaml; raise SystemExit(sys.version_info.major != 3)' >/dev/null 2>&1; then + printf '%s\n' "$SPECKIT_PYTHON" + elif command -v python3 >/dev/null 2>&1 && + PYTHONSAFEPATH=1 python3 -c 'import sys, yaml; raise SystemExit(sys.version_info.major != 3)' >/dev/null 2>&1; then + printf '%s\n' "python3" + elif command -v python >/dev/null 2>&1 && + PYTHONSAFEPATH=1 python -c 'import sys, yaml; raise SystemExit(sys.version_info.major != 3)' >/dev/null 2>&1; then + printf '%s\n' "python" + elif command -v py >/dev/null 2>&1 && + PYTHONSAFEPATH=1 py -3 -c 'import sys, yaml; raise SystemExit(sys.version_info.major != 3)' >/dev/null 2>&1; then + printf '%s\n' "py" "-3" + elif command -v uv >/dev/null 2>&1 && + PYTHONPATH= PYTHONSAFEPATH=1 uv run --isolated --no-project --with pyyaml==6.0.3 python -c 'import sys, yaml; raise SystemExit(sys.version_info.major != 3)' >/dev/null 2>&1; then + printf '%s\n' "uv" "run" "--isolated" "--no-project" "--with" "pyyaml==6.0.3" "python" else return 1 fi @@ -415,10 +440,12 @@ _python3_command() { _sorted_extension_ids() { local ext_dir="$1" - local python_spec - if python_spec=$(_python3_command); then - local -a python_cmd - read -r -a python_cmd <<< "$python_spec" + local -a python_cmd=() + local _python_cmd_line + while IFS= read -r _python_cmd_line; do + python_cmd+=("$_python_cmd_line") + done < <(_python3_command) + if [ "${#python_cmd[@]}" -gt 0 ]; then local py_stderr sorted_ids py_stderr=$(mktemp) if sorted_ids=$(SPECKIT_EXTENSIONS="$ext_dir" "${python_cmd[@]}" -c " @@ -513,11 +540,11 @@ resolve_template() { local presets_dir="$repo_root/.specify/presets" if [ -d "$presets_dir" ]; then local registry_file="$presets_dir/.registry" - local python_spec="" local -a python_cmd=() - if python_spec=$(_python3_command); then - read -r -a python_cmd <<< "$python_spec" - fi + local _python_cmd_line + while IFS= read -r _python_cmd_line; do + python_cmd+=("$_python_cmd_line") + done < <(_python3_command) if [ -f "$registry_file" ] && [ "${#python_cmd[@]}" -gt 0 ]; then # Read preset IDs sorted by priority (lower number = higher precedence). # The python3 call is wrapped in an if-condition so that set -e does not @@ -636,11 +663,13 @@ resolve_template_content() { local registry_file="$presets_dir/.registry" local sorted_presets="" local registry_parsed=false - local python_spec="" local -a python_cmd=() - if python_spec=$(_python3_command); then - read -r -a python_cmd <<< "$python_spec" - fi + local -a yaml_cmd=() + local yaml_cmd_resolved=false + local _python_cmd_line + while IFS= read -r _python_cmd_line; do + python_cmd+=("$_python_cmd_line") + done < <(_python3_command) if [ -f "$registry_file" ] && [ "${#python_cmd[@]}" -gt 0 ]; then if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" "${python_cmd[@]}" -c " import json, re, sys, os @@ -682,15 +711,25 @@ except Exception: local manifest="$presets_dir/$preset_id/preset.yml" local manifest_declared=false if [ -f "$manifest" ]; then - if [ "${#python_cmd[@]}" -eq 0 ]; then + if [ "$yaml_cmd_resolved" = false ]; then + while IFS= read -r _python_cmd_line; do + yaml_cmd+=("$_python_cmd_line") + done < <(_python3_yaml_command) + yaml_cmd_resolved=true + fi + if [ "${#yaml_cmd[@]}" -eq 0 ]; then echo "Error: Python 3 and PyYAML are required to resolve preset template composition" >&2 return 2 fi local result local py_stderr local parse_status + local -a yaml_env=("PYTHONSAFEPATH=1") + if [ "${yaml_cmd[0]}" = "uv" ]; then + yaml_env+=("PYTHONPATH=") + fi py_stderr=$(mktemp) - if result=$(SPECKIT_MANIFEST="$manifest" SPECKIT_TMPL="$template_name" "${python_cmd[@]}" -c " + if result=$(env "${yaml_env[@]}" PYTHONIOENCODING=utf-8 SPECKIT_MANIFEST="$manifest" SPECKIT_TMPL="$template_name" "${yaml_cmd[@]}" -c " import sys, os try: import yaml diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index d0489fdf70..9a626d41bf 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -321,6 +321,10 @@ 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') { return @($env:SPECKIT_PYTHON) } + } if (Get-Command python3 -ErrorAction SilentlyContinue) { return @('python3') } if (Get-Command python -ErrorAction SilentlyContinue) { $ver = & python --version 2>&1 @@ -333,6 +337,58 @@ function Get-Python3Command { return $null } +# SPECKIT_YAML_RUNTIME_FALLBACK=1 +function Get-Python3WithYamlCommand { + $candidates = @() + if ($env:SPECKIT_PYTHON -and (Get-Command $env:SPECKIT_PYTHON -ErrorAction SilentlyContinue)) { + $candidates += ,@($env:SPECKIT_PYTHON) + } + if (Get-Command python3 -ErrorAction SilentlyContinue) { $candidates += ,@('python3') } + if (Get-Command python -ErrorAction SilentlyContinue) { $candidates += ,@('python') } + if (Get-Command py -ErrorAction SilentlyContinue) { $candidates += ,@('py', '-3') } + if (Get-Command uv -ErrorAction SilentlyContinue) { + $candidates += ,@( + 'uv', + 'run', + '--isolated', + '--no-project', + '--with', + 'pyyaml==6.0.3', + 'python' + ) + } + + $previousPythonPath = $env:PYTHONPATH + $previousPythonSafePath = $env:PYTHONSAFEPATH + try { + $env:PYTHONSAFEPATH = '1' + foreach ($command in $candidates) { + if ($command[0] -eq 'uv') { + Remove-Item Env:PYTHONPATH -ErrorAction SilentlyContinue + } elseif ($null -eq $previousPythonPath) { + Remove-Item Env:PYTHONPATH -ErrorAction SilentlyContinue + } else { + $env:PYTHONPATH = $previousPythonPath + } + [array]$commandArgs = if ($command.Count -gt 1) { $command[1..($command.Count - 1)] } else { @() } + & $command[0] @commandArgs -c 'import sys, yaml; raise SystemExit(sys.version_info.major != 3)' *> $null + if ($LASTEXITCODE -eq 0) { return $command } + } + } finally { + if ($null -eq $previousPythonPath) { + Remove-Item Env:PYTHONPATH -ErrorAction SilentlyContinue + } else { + $env:PYTHONPATH = $previousPythonPath + } + if ($null -eq $previousPythonSafePath) { + Remove-Item Env:PYTHONSAFEPATH -ErrorAction SilentlyContinue + } else { + $env:PYTHONSAFEPATH = $previousPythonSafePath + } + } + return $null +} + function Get-NormalizedPriority { param($Value) @@ -592,22 +648,34 @@ function Resolve-TemplateContent { ForEach-Object { $_.Name } } - $pyCmd = @(Get-Python3Command) + $yamlCmd = $null + $yamlCommandResolved = $false foreach ($presetId in $sortedPresets) { # Read strategy and file path from preset manifest $strategy = 'replace' $manifestFilePath = '' $manifestDeclared = $false $manifest = Join-Path $presetsDir "$presetId/preset.yml" - if ((Test-Path $manifest) -and -not $pyCmd) { - throw "Python 3 and PyYAML are required to resolve preset template composition" - } if (Test-Path $manifest) { + if (-not $yamlCommandResolved) { + $yamlCmd = @(Get-Python3WithYamlCommand) + $yamlCommandResolved = $true + } + if (-not $yamlCmd) { + throw "Python 3 and PyYAML are required to resolve preset template composition" + } try { # Use Python to parse YAML manifest for strategy and file path - $pyArgs = if ($pyCmd.Count -gt 1) { $pyCmd[1..($pyCmd.Count-1)] } else { @() } + [array]$pyArgs = if ($yamlCmd.Count -gt 1) { $yamlCmd[1..($yamlCmd.Count-1)] } else { @() } $pyStderrFile = [System.IO.Path]::GetTempFileName() - $stratResult = & $pyCmd[0] @pyArgs -c @" + $previousPythonPath = $env:PYTHONPATH + $previousPythonSafePath = $env:PYTHONSAFEPATH + try { + $env:PYTHONSAFEPATH = '1' + if ($yamlCmd[0] -eq 'uv') { + Remove-Item Env:PYTHONPATH -ErrorAction SilentlyContinue + } + $stratResult = & $yamlCmd[0] @pyArgs -c @" import sys try: import yaml @@ -662,8 +730,21 @@ except Exception as exc: print(f'manifest_invalid: {exc}', file=sys.stderr) sys.exit(3) "@ $manifest $TemplateName 2>$pyStderrFile - if ($LASTEXITCODE -ne 0) { - if ($LASTEXITCODE -eq 2) { + $parserExitCode = $LASTEXITCODE + } finally { + if ($null -eq $previousPythonPath) { + Remove-Item Env:PYTHONPATH -ErrorAction SilentlyContinue + } else { + $env:PYTHONPATH = $previousPythonPath + } + if ($null -eq $previousPythonSafePath) { + Remove-Item Env:PYTHONSAFEPATH -ErrorAction SilentlyContinue + } else { + $env:PYTHONSAFEPATH = $previousPythonSafePath + } + } + if ($parserExitCode -ne 0) { + if ($parserExitCode -eq 2) { throw "PyYAML is required to resolve preset template composition" } throw "Invalid preset manifest $manifest" diff --git a/scripts/powershell/resolve-template.ps1 b/scripts/powershell/resolve-template.ps1 index 70aee0aca0..80083d3efb 100644 --- a/scripts/powershell/resolve-template.ps1 +++ b/scripts/powershell/resolve-template.ps1 @@ -22,7 +22,12 @@ if (-not $TemplateName) { . "$PSScriptRoot/common.ps1" $repoRoot = Get-RepoRoot -$templateContent = Resolve-TemplateContent -TemplateName $TemplateName -RepoRoot $repoRoot +try { + $templateContent = Resolve-TemplateContent -TemplateName $TemplateName -RepoRoot $repoRoot +} catch { + [Console]::Error.WriteLine("ERROR: $($_.Exception.Message)") + exit 1 +} if ($null -eq $templateContent) { [Console]::Error.WriteLine("ERROR: Could not resolve required $TemplateName from the template override stack for $repoRoot") exit 1 diff --git a/scripts/python/common.py b/scripts/python/common.py index db958dc1cb..5d129b94eb 100644 --- a/scripts/python/common.py +++ b/scripts/python/common.py @@ -5,7 +5,10 @@ import json import os import re +import shutil +import subprocess import sys +from collections.abc import Sequence from dataclasses import dataclass from pathlib import Path @@ -380,64 +383,228 @@ def _validate_manifest_template_entry(entry: object) -> None: ) +class _DelegatedYAMLError(Exception): + """Raised when a SPECKIT_PYTHON-delegated manifest parse fails.""" + + +class _NonNativeYAMLValue: + """Marker for a YAML value with no native JSON equivalent (e.g. a date). + + Preserves the fact that native ``yaml.safe_load`` would not have produced + a string/int/etc. here, so callers validating field types (e.g. that + ``file`` is a string) reject it the same way the in-process parser would, + instead of silently accepting a stringified value. + """ + + def __repr__(self) -> str: + return "" + + +_NON_NATIVE_MARKER_KEY = "$speckit_non_native" +_DELEGATED_YAML_TIMEOUT_SECONDS = 120 +_YAML_RUNTIME_UNRESOLVED = object() +_UV_YAML_COMMAND = ( + "uv", + "run", + "--isolated", + "--no-project", + "--with", + "pyyaml==6.0.3", + "python", +) + + +def _delegated_yaml_object_hook(obj: dict) -> object: + if len(obj) == 1 and obj.get(_NON_NATIVE_MARKER_KEY) is True: + return _NonNativeYAMLValue() + return obj + + +class _DelegatedYAML: + """``yaml.safe_load`` proxy that shells out to a PyYAML-capable Python. + + Used when this interpreter lacks PyYAML but another validated command can + provide it, including an isolated uv environment. See #4443. + """ + + YAMLError = _DelegatedYAMLError + + def __init__(self, python_command: Sequence[str]) -> None: + self._python_command = tuple(python_command) + + def safe_load(self, text: str) -> object: + child_env = dict( + os.environ, + PYTHONIOENCODING="utf-8", + PYTHONSAFEPATH="1", + PYTHONUTF8="1", + ) + if self._python_command[1:] == _UV_YAML_COMMAND[1:]: + child_env.pop("PYTHONPATH", None) + delegated_parser = ( + "import sys, json, yaml\n" + "def _default(value):\n" + f" return {{'{_NON_NATIVE_MARKER_KEY}': True}}\n" + "def _stringify_keys(obj, stack=None):\n" + " if stack is None:\n" + " stack = set()\n" + " if isinstance(obj, (dict, list, tuple)):\n" + " if id(obj) in stack:\n" + f" return {{'{_NON_NATIVE_MARKER_KEY}': True}}\n" + " stack.add(id(obj))\n" + " try:\n" + " if isinstance(obj, dict):\n" + " return {\n" + " (k if isinstance(k, (str, int, float, bool)) or k is None else str(k)): _stringify_keys(v, stack)\n" + " for k, v in obj.items()\n" + " }\n" + " return [_stringify_keys(v, stack) for v in obj]\n" + " finally:\n" + " stack.discard(id(obj))\n" + " return obj\n" + "try:\n" + " data = yaml.safe_load(sys.stdin.read())\n" + "except yaml.YAMLError as exc:\n" + " print(str(exc), file=sys.stderr)\n" + " sys.exit(1)\n" + "json.dump(_stringify_keys(data), sys.stdout, default=_default)" + ) + try: + proc = subprocess.run( + [*self._python_command, "-c", delegated_parser], + input=text, + capture_output=True, + encoding="utf-8", + env=child_env, + timeout=_DELEGATED_YAML_TIMEOUT_SECONDS, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise _DelegatedYAMLError( + f"Python could not parse the manifest: {exc}" + ) from exc + if proc.returncode != 0: + raise _DelegatedYAMLError( + proc.stderr.strip() or "Python could not parse the manifest" + ) + try: + return json.loads(proc.stdout, object_hook=_delegated_yaml_object_hook) + except json.JSONDecodeError as exc: + raise _DelegatedYAMLError( + f"Python returned invalid JSON: {exc}" + ) from exc + + +# SPECKIT_YAML_RUNTIME_FALLBACK=1 +def _import_yaml() -> object | None: + """Import PyYAML or delegate to the first validated Python command.""" + try: + import yaml + + return yaml + except ImportError: + pass + + candidates: list[tuple[str, ...]] = [] + python_override = os.environ.get("SPECKIT_PYTHON") + if python_override: + candidates.append((python_override,)) + for executable in ("python3", "python"): + resolved = shutil.which(executable) + if resolved: + candidates.append((resolved,)) + py_launcher = shutil.which("py") + if py_launcher: + candidates.append((py_launcher, "-3")) + uv_executable = shutil.which("uv") + if uv_executable: + candidates.append((uv_executable, *_UV_YAML_COMMAND[1:])) + + seen: set[tuple[str, ...]] = set() + for command in candidates: + if command in seen: + continue + seen.add(command) + probe_env = dict( + os.environ, + PYTHONIOENCODING="utf-8", + PYTHONSAFEPATH="1", + PYTHONUTF8="1", + ) + if command[1:] == _UV_YAML_COMMAND[1:]: + probe_env.pop("PYTHONPATH", None) + try: + probe = subprocess.run( + [ + *command, + "-c", + "import sys, yaml\nraise SystemExit(sys.version_info.major != 3)", + ], + capture_output=True, + env=probe_env, + timeout=_DELEGATED_YAML_TIMEOUT_SECONDS, + ) + except (OSError, subprocess.TimeoutExpired): + continue + if probe.returncode == 0: + return _DelegatedYAML(command) + return None + + def _preset_template_layer( - preset_dir: Path, template_name: str + preset_dir: Path, + template_name: str, + yaml_runtime: object = _YAML_RUNTIME_UNRESOLVED, ) -> tuple[Path, str] | None: """Return the preset template path and composition strategy.""" manifest_path = preset_dir / "preset.yml" conventional = _conventional_template(preset_dir, template_name) - try: - import yaml - except ImportError as exc: - if manifest_path.is_file(): - raise TemplateResolutionError( - "PyYAML is required to resolve preset template composition" - ) from exc + if not manifest_path.is_file(): return (conventional, "replace") if conventional is not None else None - if manifest_path.is_file(): - try: - manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) - if not isinstance(manifest, dict): - raise ValueError("manifest root must be a mapping") - if "provides" not in manifest: - raise ValueError("manifest missing provides section") - provides = manifest["provides"] - if not isinstance(provides, dict): - raise ValueError("manifest provides must be a mapping") - if "templates" not in provides: - raise ValueError("manifest provides missing templates") - templates = provides["templates"] - if not isinstance(templates, list): - raise ValueError("manifest templates must be a list") - if not templates: - raise ValueError("manifest must provide at least one template") - for entry in templates: - _validate_manifest_template_entry(entry) - for entry in templates: - if ( - entry.get("name") != template_name - or entry.get("type", "template") != "template" - ): - continue - file_value = entry.get("file", "") - strategy = entry.get("strategy", "replace") - relative = Path(file_value) - if ( - not relative - or relative.is_absolute() - or ".." in relative.parts - ): - return None - candidate = preset_dir / relative - if not candidate.is_file(): - return None - return candidate, strategy.lower() - except (OSError, UnicodeError, ValueError, yaml.YAMLError) as exc: - raise TemplateResolutionError( - f"Failed to parse preset manifest {manifest_path}: {exc}" - ) from exc + yaml = _import_yaml() if yaml_runtime is _YAML_RUNTIME_UNRESOLVED else yaml_runtime + if yaml is None: + raise TemplateResolutionError( + "PyYAML is required to resolve preset template composition" + ) + + try: + manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + if not isinstance(manifest, dict): + raise ValueError("manifest root must be a mapping") + if "provides" not in manifest: + raise ValueError("manifest missing provides section") + provides = manifest["provides"] + if not isinstance(provides, dict): + raise ValueError("manifest provides must be a mapping") + if "templates" not in provides: + raise ValueError("manifest provides missing templates") + templates = provides["templates"] + if not isinstance(templates, list): + raise ValueError("manifest templates must be a list") + if not templates: + raise ValueError("manifest must provide at least one template") + for entry in templates: + _validate_manifest_template_entry(entry) + for entry in templates: + if ( + entry.get("name") != template_name + or entry.get("type", "template") != "template" + ): + continue + file_value = entry.get("file", "") + strategy = entry.get("strategy", "replace") + relative = Path(file_value) + if not relative or relative.is_absolute() or ".." in relative.parts: + return None + candidate = preset_dir / relative + if not candidate.is_file(): + return None + return candidate, strategy.lower() + except (OSError, UnicodeError, ValueError, yaml.YAMLError) as exc: + raise TemplateResolutionError( + f"Failed to parse preset manifest {manifest_path}: {exc}" + ) from exc return (conventional, "replace") if conventional is not None else None @@ -487,8 +654,19 @@ def compose_from_base() -> str: return compose_from_base() presets_dir = repo_root / ".specify" / "presets" + yaml_runtime: object = _YAML_RUNTIME_UNRESOLVED for preset_id in _sorted_preset_ids(presets_dir): - layer = _preset_template_layer(presets_dir / preset_id, template_name) + preset_dir = presets_dir / preset_id + if ( + yaml_runtime is _YAML_RUNTIME_UNRESOLVED + and (preset_dir / "preset.yml").is_file() + ): + yaml_runtime = _import_yaml() + layer = _preset_template_layer( + preset_dir, + template_name, + yaml_runtime, + ) if layer is not None: layers.append(layer) if layer[1] == "replace": diff --git a/tests/parity_helpers.py b/tests/parity_helpers.py index 5ab3fcfa0d..9f292c2926 100644 --- a/tests/parity_helpers.py +++ b/tests/parity_helpers.py @@ -79,9 +79,126 @@ def clean_env() -> dict[str, str]: for key in list(env): if key.startswith("SPECIFY_"): env.pop(key) + # A --without-pip venv still honors an inherited PYTHONPATH, so leaving + # this set could make a "no-PyYAML" test interpreter import PyYAML + # anyway, silently skipping the delegated-parsing path under test. + env.pop("PYTHONPATH", None) + # Tests exercising SPECKIT_PYTHON set it explicitly; an ambient value in + # the host environment would otherwise silently override the "unset" + # baseline for every other test. + env.pop("SPECKIT_PYTHON", None) return env +def venv_python3_exe(venv_dir: Path) -> Path: + """Path to the python3 executable of a venv created with ``--without-pip``.""" + if os.name == "nt": + return venv_dir / "Scripts" / "python.exe" + return venv_dir / "bin" / "python3" + + +def make_yaml_less_venv(venv_dir: Path) -> Path: + """Create a ``--without-pip`` venv and return its python3 executable. + + Asserts the interpreter cannot actually import PyYAML, since it could + otherwise be visible via an inherited ``PYTHONPATH`` despite + ``--without-pip``, silently invalidating tests that assume it lacks one. + """ + subprocess.run( + [sys.executable, "-m", "venv", "--without-pip", str(venv_dir)], + check=True, + capture_output=True, + ) + exe = venv_python3_exe(venv_dir) + assert exe.is_file() + probe = subprocess.run( + [str(exe), "-c", "import yaml"], + capture_output=True, + env=clean_env(), + check=False, + ) + assert probe.returncode != 0, "venv unexpectedly has PyYAML importable" + return exe + + +def make_python_candidate_shims(bin_dir: Path, python_executable: Path) -> None: + """Shadow python3, python, and py with one known interpreter.""" + bin_dir.mkdir(parents=True, exist_ok=True) + runner = bin_dir / "python_candidate_runner.py" + runner.write_text( + "import os\n" + "import sys\n" + "args = sys.argv[1:]\n" + "if args[:1] == ['-3']:\n" + " args = args[1:]\n" + f"python_executable = {str(python_executable)!r}\n" + "os.execv(python_executable, [python_executable, *args])\n", + encoding="utf-8", + ) + for name in ("python3", "python", "py"): + shim = bin_dir / name + shim.write_text( + f'#!/bin/sh\nexec "{sys.executable}" "{runner}" "$@"\n', + encoding="utf-8", + ) + shim.chmod(0o755) + if os.name == "nt": + (bin_dir / f"{name}.cmd").write_text( + f'@"{sys.executable}" "{runner}" %*\r\n', + encoding="utf-8", + ) + + +def make_fake_uv( + bin_dir: Path, + log_file: Path, + *, + fail: bool = False, +) -> None: + """Install a uv stub that validates the pinned fallback argv.""" + bin_dir.mkdir(parents=True, exist_ok=True) + runner = bin_dir / "fake_uv_runner.py" + runner.write_text( + "import json\n" + "import os\n" + "import sys\n" + "from pathlib import Path\n" + "expected = [\n" + " 'run', '--isolated', '--no-project', '--with',\n" + " 'pyyaml==6.0.3', 'python',\n" + "]\n" + "args = sys.argv[1:]\n" + "if args[:len(expected)] != expected:\n" + " print(f'unexpected uv arguments: {args!r}', file=sys.stderr)\n" + " raise SystemExit(64)\n" + "if os.environ.get('PYTHONPATH'):\n" + " print('uv fallback inherited PYTHONPATH', file=sys.stderr)\n" + " raise SystemExit(65)\n" + "if os.environ.get('PYTHONSAFEPATH') != '1':\n" + " print('uv fallback did not enable PYTHONSAFEPATH', file=sys.stderr)\n" + " raise SystemExit(66)\n" + f"log_file = Path({str(log_file)!r})\n" + "with log_file.open('a', encoding='utf-8') as stream:\n" + " stream.write(json.dumps(args[:len(expected)]) + '\\n')\n" + f"if {fail!r}:\n" + " raise SystemExit(42)\n" + f"python_executable = {sys.executable!r}\n" + "os.execv(python_executable, [python_executable, *args[len(expected):]])\n", + encoding="utf-8", + ) + shim = bin_dir / "uv" + shim.write_text( + f'#!/bin/sh\nexec "{sys.executable}" "{runner}" "$@"\n', + encoding="utf-8", + ) + shim.chmod(0o755) + if os.name == "nt": + (bin_dir / "uv.cmd").write_text( + f'@"{sys.executable}" "{runner}" %*\r\n', + encoding="utf-8", + ) + + def collation_range_locale() -> str | None: """A locale whose ``[a-z]`` bracket range is collation-ordered, or ``None``. diff --git a/tests/test_resolve_template_python_parity.py b/tests/test_resolve_template_python_parity.py index 2bf9977e14..8a8076fa3a 100644 --- a/tests/test_resolve_template_python_parity.py +++ b/tests/test_resolve_template_python_parity.py @@ -4,10 +4,13 @@ import json import os +import subprocess +import sys from pathlib import Path import pytest +from scripts.python import common as python_common from tests.conftest import requires_bash from tests.parity_helpers import ( HAS_POWERSHELL, @@ -16,7 +19,10 @@ install_composition_stack, install_scripts, json_stdout, + make_fake_uv, + make_python_candidate_shims, make_repo, + make_yaml_less_venv, ps_cmd, py_cmd, run, @@ -33,6 +39,21 @@ def _setup_repo(tmp_path: Path) -> tuple[Path, str]: return repo, expected +def _yaml_fallback_env( + tmp_path: Path, + python_executable: Path, + *, + fail_uv: bool = False, +) -> tuple[dict[str, str], Path]: + shim_dir = tmp_path / "yaml-runtime-bin" + uv_log = tmp_path / "uv-invocations.jsonl" + make_python_candidate_shims(shim_dir, python_executable) + make_fake_uv(shim_dir, uv_log, fail=fail_uv) + env = clean_env() + env["PATH"] = f"{shim_dir}{os.pathsep}{env.get('PATH', '')}" + return env, uv_log + + @requires_bash def test_all_variants_emit_composed_template_content(tmp_path: Path) -> None: repo, expected = _setup_repo(tmp_path) @@ -626,26 +647,603 @@ def test_all_variants_fail_when_yaml_parser_is_unavailable( tmp_path: Path, ) -> None: repo, _ = _setup_repo(tmp_path) - blocker = tmp_path / "blocker" + no_yaml_exe = make_yaml_less_venv(tmp_path / "no-yaml-venv") + env, uv_log = _yaml_fallback_env(tmp_path, no_yaml_exe, fail_uv=True) + env["SPECKIT_PYTHON"] = str(no_yaml_exe) + py_script = repo / ".specify" / "scripts" / "python" / "resolve_template.py" + + results = [ + run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo, env, timeout=30), + run( + [str(no_yaml_exe), str(py_script), TEMPLATE, "--json"], + repo, + env, + timeout=30, + ), + ] + if HAS_POWERSHELL: + results.append( + run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo, env, timeout=30) + ) + + assert all(result.returncode != 0 for result in results) + assert all(result.stdout == "" for result in results) + assert all("Traceback" not in result.stderr for result in results) + assert all("PyYAML" in result.stderr for result in results) + assert all(len(result.stderr.splitlines()) <= 2 for result in results) + assert uv_log.is_file() + assert len(uv_log.read_text(encoding="utf-8").splitlines()) == len(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_exe = make_yaml_less_venv(tmp_path / "no-yaml-venv") + py_script = repo / ".specify" / "scripts" / "python" / "resolve_template.py" + override_env, uv_log = _yaml_fallback_env( + tmp_path, no_yaml_exe, fail_uv=True + ) + 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 + ) + assert not uv_log.exists() + + +@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_exe = make_yaml_less_venv(tmp_path / "no-yaml-venv") + + env, uv_log = _yaml_fallback_env( + tmp_path, Path(sys.executable), fail_uv=True + ) + env["SPECKIT_PYTHON"] = str(no_yaml_exe) + py_script = repo / ".specify" / "scripts" / "python" / "resolve_template.py" + + results = [ + run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo, env, timeout=30), + run( + [str(no_yaml_exe), str(py_script), TEMPLATE, "--json"], + repo, + env, + timeout=30, + ), + ] + if HAS_POWERSHELL: + results.append( + run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo, env, timeout=30) + ) + + assert all(result.returncode == 0 for result in results) + assert all( + json_stdout(result) + == {"TEMPLATE_NAME": TEMPLATE, "TEMPLATE_CONTENT": expected} + for result in results + ) + assert not uv_log.exists() + + +@requires_bash +def test_all_variants_use_isolated_uv_when_python_candidates_lack_yaml( + tmp_path: Path, +) -> None: + repo, expected = _setup_repo(tmp_path) + no_yaml_exe = make_yaml_less_venv(tmp_path / "no-yaml-venv") + env, uv_log = _yaml_fallback_env(tmp_path, no_yaml_exe) + env["SPECKIT_PYTHON"] = str(no_yaml_exe) + blocker = tmp_path / "ambient-pythonpath" blocker.mkdir() (blocker / "yaml.py").write_text( - "raise ImportError('simulated missing PyYAML')\n", + "raise ImportError('ambient yaml blocker')\n", encoding="utf-8" + ) + (repo / "yaml.py").write_text( + "raise ImportError('working-directory yaml blocker')\n", encoding="utf-8", ) - env = clean_env() env["PYTHONPATH"] = str(blocker) + py_script = repo / ".specify" / "scripts" / "python" / "resolve_template.py" results = [ run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo, env), - run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo, env), + run([str(no_yaml_exe), str(py_script), TEMPLATE, "--json"], repo, env), ] if HAS_POWERSHELL: - results.append( - run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo, env) - ) + results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo, env)) - assert all(result.returncode != 0 for result in results) - assert all(result.stdout == "" for result in results) + assert all(result.returncode == 0 for result in results) + assert all( + json_stdout(result) + == {"TEMPLATE_NAME": TEMPLATE, "TEMPLATE_CONTENT": expected} + for result in results + ) + invocations = [ + json.loads(line) + for line in uv_log.read_text(encoding="utf-8").splitlines() + ] + assert invocations + assert len(invocations) == len(results) * 4 + assert all( + invocation + == [ + "run", + "--isolated", + "--no-project", + "--with", + "pyyaml==6.0.3", + "python", + ] + for invocation in invocations + ) + + +def test_python_variant_accepts_multi_element_delegated_command() -> None: + delegated_yaml = python_common._DelegatedYAML([sys.executable, "-I"]) + assert delegated_yaml.safe_load("value: ok\n") == {"value": "ok"} + + +@requires_bash +def test_all_variants_skip_uv_for_manifest_less_preset(tmp_path: Path) -> None: + repo = make_repo(tmp_path) + install_scripts(repo, SCRIPT) + preset = repo / ".specify" / "presets" / "plain-pack" / "templates" + preset.mkdir(parents=True) + (preset / f"{TEMPLATE}.md").write_text("# Plain preset\n", encoding="utf-8") + registry = repo / ".specify" / "presets" / ".registry" + registry.write_text( + '{"presets":{"plain-pack":{"enabled":true,"priority":1}}}\n', + encoding="utf-8", + ) + no_yaml_exe = make_yaml_less_venv(tmp_path / "no-yaml-venv") + env, uv_log = _yaml_fallback_env(tmp_path, no_yaml_exe) + env["SPECKIT_PYTHON"] = str(no_yaml_exe) + py_script = repo / ".specify" / "scripts" / "python" / "resolve_template.py" + + results = [ + run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo, env), + run([str(no_yaml_exe), str(py_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_CONTENT"] == "# Plain preset\n" + for result in results + ) + assert not uv_log.exists() + + +def test_generated_common_files_contain_yaml_runtime_marker() -> None: + marker = "SPECKIT_YAML_RUNTIME_FALLBACK=1" + paths = ( + Path("scripts/bash/common.sh"), + Path("scripts/powershell/common.ps1"), + Path("scripts/python/common.py"), + ) + assert all(marker in path.read_text(encoding="utf-8") for path in paths) + + +def test_clean_env_strips_pythonpath(monkeypatch: pytest.MonkeyPatch) -> None: + """A ``--without-pip`` venv still honors an inherited `PYTHONPATH`, so a + leaked `PYTHONPATH` pointing at a directory with PyYAML would let the + "no-PyYAML" interpreters used above import it anyway, silently making + those tests pass without exercising the `SPECKIT_PYTHON`/delegated-YAML + path they claim to cover (#4445).""" + monkeypatch.setenv("PYTHONPATH", "/somewhere/with/yaml") + assert "PYTHONPATH" not in clean_env() + + +def test_clean_env_strips_speckit_python(monkeypatch: pytest.MonkeyPatch) -> None: + """An ambient `SPECKIT_PYTHON` in the host/CI environment would otherwise + survive into every baseline built from `clean_env()`, silently bypassing + the blocked/default interpreter that tests like + `test_all_variants_fail_when_yaml_parser_is_unavailable` rely on. Tests + that exercise the override set it explicitly after calling + `clean_env()` (#4445).""" + monkeypatch.setenv("SPECKIT_PYTHON", "/somewhere/with/yaml") + assert "SPECKIT_PYTHON" not in clean_env() + + +@requires_bash +def test_bash_honors_speckit_python_path_containing_spaces(tmp_path: Path) -> None: + """SPECKIT_PYTHON may be an absolute path containing spaces (e.g. a venv + named "tool env"); callers must treat it as one argv element rather than + splitting it on whitespace (#4445). + + A shim script that execs `sys.executable` guarantees the spaced path + actually has PyYAML: a symlink would not do, since `sys.executable` may + itself be a venv-relative symlink (e.g. under `uv run`), and invoking it + through a second symlink placed outside that venv's directory tree + breaks Python's `pyvenv.cfg` discovery, silently hiding the venv's + site-packages (and PyYAML with it). Exec'ing the real path from a shim + keeps `argv[0]` at its original, correctly-resolvable location. PATH's + `python3` is a PyYAML-less venv, so success is only possible if + `_python3_command` selects the spaced override intact. + """ + repo, expected = _setup_repo(tmp_path) + + spaced_dir = tmp_path / "tool env" + spaced_dir.mkdir() + spaced_exe = spaced_dir / Path(sys.executable).name + spaced_exe.write_text(f'#!/bin/sh\nexec "{sys.executable}" "$@"\n') + spaced_exe.chmod(0o755) + + no_yaml_bin = make_yaml_less_venv(tmp_path / "no-yaml-venv").parent + + env = clean_env() + env["SPECKIT_PYTHON"] = str(spaced_exe) + env["PATH"] = f"{no_yaml_bin}{os.pathsep}{env.get('PATH', '')}" + + result = run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo, env) + + assert result.returncode == 0, result.stderr + assert json_stdout(result) == { + "TEMPLATE_NAME": TEMPLATE, + "TEMPLATE_CONTENT": expected, + } + + +@requires_bash +def test_python_variant_delegates_manifest_with_non_json_native_yaml_value( + tmp_path: Path, +) -> None: + """A manifest holding a value PyYAML parses into a non-JSON-native type + (e.g. an unquoted date) must still resolve when the Python twin delegates + parsing to SPECKIT_PYTHON because its own interpreter lacks PyYAML.""" + repo, expected = _setup_repo(tmp_path) + + manifest = repo / ".specify" / "presets" / "wrap-pack" / "preset.yml" + manifest.write_text( + "created_at: 2026-09-08\n" + manifest.read_text(encoding="utf-8"), + encoding="utf-8", + ) + + no_yaml_exe = make_yaml_less_venv(tmp_path / "no-yaml-venv") + + py_script = repo / ".specify" / "scripts" / "python" / "resolve_template.py" + env = clean_env() + env["SPECKIT_PYTHON"] = sys.executable + + result = run([str(no_yaml_exe), str(py_script), TEMPLATE, "--json"], repo, env) + + assert result.returncode == 0, result.stderr + assert json_stdout(result) == { + "TEMPLATE_NAME": TEMPLATE, + "TEMPLATE_CONTENT": expected, + } + + +@requires_bash +def test_python_variant_rejects_delegated_manifest_with_non_string_validated_field( + tmp_path: Path, +) -> None: + """A validated field (``file``) holding a value PyYAML parses into a + non-JSON-native type (e.g. an unquoted date) must be rejected via + delegation exactly as the in-process parser rejects it, not silently + coerced to a string that passes the ``isinstance(str)`` check (#4445).""" + repo, _ = _setup_repo(tmp_path) + + manifest = repo / ".specify" / "presets" / "wrap-pack" / "preset.yml" + manifest.write_text( + "provides:\n" + " templates:\n" + " - type: template\n" + f" name: {TEMPLATE}\n" + " file: 2026-09-08\n" + " strategy: wrap\n", + encoding="utf-8", + ) + + no_yaml_exe = make_yaml_less_venv(tmp_path / "no-yaml-venv") + + py_script = repo / ".specify" / "scripts" / "python" / "resolve_template.py" + env = clean_env() + env["SPECKIT_PYTHON"] = sys.executable + + result = run([str(no_yaml_exe), str(py_script), TEMPLATE, "--json"], repo, env) + + assert result.returncode != 0 + assert result.stdout == "" + + +@requires_bash +def test_python_variant_delegates_manifest_with_non_ascii_metadata_under_ascii_locale( + tmp_path: Path, +) -> None: + """Delegated manifest parsing must force UTF-8 on the subprocess pipe and + the child's own stdio, not the process locale, so non-ASCII metadata in a + manifest still resolves when this interpreter lacks PyYAML and the + process is running under a forced ASCII locale (#4445).""" + repo, expected = _setup_repo(tmp_path) + + manifest = repo / ".specify" / "presets" / "wrap-pack" / "preset.yml" + manifest.write_text( + manifest.read_text(encoding="utf-8") + ' description: "Café ✓"\n', + encoding="utf-8", + ) + + no_yaml_exe = make_yaml_less_venv(tmp_path / "no-yaml-venv") + + py_script = repo / ".specify" / "scripts" / "python" / "resolve_template.py" + env = clean_env() + env["SPECKIT_PYTHON"] = sys.executable + env["PYTHONUTF8"] = "0" + env["PYTHONCOERCECLOCALE"] = "0" + env["LC_ALL"] = "C" + env["LANG"] = "C" + + result = run([str(no_yaml_exe), str(py_script), TEMPLATE, "--json"], repo, env) + + assert result.returncode == 0, result.stderr + assert json_stdout(result) == { + "TEMPLATE_NAME": TEMPLATE, + "TEMPLATE_CONTENT": expected, + } + + +@requires_bash +def test_python_variant_delegates_manifest_with_non_json_native_mapping_key( + tmp_path: Path, +) -> None: + """An otherwise-ignored mapping whose key PyYAML parses into a + non-JSON-native type (e.g. an unquoted date) must not break delegation: + `json.dump`'s `default` hook only applies to values, never to keys, so + such a key must be stringified before serialization instead of raising + `TypeError` (#4445).""" + repo, expected = _setup_repo(tmp_path) + + manifest = repo / ".specify" / "presets" / "wrap-pack" / "preset.yml" + manifest.write_text( + "metadata:\n 2026-09-08: value\n" + manifest.read_text(encoding="utf-8"), + encoding="utf-8", + ) + + no_yaml_exe = make_yaml_less_venv(tmp_path / "no-yaml-venv") + + py_script = repo / ".specify" / "scripts" / "python" / "resolve_template.py" + env = clean_env() + env["SPECKIT_PYTHON"] = sys.executable + + result = run([str(no_yaml_exe), str(py_script), TEMPLATE, "--json"], repo, env) + + assert result.returncode == 0, result.stderr + assert json_stdout(result) == { + "TEMPLATE_NAME": TEMPLATE, + "TEMPLATE_CONTENT": expected, + } + + +def test_python_variant_delegates_manifest_with_omap_metadata( + tmp_path: Path, +) -> None: + """An ignored `!!omap` metadata value must not break delegation: + PyYAML's safe loader represents `!!omap`/`!!pairs` entries as tuples, + which naive recursive normalization returns unchanged, so a nested + non-JSON-native mapping key inside one (e.g. an unquoted date) still + reaches `json.dump` unstringified and raises `TypeError`, even though + the in-process parser accepts and ignores the same metadata (#4445).""" + repo, expected = _setup_repo(tmp_path) + + manifest = repo / ".specify" / "presets" / "wrap-pack" / "preset.yml" + manifest.write_text( + "metadata: !!omap\n - entry:\n 2026-09-08: value\n" + + manifest.read_text(encoding="utf-8"), + encoding="utf-8", + ) + + no_yaml_exe = make_yaml_less_venv(tmp_path / "no-yaml-venv") + + py_script = repo / ".specify" / "scripts" / "python" / "resolve_template.py" + env = clean_env() + env["SPECKIT_PYTHON"] = sys.executable + + result = run([str(no_yaml_exe), str(py_script), TEMPLATE, "--json"], repo, env) + + assert result.returncode == 0, result.stderr + payload = json_stdout(result) + assert isinstance(payload, dict) + assert payload["TEMPLATE_NAME"] == TEMPLATE + content = payload["TEMPLATE_CONTENT"] + assert isinstance(content, str) + assert content.replace("\r\n", "\n") == expected.replace("\r\n", "\n") + + +@requires_bash +def test_python_variant_delegates_manifest_with_recursive_yaml_alias( + tmp_path: Path, +) -> None: + """An ignored `metadata` mapping containing a self-referential YAML alias + (`&m {self: *m}`) must not break delegation: `yaml.safe_load` supports + this via a shared reference, so naive recursive serialization of the + same object forever revisits it and raises `RecursionError` instead of + ignoring the unused field the way the in-process parser does (#4445).""" + repo, expected = _setup_repo(tmp_path) + + manifest = repo / ".specify" / "presets" / "wrap-pack" / "preset.yml" + manifest.write_text( + "metadata: &m\n self: *m\n" + manifest.read_text(encoding="utf-8"), + encoding="utf-8", + ) + + no_yaml_exe = make_yaml_less_venv(tmp_path / "no-yaml-venv") + + py_script = repo / ".specify" / "scripts" / "python" / "resolve_template.py" + env = clean_env() + env["SPECKIT_PYTHON"] = sys.executable + + result = run([str(no_yaml_exe), str(py_script), TEMPLATE, "--json"], repo, env) + + assert result.returncode == 0, result.stderr + assert json_stdout(result) == { + "TEMPLATE_NAME": TEMPLATE, + "TEMPLATE_CONTENT": expected, + } + + +@requires_bash +def test_python_variant_delegates_manifest_with_shared_non_recursive_alias( + tmp_path: Path, +) -> None: + """A YAML alias shared between two locations (not a self-reference) must + not be treated as a cycle: marking a container as seen for the rest of + the document -- rather than only while its own subtree is being walked + -- makes a second, unrelated reference to the same anchor look like a + recursive structure, so delegation replaces `provides.templates` with + the non-native marker even though `yaml.safe_load` resolves it to the + same plain list the in-process parser accepts (#4445).""" + repo, expected = _setup_repo(tmp_path) + + manifest = repo / ".specify" / "presets" / "wrap-pack" / "preset.yml" + manifest.write_text( + "metadata:\n" + " shared_templates: &shared_templates\n" + " - type: template\n" + f" name: {TEMPLATE}\n" + f" file: templates/{TEMPLATE}.md\n" + " strategy: wrap\n" + "provides:\n" + " templates: *shared_templates\n", + encoding="utf-8", + ) + + no_yaml_exe = make_yaml_less_venv(tmp_path / "no-yaml-venv") + + py_script = repo / ".specify" / "scripts" / "python" / "resolve_template.py" + env = clean_env() + env["SPECKIT_PYTHON"] = sys.executable + + result = run([str(no_yaml_exe), str(py_script), TEMPLATE, "--json"], repo, env) + + assert result.returncode == 0, result.stderr + assert json_stdout(result) == { + "TEMPLATE_NAME": TEMPLATE, + "TEMPLATE_CONTENT": expected, + } + + +def test_python_variant_rejects_speckit_python_override_without_python_3( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A `SPECKIT_PYTHON` override that can `import yaml` but is not a + Python 3 interpreter (e.g. Python 2 with PyYAML installed) must not be + accepted: probing only `import yaml` lets it through, and the later + delegated subprocess then fails with syntax/runtime errors instead of + falling back to a working interpreter (#4445).""" + monkeypatch.setitem(sys.modules, "yaml", None) + monkeypatch.setenv("SPECKIT_PYTHON", "/fake/python2") + monkeypatch.setattr(python_common.shutil, "which", lambda _: None) + + def fake_run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess: + code = cmd[2] + returncode = 1 if "version_info" in code else 0 + return subprocess.CompletedProcess(cmd, returncode) + + monkeypatch.setattr(python_common.subprocess, "run", fake_run) + + assert python_common._import_yaml() is None + + +def test_python_variant_skips_yaml_probe_for_manifest_less_preset( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A preset with no preset.yml only needs the conventional template + fallback; importing/probing PyYAML for it (which can spawn a + SPECKIT_PYTHON child process) is unnecessary per-preset overhead that + `resolve_template_content` would otherwise pay for every manifest-less + preset (#4445).""" + preset_dir = tmp_path / "preset" + (preset_dir / "templates").mkdir(parents=True) + conventional = preset_dir / "templates" / f"{TEMPLATE}.md" + conventional.write_text("# Preset\n", encoding="utf-8") + + def fail_if_called() -> object: + raise AssertionError("_import_yaml should not be called without a manifest") + + monkeypatch.setattr(python_common, "_import_yaml", fail_if_called) + + assert python_common._preset_template_layer(preset_dir, TEMPLATE) == ( + conventional, + "replace", + ) + + +@requires_bash +def test_python_variant_reports_concise_error_for_malformed_delegated_manifest( + tmp_path: Path, +) -> None: + """A syntactically invalid manifest parsed via delegation must fail with + a concise message, matching the in-process parser, instead of leaking + the child interpreter's raw Python traceback into the user-facing + error (#4445).""" + repo, _ = _setup_repo(tmp_path) + + manifest = repo / ".specify" / "presets" / "wrap-pack" / "preset.yml" + manifest.write_text("provides: [\n", encoding="utf-8") + + no_yaml_exe = make_yaml_less_venv(tmp_path / "no-yaml-venv") + + py_script = repo / ".specify" / "scripts" / "python" / "resolve_template.py" + env = clean_env() + env["SPECKIT_PYTHON"] = sys.executable + + result = run([str(no_yaml_exe), str(py_script), TEMPLATE, "--json"], repo, env) + + assert result.returncode != 0 + assert result.stdout == "" + assert "Traceback" not in result.stderr + + +@requires_bash +def test_bash_resolves_composed_template_without_bash4_mapfile_builtin( + tmp_path: Path, +) -> None: + """`resolve_template_content` must not rely on `mapfile`, a Bash 4+ + builtin unavailable on macOS's system Bash 3.2 (#4445). Disabling the + builtin for this process reproduces that environment without requiring + an actual Bash 3.2 install.""" + repo, expected = _setup_repo(tmp_path) + + script = repo / ".specify" / "scripts" / "bash" / f"{SCRIPT}.sh" + result = run( + ["bash", "-c", 'enable -n mapfile; source "$0" "$@"', str(script), TEMPLATE, "--json"], + repo, + ) + + assert result.returncode == 0, result.stderr + assert json_stdout(result) == { + "TEMPLATE_NAME": TEMPLATE, + "TEMPLATE_CONTENT": expected, + } @requires_bash