mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
feat(scripts): port create-new-feature, setup-plan and setup-tasks to Python (#3386)
* feat(scripts): port create-new-feature, setup-plan and setup-tasks to Python Ports the three core workflow scripts to Python as part of #3280, following the check-prerequisites PoC pattern from #3302. Adds resolve_template() to the shared common.py module and parity tests that run bash and Python side by side. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(tests): treat only None env as unset in parity run helper Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): fall back to directory scan on any registry error, skip hidden preset dirs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(templates): add py: lines for setup_plan and setup_tasks Ships with the scripts they reference; the remaining templates got their py: lines in #3403. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: support py variant in skills placeholder resolver resolve_skill_placeholders only accepted sh/ps, so a py init option fell into the fallback path and {SCRIPT} rendered without an interpreter prefix. Accept py and prefix the resolved interpreter, matching process_template. Also guard ps_cmd against a missing PowerShell with a clear assert. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: pin clean-error behavior for invalid --number Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(scripts): reword unused-arg comment to match implementation The loop accepts and silently ignores extra positional args (it doesn't build a collected list); match the wording to what the code and setup-plan.sh actually do. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: fall back when configured script variant is missing from frontmatter Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): reject signed/whitespace --number values to match bash 10# parity The bash twin uses $((10#$BRANCH_NUMBER)), which rejects signed and whitespace-padded values. Python's int() accepted them (e.g. -1), producing a malformed -01-... prefix that sequential scans ignore. Restrict --number to unsigned decimal digits before conversion, and pin the parity with a bash-comparison test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): complete Python port installation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(integrations): fall back for missing script variants Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: make Python script checks platform-aware Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix Windows Python command invocation parity Use PowerShell's call operator for spaced Python interpreter paths and align setup-tasks missing-template errors across script variants. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): preserve cross-platform Python parity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: reject signed PowerShell feature numbers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): align feature number range Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): reject exhausted feature numbers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): complete create feature parity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): align create feature outputs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): harden cross-platform parity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): keep truncation JSON clean Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): align setup failure parity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): close parity edge cases Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): propagate PowerShell setup errors Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): harden fallback resolution Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): stabilize PowerShell fallbacks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): complete setup-plan parity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(cli): require runnable script fallbacks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(cli): preserve shell fallback without preference Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): restore help and symlink parity - setup-tasks.ps1: check -Help before unknown-argument validation so '-Help --bogus' exits 0 like the Bash/Python variants - common.py: strip the repo root prefix lexically in persist_feature_json instead of resolve(), so a symlinked specs/ still persists the relative 'specs/NNN-name' path the Bash/PowerShell helpers store Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): align persist-hint quoting with shlex.quote - create-new-feature.sh: replace printf %q with a shell_quote helper that emits shlex.quote-identical output, so the persistence hints stay byte-identical between the Bash and Python variants (printf %q output also varies between bash versions) - promote the negative --number test to an all-variants parity test now that Bash and PowerShell reject signed values consistently - add a spaced-repo-path parity test for the persistence hints Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -14,6 +14,7 @@ Provides:
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
@@ -668,6 +669,46 @@ class IntegrationBase(ABC):
|
||||
return name
|
||||
return sys.executable or "python3"
|
||||
|
||||
@staticmethod
|
||||
def build_python_invocation(
|
||||
script_command: str, project_root: Path | None = None
|
||||
) -> str:
|
||||
"""Build a Python script command for the current platform shell."""
|
||||
interpreter = IntegrationBase.resolve_python_interpreter(project_root)
|
||||
if os.name == "nt" and not re.fullmatch(r"[A-Za-z0-9_./:\\-]+", interpreter):
|
||||
quoted_interpreter = interpreter.replace("'", "''")
|
||||
interpreter = f"& '{quoted_interpreter}'"
|
||||
elif os.name != "nt":
|
||||
interpreter = shlex.quote(interpreter)
|
||||
return f"{interpreter} {script_command}"
|
||||
|
||||
@staticmethod
|
||||
def select_script_variant(
|
||||
requested: object, script_commands: dict[str, str]
|
||||
) -> str:
|
||||
"""Select the requested variant or a runnable platform fallback."""
|
||||
if isinstance(requested, str) and requested in script_commands:
|
||||
return requested
|
||||
|
||||
platform_variant = (
|
||||
"ps" if platform.system().lower().startswith("win") else "sh"
|
||||
)
|
||||
secondary_variant = "sh" if platform_variant == "ps" else "ps"
|
||||
fallbacks = (
|
||||
(platform_variant, "py")
|
||||
if requested == "py"
|
||||
else (platform_variant, secondary_variant, "py")
|
||||
)
|
||||
for candidate in fallbacks:
|
||||
if candidate in script_commands:
|
||||
return candidate
|
||||
|
||||
available = ", ".join(sorted(script_commands)) or "none"
|
||||
raise ValueError(
|
||||
"No runnable script variant for this platform: "
|
||||
f"requested {requested!r}; available: {available}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _interpreter_runs(path: str) -> bool:
|
||||
"""Return True when *path* executes as a Python interpreter.
|
||||
@@ -702,7 +743,8 @@ class IntegrationBase(ABC):
|
||||
"""Process a raw command template into agent-ready content.
|
||||
|
||||
Performs the same transformations as the release script:
|
||||
1. Extract ``scripts.<script_type>`` value from YAML frontmatter
|
||||
1. Select ``scripts.<script_type>`` from YAML frontmatter, falling
|
||||
back to a runnable platform shell or Python variant when unavailable
|
||||
2. Replace ``{SCRIPT}`` with the extracted script command
|
||||
3. Strip ``scripts:`` section from frontmatter
|
||||
4. Replace ``{ARGS}`` and ``$ARGUMENTS`` with *arg_placeholder*
|
||||
@@ -711,37 +753,46 @@ class IntegrationBase(ABC):
|
||||
7. Replace ``__SPECKIT_COMMAND_<NAME>__`` with invocation strings
|
||||
"""
|
||||
# 1. Extract script command from frontmatter
|
||||
script_command = ""
|
||||
script_pattern = re.compile(
|
||||
rf"^\s*{re.escape(script_type)}:\s*(.+)$", re.MULTILINE
|
||||
)
|
||||
script_commands: dict[str, str] = {}
|
||||
script_pattern = re.compile(r"^\s*([A-Za-z0-9_-]+):\s*(.+)$")
|
||||
# Find the scripts: block
|
||||
in_frontmatter = False
|
||||
in_scripts = False
|
||||
for line in content.splitlines():
|
||||
if line.strip() == "scripts:":
|
||||
if line == "---":
|
||||
if in_frontmatter:
|
||||
break
|
||||
in_frontmatter = True
|
||||
continue
|
||||
if not in_frontmatter:
|
||||
continue
|
||||
if line == "scripts:":
|
||||
in_scripts = True
|
||||
continue
|
||||
if in_scripts and line and not line[0].isspace():
|
||||
in_scripts = False
|
||||
break
|
||||
if in_scripts:
|
||||
m = script_pattern.match(line)
|
||||
if m:
|
||||
script_command = m.group(1).strip()
|
||||
break
|
||||
script_commands[m.group(1)] = m.group(2).strip()
|
||||
|
||||
selected_script_type = (
|
||||
IntegrationBase.select_script_variant(script_type, script_commands)
|
||||
if script_commands
|
||||
else ""
|
||||
)
|
||||
|
||||
script_command = script_commands.get(selected_script_type, "")
|
||||
|
||||
# 2. Replace {SCRIPT}
|
||||
if script_command:
|
||||
# For the Python script type, prefix the resolved interpreter so
|
||||
# the command is portable (``.py`` files are not directly
|
||||
# executable on Windows).
|
||||
if script_type == "py":
|
||||
interpreter = IntegrationBase.resolve_python_interpreter(project_root)
|
||||
# Quote the interpreter if it contains whitespace (e.g. an
|
||||
# absolute ``sys.executable`` path under Windows
|
||||
# ``Program Files``) so it isn't split into multiple args.
|
||||
if any(ch.isspace() for ch in interpreter):
|
||||
interpreter = f'"{interpreter}"'
|
||||
script_command = f"{interpreter} {script_command}"
|
||||
if selected_script_type == "py":
|
||||
script_command = IntegrationBase.build_python_invocation(
|
||||
script_command, project_root
|
||||
)
|
||||
content = content.replace("{SCRIPT}", script_command)
|
||||
|
||||
# 3. Strip scripts: section from frontmatter
|
||||
|
||||
Reference in New Issue
Block a user