From 9abc4a5f449ffb291d6054d2bd12578b13c2162e Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Wed, 8 Jul 2026 08:31:00 +0200 Subject: [PATCH 1/4] 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> --- templates/commands/analyze.md | 1 + templates/commands/checklist.md | 1 + templates/commands/clarify.md | 1 + templates/commands/converge.md | 1 + templates/commands/implement.md | 1 + templates/commands/plan.md | 1 + templates/commands/tasks.md | 1 + templates/commands/taskstoissues.md | 1 + tests/test_command_template_py_scripts.py | 62 +++++++++++++++++++++++ 9 files changed, 70 insertions(+) create mode 100644 tests/test_command_template_py_scripts.py diff --git a/templates/commands/analyze.md b/templates/commands/analyze.md index e4ba8f7d81..2cd83bd7c0 100644 --- a/templates/commands/analyze.md +++ b/templates/commands/analyze.md @@ -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 diff --git a/templates/commands/checklist.md b/templates/commands/checklist.md index e202ebb667..6a5c6d8745 100644 --- a/templates/commands/checklist.md +++ b/templates/commands/checklist.md @@ -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" diff --git a/templates/commands/clarify.md b/templates/commands/clarify.md index 4948fdcfaf..a52ef3942a 100644 --- a/templates/commands/clarify.md +++ b/templates/commands/clarify.md @@ -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 diff --git a/templates/commands/converge.md b/templates/commands/converge.md index 35cf3736c3..eadb96ee58 100644 --- a/templates/commands/converge.md +++ b/templates/commands/converge.md @@ -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 diff --git a/templates/commands/implement.md b/templates/commands/implement.md index eda580d560..5efbcf7243 100644 --- a/templates/commands/implement.md +++ b/templates/commands/implement.md @@ -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 diff --git a/templates/commands/plan.md b/templates/commands/plan.md index e82bd4b303..3a56c671ca 100644 --- a/templates/commands/plan.md +++ b/templates/commands/plan.md @@ -11,6 +11,7 @@ handoffs: scripts: sh: scripts/bash/setup-plan.sh --json ps: scripts/powershell/setup-plan.ps1 -Json + py: scripts/python/setup_plan.py --json --- ## User Input diff --git a/templates/commands/tasks.md b/templates/commands/tasks.md index 4d3e45a7c4..454d7e3b32 100644 --- a/templates/commands/tasks.md +++ b/templates/commands/tasks.md @@ -12,6 +12,7 @@ handoffs: scripts: sh: scripts/bash/setup-tasks.sh --json ps: scripts/powershell/setup-tasks.ps1 -Json + py: scripts/python/setup_tasks.py --json --- ## User Input diff --git a/templates/commands/taskstoissues.md b/templates/commands/taskstoissues.md index f1df100010..6b60e6f6a8 100644 --- a/templates/commands/taskstoissues.md +++ b/templates/commands/taskstoissues.md @@ -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 diff --git a/tests/test_command_template_py_scripts.py b/tests/test_command_template_py_scripts.py new file mode 100644 index 0000000000..df27240d5d --- /dev/null +++ b/tests/test_command_template_py_scripts.py @@ -0,0 +1,62 @@ +"""Every command template with a scripts: block must render for --script py. + +Covers #3283: each ``templates/commands/*.md`` that declares a ``scripts:`` +frontmatter block carries a ``py:`` line, and ``process_template`` turns it +into a valid Python invocation (interpreter-prefixed, path rewritten to the +``.specify`` tree). +""" + +import re +from pathlib import Path + +import pytest + +from specify_cli.integrations.base import IntegrationBase + +TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "commands" + +SCRIPTED_TEMPLATES = sorted( + p.name for p in TEMPLATES_DIR.glob("*.md") if "\nscripts:\n" in p.read_text() +) + + +@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_scripted_templates_discovered(): + # Guard: the glob must find the known scripted templates, otherwise the + # parametrized tests below would silently pass on an empty set. + assert "plan.md" in SCRIPTED_TEMPLATES + assert "implement.md" in SCRIPTED_TEMPLATES + + +@pytest.mark.parametrize("name", SCRIPTED_TEMPLATES) +def test_template_declares_py_script(name: str): + content = (TEMPLATES_DIR / name).read_text() + assert re.search(r"^\s*py: scripts/python/\S+\.py", content, re.MULTILINE), ( + f"{name} has a scripts: block but no py: line" + ) + + +@pytest.mark.parametrize("name", SCRIPTED_TEMPLATES) +def test_template_renders_python_invocation(name: str): + content = (TEMPLATES_DIR / name).read_text() + 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", SCRIPTED_TEMPLATES) +def test_sh_rendering_unchanged(name: str): + # Negative: adding py: lines must not leak into sh rendering. + content = (TEMPLATES_DIR / name).read_text() + result = IntegrationBase.process_template(content, "agent", "sh") + assert "{SCRIPT}" not in result + assert "scripts/python" not in result From aa7e6278847dde79f600f50c6ab02b6fa0c4ab73 Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Wed, 8 Jul 2026 08:43:06 +0200 Subject: [PATCH 2/4] 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> --- src/specify_cli/__init__.py | 5 +++-- src/specify_cli/shared_infra.py | 6 +++++- tests/test_command_template_py_scripts.py | 21 +++++++++++++++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index f2ed932c46..110234a03e 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -141,8 +141,9 @@ def _install_shared_infra( Copies ``.specify/scripts//`` and ``.specify/templates/`` from the bundled core_pack or source checkout, where ```` 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___`` placeholders using *invoke_separator* diff --git a/src/specify_cli/shared_infra.py b/src/specify_cli/shared_infra.py index 0685b6c9bc..cdf5147581 100644 --- a/src/specify_cli/shared_infra.py +++ b/src/specify_cli/shared_infra.py @@ -400,7 +400,7 @@ def _is_managed(rel: str, dst: Path) -> bool: # 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.""" @@ -462,6 +462,10 @@ def _ensure_or_bucket_dir(directory: Path) -> bool: 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 diff --git a/tests/test_command_template_py_scripts.py b/tests/test_command_template_py_scripts.py index df27240d5d..e1c596a5d4 100644 --- a/tests/test_command_template_py_scripts.py +++ b/tests/test_command_template_py_scripts.py @@ -60,3 +60,24 @@ def test_sh_rendering_unchanged(name: str): 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=Path(__file__).resolve().parents[1], + 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() From 1667e1bed48ff835273675086d5166d24efa9e18 Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Wed, 8 Jul 2026 08:49:03 +0200 Subject: [PATCH 3/4] test: read templates with explicit utf-8 encoding Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/test_command_template_py_scripts.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_command_template_py_scripts.py b/tests/test_command_template_py_scripts.py index e1c596a5d4..534328873e 100644 --- a/tests/test_command_template_py_scripts.py +++ b/tests/test_command_template_py_scripts.py @@ -16,7 +16,7 @@ TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "commands" SCRIPTED_TEMPLATES = sorted( - p.name for p in TEMPLATES_DIR.glob("*.md") if "\nscripts:\n" in p.read_text() + p.name for p in TEMPLATES_DIR.glob("*.md") if "\nscripts:\n" in p.read_text(encoding="utf-8") ) @@ -37,7 +37,7 @@ def test_scripted_templates_discovered(): @pytest.mark.parametrize("name", SCRIPTED_TEMPLATES) def test_template_declares_py_script(name: str): - content = (TEMPLATES_DIR / name).read_text() + content = (TEMPLATES_DIR / name).read_text(encoding="utf-8") assert re.search(r"^\s*py: scripts/python/\S+\.py", content, re.MULTILINE), ( f"{name} has a scripts: block but no py: line" ) @@ -45,7 +45,7 @@ def test_template_declares_py_script(name: str): @pytest.mark.parametrize("name", SCRIPTED_TEMPLATES) def test_template_renders_python_invocation(name: str): - content = (TEMPLATES_DIR / name).read_text() + content = (TEMPLATES_DIR / name).read_text(encoding="utf-8") result = IntegrationBase.process_template(content, "agent", "py") assert "{SCRIPT}" not in result assert re.search( @@ -56,7 +56,7 @@ def test_template_renders_python_invocation(name: str): @pytest.mark.parametrize("name", SCRIPTED_TEMPLATES) def test_sh_rendering_unchanged(name: str): # Negative: adding py: lines must not leak into sh rendering. - content = (TEMPLATES_DIR / name).read_text() + 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 From ee3709fa22ae8ac39dcaa14c31792b5abd696f56 Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Wed, 8 Jul 2026 08:58:14 +0200 Subject: [PATCH 4/4] 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> --- templates/commands/plan.md | 1 - templates/commands/tasks.md | 1 - tests/test_command_template_py_scripts.py | 57 ++++++++++++++--------- 3 files changed, 35 insertions(+), 24 deletions(-) diff --git a/templates/commands/plan.md b/templates/commands/plan.md index 3a56c671ca..e82bd4b303 100644 --- a/templates/commands/plan.md +++ b/templates/commands/plan.md @@ -11,7 +11,6 @@ handoffs: scripts: sh: scripts/bash/setup-plan.sh --json ps: scripts/powershell/setup-plan.ps1 -Json - py: scripts/python/setup_plan.py --json --- ## User Input diff --git a/templates/commands/tasks.md b/templates/commands/tasks.md index 454d7e3b32..4d3e45a7c4 100644 --- a/templates/commands/tasks.md +++ b/templates/commands/tasks.md @@ -12,7 +12,6 @@ handoffs: scripts: sh: scripts/bash/setup-tasks.sh --json ps: scripts/powershell/setup-tasks.ps1 -Json - py: scripts/python/setup_tasks.py --json --- ## User Input diff --git a/tests/test_command_template_py_scripts.py b/tests/test_command_template_py_scripts.py index 534328873e..6ad6879648 100644 --- a/tests/test_command_template_py_scripts.py +++ b/tests/test_command_template_py_scripts.py @@ -1,9 +1,13 @@ -"""Every command template with a scripts: block must render for --script py. +"""Command templates with a py: script line must render for --script py. -Covers #3283: each ``templates/commands/*.md`` that declares a ``scripts:`` -frontmatter block carries a ``py:`` line, and ``process_template`` turns it -into a valid Python invocation (interpreter-prefixed, path rewritten to the -``.specify`` tree). +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 @@ -13,10 +17,19 @@ from specify_cli.integrations.base import IntegrationBase -TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "commands" +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 -SCRIPTED_TEMPLATES = sorted( - p.name for p in TEMPLATES_DIR.glob("*.md") if "\nscripts:\n" in p.read_text(encoding="utf-8") + +PY_TEMPLATES = sorted( + p.name for p in TEMPLATES_DIR.glob("*.md") if _py_script(p.name) ) @@ -28,22 +41,22 @@ def _pin_interpreter(monkeypatch): ) -def test_scripted_templates_discovered(): - # Guard: the glob must find the known scripted templates, otherwise the - # parametrized tests below would silently pass on an empty set. - assert "plan.md" in SCRIPTED_TEMPLATES - assert "implement.md" in SCRIPTED_TEMPLATES +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", SCRIPTED_TEMPLATES) -def test_template_declares_py_script(name: str): - content = (TEMPLATES_DIR / name).read_text(encoding="utf-8") - assert re.search(r"^\s*py: scripts/python/\S+\.py", content, re.MULTILINE), ( - f"{name} has a scripts: block but no py: line" - ) +@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", SCRIPTED_TEMPLATES) +@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") @@ -53,7 +66,7 @@ def test_template_renders_python_invocation(name: str): ), f"{name} did not render a Python invocation" -@pytest.mark.parametrize("name", SCRIPTED_TEMPLATES) +@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") @@ -74,7 +87,7 @@ def test_install_shared_infra_copies_python_scripts(tmp_path): "py", version="0.0.0", core_pack=None, - repo_root=Path(__file__).resolve().parents[1], + repo_root=REPO_ROOT, console=Console(quiet=True), force=False, )