mirror of
https://github.com/github/spec-kit.git
synced 2026-07-11 10:34:06 +08:00
feat(templates): add py: lines to command templates' scripts frontmatter (#3403)
* feat(templates): add py: lines to command templates' scripts frontmatter Every templates/commands/*.md with a scripts: block now declares a py: variant so --script py renders a Python invocation via the existing interpreter-prefixing in process_template. Fixes #3283 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: install scripts/python for the py script type install_shared_infra mapped every non-sh script type to powershell, so --script py rendered invocations pointing at files that were never installed. Map py to the python variant dir and skip __pycache__ artifacts during the copy. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: read templates with explicit utf-8 encoding Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: keep py: lines standalone, enforce script existence in tests Drop the plan/tasks py: lines that referenced scripts shipping in the core port (#3280); they move to that PR. Tests now assert every py: line points at a script the repo ships, so a dangling reference can never merge green. 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:
@@ -141,8 +141,9 @@ def _install_shared_infra(
|
||||
|
||||
Copies ``.specify/scripts/<variant>/`` and ``.specify/templates/`` from
|
||||
the bundled core_pack or source checkout, where ``<variant>`` is
|
||||
``bash`` when *script_type* is ``"sh"`` and ``powershell`` when it is
|
||||
``"ps"``. Tracks all installed files in ``speckit.manifest.json``.
|
||||
``bash`` when *script_type* is ``"sh"``, ``python`` when it is ``"py"``,
|
||||
and ``powershell`` when it is ``"ps"``. Tracks all installed files in
|
||||
``speckit.manifest.json``.
|
||||
|
||||
Shared scripts and page templates are processed to resolve
|
||||
``__SPECKIT_COMMAND_<NAME>__`` placeholders using *invoke_separator*
|
||||
|
||||
@@ -403,7 +403,7 @@ def install_shared_infra(
|
||||
# manifest entries the core no longer ships (stale-script cleanup, #3076).
|
||||
seen_rels: set[str] = set()
|
||||
scripts_scanned = False
|
||||
variant_dir = "bash" if script_type == "sh" else "powershell"
|
||||
variant_dir = {"sh": "bash", "py": "python"}.get(script_type, "powershell")
|
||||
|
||||
def _decide_overwrite(rel: str, dst: Path) -> tuple[bool, str | None]:
|
||||
"""Return (write, bucket) where bucket is 'skip', 'preserved', or None."""
|
||||
@@ -465,6 +465,10 @@ def install_shared_infra(
|
||||
for src_path in variant_src.rglob("*"):
|
||||
if not src_path.is_file():
|
||||
continue
|
||||
# Python bytecode caches are local artifacts, not
|
||||
# workflow scripts — never install them.
|
||||
if "__pycache__" in src_path.parts:
|
||||
continue
|
||||
# Mark scanned only once a real source file is seen. An
|
||||
# empty (or symlink-skipped) variant keeps this False, so
|
||||
# stale-cleanup is skipped — otherwise it would treat every
|
||||
|
||||
@@ -3,6 +3,7 @@ description: Perform a non-destructive cross-artifact consistency and quality an
|
||||
scripts:
|
||||
sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks
|
||||
ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
|
||||
py: scripts/python/check_prerequisites.py --json --require-tasks --include-tasks
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
@@ -3,6 +3,7 @@ description: Generate a custom checklist for the current feature based on user r
|
||||
scripts:
|
||||
sh: scripts/bash/check-prerequisites.sh --json
|
||||
ps: scripts/powershell/check-prerequisites.ps1 -Json
|
||||
py: scripts/python/check_prerequisites.py --json
|
||||
---
|
||||
|
||||
## Checklist Purpose: "Unit Tests for English"
|
||||
|
||||
@@ -7,6 +7,7 @@ handoffs:
|
||||
scripts:
|
||||
sh: scripts/bash/check-prerequisites.sh --json --paths-only
|
||||
ps: scripts/powershell/check-prerequisites.ps1 -Json -PathsOnly
|
||||
py: scripts/python/check_prerequisites.py --json --paths-only
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
@@ -3,6 +3,7 @@ description: Assess the current codebase against the feature's spec, plan, and t
|
||||
scripts:
|
||||
sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks
|
||||
ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
|
||||
py: scripts/python/check_prerequisites.py --json --require-tasks --include-tasks
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
@@ -3,6 +3,7 @@ description: Execute the implementation plan by processing and executing all tas
|
||||
scripts:
|
||||
sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks
|
||||
ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
|
||||
py: scripts/python/check_prerequisites.py --json --require-tasks --include-tasks
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
@@ -4,6 +4,7 @@ tools: ['github/github-mcp-server/list_issues', 'github/github-mcp-server/issue_
|
||||
scripts:
|
||||
sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks
|
||||
ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
|
||||
py: scripts/python/check_prerequisites.py --json --require-tasks --include-tasks
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
96
tests/test_command_template_py_scripts.py
Normal file
96
tests/test_command_template_py_scripts.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""Command templates with a py: script line must render for --script py.
|
||||
|
||||
Covers #3283: ``py:`` lines in the ``scripts:`` frontmatter of
|
||||
``templates/commands/*.md`` reference Python scripts that exist in the repo,
|
||||
and ``process_template`` turns them into a valid Python invocation
|
||||
(interpreter-prefixed, path rewritten to the ``.specify`` tree).
|
||||
|
||||
``plan.md`` and ``tasks.md`` gain their ``py:`` lines together with
|
||||
``setup_plan.py``/``setup_tasks.py`` in the core-scripts port (#3280); the
|
||||
existence check below enforces that ordering.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.integrations.base import IntegrationBase
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
TEMPLATES_DIR = REPO_ROOT / "templates" / "commands"
|
||||
|
||||
_PY_LINE = re.compile(r"^\s*py: (scripts/python/\S+\.py)", re.MULTILINE)
|
||||
|
||||
|
||||
def _py_script(name: str) -> str | None:
|
||||
match = _PY_LINE.search((TEMPLATES_DIR / name).read_text(encoding="utf-8"))
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
PY_TEMPLATES = sorted(
|
||||
p.name for p in TEMPLATES_DIR.glob("*.md") if _py_script(p.name)
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _pin_interpreter(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which",
|
||||
lambda name: "/usr/bin/python3" if name == "python3" else None,
|
||||
)
|
||||
|
||||
|
||||
def test_py_templates_discovered():
|
||||
# Guard: the glob must find the known py-scripted templates, otherwise
|
||||
# the parametrized tests below would silently pass on an empty set.
|
||||
assert "implement.md" in PY_TEMPLATES
|
||||
assert "clarify.md" in PY_TEMPLATES
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", PY_TEMPLATES)
|
||||
def test_referenced_python_script_exists(name: str):
|
||||
# A py: line must never point at a script the repo does not ship —
|
||||
# rendering would produce a broken invocation at runtime.
|
||||
script = _py_script(name)
|
||||
assert (REPO_ROOT / script).is_file(), f"{name} references missing {script}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", PY_TEMPLATES)
|
||||
def test_template_renders_python_invocation(name: str):
|
||||
content = (TEMPLATES_DIR / name).read_text(encoding="utf-8")
|
||||
result = IntegrationBase.process_template(content, "agent", "py")
|
||||
assert "{SCRIPT}" not in result
|
||||
assert re.search(
|
||||
r"python3 \.specify/scripts/python/\w+\.py(?: --[\w-]+)*", result
|
||||
), f"{name} did not render a Python invocation"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", PY_TEMPLATES)
|
||||
def test_sh_rendering_unchanged(name: str):
|
||||
# Negative: adding py: lines must not leak into sh rendering.
|
||||
content = (TEMPLATES_DIR / name).read_text(encoding="utf-8")
|
||||
result = IntegrationBase.process_template(content, "agent", "sh")
|
||||
assert "{SCRIPT}" not in result
|
||||
assert "scripts/python" not in result
|
||||
|
||||
|
||||
def test_install_shared_infra_copies_python_scripts(tmp_path):
|
||||
# --script py must install scripts/python/ into .specify/scripts/python/
|
||||
# so the rendered invocations point at files that exist.
|
||||
from rich.console import Console
|
||||
|
||||
from specify_cli.shared_infra import install_shared_infra
|
||||
|
||||
install_shared_infra(
|
||||
tmp_path,
|
||||
"py",
|
||||
version="0.0.0",
|
||||
core_pack=None,
|
||||
repo_root=REPO_ROOT,
|
||||
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()
|
||||
Reference in New Issue
Block a user