diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index f2ed932c4..110234a03 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 581abfb51..1b07cc771 100644 --- a/src/specify_cli/shared_infra.py +++ b/src/specify_cli/shared_infra.py @@ -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 diff --git a/templates/commands/analyze.md b/templates/commands/analyze.md index e4ba8f7d8..2cd83bd7c 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 e202ebb66..6a5c6d874 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 4948fdcfa..a52ef3942 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 35cf3736c..eadb96ee5 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 eda580d56..5efbcf724 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/taskstoissues.md b/templates/commands/taskstoissues.md index f1df10001..6b60e6f6a 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 000000000..6ad687964 --- /dev/null +++ b/tests/test_command_template_py_scripts.py @@ -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()