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:
@@ -10,12 +10,18 @@ and ``process_template`` turns them into a valid Python invocation
|
||||
existence check below enforces that ordering.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.integrations.base import IntegrationBase
|
||||
from tests.parity_helpers import HAS_POWERSHELL, POWERSHELL_EXE
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
TEMPLATES_DIR = REPO_ROOT / "templates" / "commands"
|
||||
@@ -77,6 +83,122 @@ def test_template_renders_python_invocation(name: str):
|
||||
), f"{name} did not render a Python invocation"
|
||||
|
||||
|
||||
def test_py_missing_variant_rejects_opposite_shell_only():
|
||||
opposite_variant = "sh" if os.name == "nt" else "ps"
|
||||
opposite_command = (
|
||||
"scripts/bash/setup-plan.sh --json"
|
||||
if opposite_variant == "sh"
|
||||
else "scripts/powershell/setup-plan.ps1 -Json"
|
||||
)
|
||||
content = """---
|
||||
scripts:
|
||||
{variant}: {command}
|
||||
---
|
||||
Run {{SCRIPT}} now.
|
||||
""".format(variant=opposite_variant, command=opposite_command)
|
||||
|
||||
with pytest.raises(ValueError, match="No runnable script variant"):
|
||||
IntegrationBase.process_template(content, "agent", "py")
|
||||
|
||||
|
||||
def test_missing_script_preference_keeps_available_shell(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.platform.system", lambda: "Windows"
|
||||
)
|
||||
|
||||
selected = IntegrationBase.select_script_variant(
|
||||
None, {"sh": "scripts/bash/setup-plan.sh --json"}
|
||||
)
|
||||
|
||||
assert selected == "sh"
|
||||
|
||||
|
||||
def test_spaced_python_interpreter_uses_powershell_call_operator(monkeypatch):
|
||||
interpreter = r"C:\Program Files\Py$thon's\python.exe"
|
||||
quoted_interpreter = interpreter.replace("'", "''")
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which", lambda name: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.sys.executable",
|
||||
interpreter,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.os", SimpleNamespace(name="nt")
|
||||
)
|
||||
|
||||
content = "---\nscripts:\n py: scripts/python/setup_plan.py --json\n---\n{SCRIPT}\n"
|
||||
result = IntegrationBase.process_template(content, "agent", "py")
|
||||
|
||||
assert (
|
||||
f"& '{quoted_interpreter}' "
|
||||
".specify/scripts/python/setup_plan.py --json"
|
||||
) in result
|
||||
|
||||
|
||||
def test_spaced_python_interpreter_uses_posix_shell_quoting(monkeypatch):
|
||||
interpreter = "/opt/Python $HOME's/bin/python"
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which", lambda name: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.sys.executable",
|
||||
interpreter,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.os", SimpleNamespace(name="posix")
|
||||
)
|
||||
|
||||
content = "---\nscripts:\n py: scripts/python/setup_plan.py --json\n---\n{SCRIPT}\n"
|
||||
result = IntegrationBase.process_template(content, "agent", "py")
|
||||
|
||||
assert (
|
||||
f"{shlex.quote(interpreter)} "
|
||||
".specify/scripts/python/setup_plan.py --json"
|
||||
) in result
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
def test_spaced_python_interpreter_invocation_runs_in_powershell(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
interpreter_dir = tmp_path / "Python With Spaces"
|
||||
interpreter_dir.mkdir()
|
||||
if os.name == "nt":
|
||||
interpreter = interpreter_dir / "python.cmd"
|
||||
interpreter.write_text(f'@"{sys.executable}" %*\n', encoding="utf-8")
|
||||
else:
|
||||
interpreter = interpreter_dir / "python"
|
||||
interpreter.write_text(
|
||||
f'#!/bin/sh\nexec "{sys.executable}" "$@"\n', encoding="utf-8"
|
||||
)
|
||||
interpreter.chmod(0o755)
|
||||
|
||||
(tmp_path / "probe.py").write_text("print('ok')\n", encoding="utf-8")
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which", lambda name: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.sys.executable", str(interpreter)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.os", SimpleNamespace(name="nt")
|
||||
)
|
||||
|
||||
content = "---\nscripts:\n py: probe.py\n---\n{SCRIPT}\n"
|
||||
command = IntegrationBase.process_template(content, "agent", "py").splitlines()[-1]
|
||||
result = subprocess.run(
|
||||
[POWERSHELL_EXE, "-NoProfile", "-Command", command],
|
||||
cwd=tmp_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == "ok"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", PY_TEMPLATES)
|
||||
def test_sh_rendering_unchanged(name: str):
|
||||
# Negative: adding py: lines must not leak into sh rendering.
|
||||
@@ -102,6 +224,9 @@ def test_install_shared_infra_copies_python_scripts(tmp_path):
|
||||
console=Console(quiet=True),
|
||||
force=False,
|
||||
)
|
||||
dest = tmp_path / ".specify" / "scripts" / "python"
|
||||
assert (dest / "check_prerequisites.py").is_file()
|
||||
assert not (tmp_path / ".specify" / "scripts" / "powershell").exists()
|
||||
scripts_dir = tmp_path / ".specify" / "scripts"
|
||||
assert (scripts_dir / "python" / "check_prerequisites.py").is_file()
|
||||
shell_variant = "powershell" if os.name == "nt" else "bash"
|
||||
other_variant = "bash" if os.name == "nt" else "powershell"
|
||||
assert (scripts_dir / shell_variant).is_dir()
|
||||
assert not (scripts_dir / other_variant).exists()
|
||||
|
||||
Reference in New Issue
Block a user