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
34 changes: 20 additions & 14 deletions scripts/bash/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -399,26 +399,32 @@ 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"
Comment thread
Copilot marked this conversation as resolved.
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 &&
python -c 'import sys; raise SystemExit(sys.version_info.major != 3)' >/dev/null 2>&1; then
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
}

_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 "
Expand Down Expand Up @@ -513,11 +519,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
Expand Down Expand Up @@ -636,11 +642,11 @@ 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 _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
Expand Down
7 changes: 7 additions & 0 deletions scripts/powershell/common.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
104 changes: 100 additions & 4 deletions scripts/python/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import json
import os
import re
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
Expand Down Expand Up @@ -380,20 +381,115 @@ 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 YAML value>"


_NON_NATIVE_MARKER_KEY = "$speckit_non_native"


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 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:
child_env = dict(os.environ, PYTHONIOENCODING="utf-8")
try:
proc = subprocess.run(
[
self._python_exe,
"-c",
"import sys, json, yaml\n"
"def _default(value):\n"
f" return {{'{_NON_NATIVE_MARKER_KEY}': True}}\n"
"json.dump(yaml.safe_load(sys.stdin.read()), sys.stdout, default=_default)",
],
input=text,
capture_output=True,
encoding="utf-8",
env=child_env,
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, object_hook=_delegated_yaml_object_hook)
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:
"""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:
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():
Expand Down
7 changes: 7 additions & 0 deletions tests/parity_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@ def clean_env() -> dict[str, str]:
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 collation_range_locale() -> str | None:
"""A locale whose ``[a-z]`` bracket range is collation-ordered, or ``None``.

Expand Down
Loading