Skip to content

Commit 55da30c

Browse files
marcelsafinCopilot
andauthored
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>
1 parent 0624180 commit 55da30c

9 files changed

Lines changed: 110 additions & 3 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,9 @@ def _install_shared_infra(
141141
142142
Copies ``.specify/scripts/<variant>/`` and ``.specify/templates/`` from
143143
the bundled core_pack or source checkout, where ``<variant>`` is
144-
``bash`` when *script_type* is ``"sh"`` and ``powershell`` when it is
145-
``"ps"``. Tracks all installed files in ``speckit.manifest.json``.
144+
``bash`` when *script_type* is ``"sh"``, ``python`` when it is ``"py"``,
145+
and ``powershell`` when it is ``"ps"``. Tracks all installed files in
146+
``speckit.manifest.json``.
146147
147148
Shared scripts and page templates are processed to resolve
148149
``__SPECKIT_COMMAND_<NAME>__`` placeholders using *invoke_separator*

src/specify_cli/shared_infra.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -403,7 +403,7 @@ def _is_managed(rel: str, dst: Path) -> bool:
403403
# manifest entries the core no longer ships (stale-script cleanup, #3076).
404404
seen_rels: set[str] = set()
405405
scripts_scanned = False
406-
variant_dir = "bash" if script_type == "sh" else "powershell"
406+
variant_dir = {"sh": "bash", "py": "python"}.get(script_type, "powershell")
407407

408408
def _decide_overwrite(rel: str, dst: Path) -> tuple[bool, str | None]:
409409
"""Return (write, bucket) where bucket is 'skip', 'preserved', or None."""
@@ -465,6 +465,10 @@ def _ensure_or_bucket_dir(directory: Path) -> bool:
465465
for src_path in variant_src.rglob("*"):
466466
if not src_path.is_file():
467467
continue
468+
# Python bytecode caches are local artifacts, not
469+
# workflow scripts — never install them.
470+
if "__pycache__" in src_path.parts:
471+
continue
468472
# Mark scanned only once a real source file is seen. An
469473
# empty (or symlink-skipped) variant keeps this False, so
470474
# stale-cleanup is skipped — otherwise it would treat every

templates/commands/analyze.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ description: Perform a non-destructive cross-artifact consistency and quality an
33
scripts:
44
sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks
55
ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
6+
py: scripts/python/check_prerequisites.py --json --require-tasks --include-tasks
67
---
78

89
## User Input

templates/commands/checklist.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ description: Generate a custom checklist for the current feature based on user r
33
scripts:
44
sh: scripts/bash/check-prerequisites.sh --json
55
ps: scripts/powershell/check-prerequisites.ps1 -Json
6+
py: scripts/python/check_prerequisites.py --json
67
---
78

89
## Checklist Purpose: "Unit Tests for English"

templates/commands/clarify.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ handoffs:
77
scripts:
88
sh: scripts/bash/check-prerequisites.sh --json --paths-only
99
ps: scripts/powershell/check-prerequisites.ps1 -Json -PathsOnly
10+
py: scripts/python/check_prerequisites.py --json --paths-only
1011
---
1112

1213
## User Input

templates/commands/converge.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ description: Assess the current codebase against the feature's spec, plan, and t
33
scripts:
44
sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks
55
ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
6+
py: scripts/python/check_prerequisites.py --json --require-tasks --include-tasks
67
---
78

89
## User Input

templates/commands/implement.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ description: Execute the implementation plan by processing and executing all tas
33
scripts:
44
sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks
55
ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
6+
py: scripts/python/check_prerequisites.py --json --require-tasks --include-tasks
67
---
78

89
## User Input

templates/commands/taskstoissues.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ tools: ['github/github-mcp-server/list_issues', 'github/github-mcp-server/issue_
44
scripts:
55
sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks
66
ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
7+
py: scripts/python/check_prerequisites.py --json --require-tasks --include-tasks
78
---
89

910
## User Input
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"""Command templates with a py: script line must render for --script py.
2+
3+
Covers #3283: ``py:`` lines in the ``scripts:`` frontmatter of
4+
``templates/commands/*.md`` reference Python scripts that exist in the repo,
5+
and ``process_template`` turns them into a valid Python invocation
6+
(interpreter-prefixed, path rewritten to the ``.specify`` tree).
7+
8+
``plan.md`` and ``tasks.md`` gain their ``py:`` lines together with
9+
``setup_plan.py``/``setup_tasks.py`` in the core-scripts port (#3280); the
10+
existence check below enforces that ordering.
11+
"""
12+
13+
import re
14+
from pathlib import Path
15+
16+
import pytest
17+
18+
from specify_cli.integrations.base import IntegrationBase
19+
20+
REPO_ROOT = Path(__file__).parent.parent
21+
TEMPLATES_DIR = REPO_ROOT / "templates" / "commands"
22+
23+
_PY_LINE = re.compile(r"^\s*py: (scripts/python/\S+\.py)", re.MULTILINE)
24+
25+
26+
def _py_script(name: str) -> str | None:
27+
match = _PY_LINE.search((TEMPLATES_DIR / name).read_text(encoding="utf-8"))
28+
return match.group(1) if match else None
29+
30+
31+
PY_TEMPLATES = sorted(
32+
p.name for p in TEMPLATES_DIR.glob("*.md") if _py_script(p.name)
33+
)
34+
35+
36+
@pytest.fixture(autouse=True)
37+
def _pin_interpreter(monkeypatch):
38+
monkeypatch.setattr(
39+
"specify_cli.integrations.base.shutil.which",
40+
lambda name: "/usr/bin/python3" if name == "python3" else None,
41+
)
42+
43+
44+
def test_py_templates_discovered():
45+
# Guard: the glob must find the known py-scripted templates, otherwise
46+
# the parametrized tests below would silently pass on an empty set.
47+
assert "implement.md" in PY_TEMPLATES
48+
assert "clarify.md" in PY_TEMPLATES
49+
50+
51+
@pytest.mark.parametrize("name", PY_TEMPLATES)
52+
def test_referenced_python_script_exists(name: str):
53+
# A py: line must never point at a script the repo does not ship —
54+
# rendering would produce a broken invocation at runtime.
55+
script = _py_script(name)
56+
assert (REPO_ROOT / script).is_file(), f"{name} references missing {script}"
57+
58+
59+
@pytest.mark.parametrize("name", PY_TEMPLATES)
60+
def test_template_renders_python_invocation(name: str):
61+
content = (TEMPLATES_DIR / name).read_text(encoding="utf-8")
62+
result = IntegrationBase.process_template(content, "agent", "py")
63+
assert "{SCRIPT}" not in result
64+
assert re.search(
65+
r"python3 \.specify/scripts/python/\w+\.py(?: --[\w-]+)*", result
66+
), f"{name} did not render a Python invocation"
67+
68+
69+
@pytest.mark.parametrize("name", PY_TEMPLATES)
70+
def test_sh_rendering_unchanged(name: str):
71+
# Negative: adding py: lines must not leak into sh rendering.
72+
content = (TEMPLATES_DIR / name).read_text(encoding="utf-8")
73+
result = IntegrationBase.process_template(content, "agent", "sh")
74+
assert "{SCRIPT}" not in result
75+
assert "scripts/python" not in result
76+
77+
78+
def test_install_shared_infra_copies_python_scripts(tmp_path):
79+
# --script py must install scripts/python/ into .specify/scripts/python/
80+
# so the rendered invocations point at files that exist.
81+
from rich.console import Console
82+
83+
from specify_cli.shared_infra import install_shared_infra
84+
85+
install_shared_infra(
86+
tmp_path,
87+
"py",
88+
version="0.0.0",
89+
core_pack=None,
90+
repo_root=REPO_ROOT,
91+
console=Console(quiet=True),
92+
force=False,
93+
)
94+
dest = tmp_path / ".specify" / "scripts" / "python"
95+
assert (dest / "check_prerequisites.py").is_file()
96+
assert not (tmp_path / ".specify" / "scripts" / "powershell").exists()

0 commit comments

Comments
 (0)