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:
Marsel Safin
2026-07-21 18:40:15 +02:00
committed by GitHub
parent 7873c447bd
commit 2a0ada9a6a
28 changed files with 2883 additions and 167 deletions

View File

@@ -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()