feat(cli): add py script type & Python interpreter resolution (#3278) (#3285)

* feat(cli): add `py` script type & Python interpreter resolution (#3278)

Introduce a third script variant alongside `sh`/`ps` as the foundation
for unifying workflow scripts under a single Python implementation.

- Add `"py": "Python"` to `SCRIPT_TYPE_CHOICES`; `VALID_SCRIPT_TYPES`
  consumers (init workflow step, init command, _helpers) pick it up
  automatically since they derive from that mapping.
- Add `IntegrationBase.resolve_python_interpreter()` (project venv →
  `python3` → `python`, falling back to `python3`).
- Prefix the resolved interpreter when `process_template()` expands
  `{SCRIPT}` for the `py` script type so `.py` scripts run portably
  (notably on Windows); thread `project_root` through callers so venv
  preference works.
- Make `install_scripts()` mark copied `.py` files executable too.

Includes positive and negative unit tests for interpreter resolution,
`py` template processing, the new choice, and script installation.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(cli): return repo-relative venv interpreter & correct docstring

Address PR review feedback on #3285:

- `resolve_python_interpreter()` now returns the venv interpreter as a
  path relative to the project root (`.venv/bin/python` /
  `.venv/Scripts/python.exe`) instead of an absolute/joined path, so the
  generated `{SCRIPT}` invocation stays portable and runnable from the
  repo root regardless of where the project lives.
- Update `install_scripts()` docstring to note `.py` scripts are now
  made executable alongside `.sh`.
- Update tests to assert the repo-relative interpreter path.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(cli): fall back to sys.executable for interpreter resolution

When neither python3 nor python is discoverable on PATH (and no project
venv is found), resolve_python_interpreter() now returns the running
interpreter (sys.executable) so the generated {SCRIPT} invocation works
in the current environment, falling back to "python3" only if that is
also unavailable. Update unit tests accordingly.

* fix(cli): quote py interpreter path when it contains whitespace

For the `py` script type, the resolved interpreter may be an absolute
path containing spaces (notably `sys.executable` under Windows
`Program Files`). Quote it when it contains whitespace so the `{SCRIPT}`
invocation isn't split into multiple arguments. Add positive/negative
tests for the quoting behavior.

* test: guard executable-bit assertions from Windows chmod semantics

The Windows CI job failed because `os.chmod` does not set POSIX
executable bits on Windows, so `install_scripts()` cannot make `.py`/
`.sh` files executable there (nor is it needed — the interpreter is
invoked explicitly). Split the install_scripts test so file-copy
behavior is still verified cross-platform, and skip the executable-bit
assertions on win32 (matching the repo's existing pattern).

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Manfred Riem
2026-07-01 16:34:46 -05:00
committed by GitHub
parent 3b30e40aaa
commit bbe86310ca
8 changed files with 269 additions and 4 deletions

View File

@@ -17,4 +17,8 @@ AGENT_CONFIG: dict[str, dict[str, Any]] = _build_agent_config()
DEFAULT_INIT_INTEGRATION = "copilot" DEFAULT_INIT_INTEGRATION = "copilot"
SCRIPT_TYPE_CHOICES: dict[str, str] = {"sh": "POSIX Shell (bash/zsh)", "ps": "PowerShell"} SCRIPT_TYPE_CHOICES: dict[str, str] = {
"sh": "POSIX Shell (bash/zsh)",
"ps": "PowerShell",
"py": "Python",
}

View File

@@ -17,6 +17,7 @@ import os
import re import re
import shlex import shlex
import shutil import shutil
import sys
from abc import ABC from abc import ABC
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@@ -495,8 +496,8 @@ class IntegrationBase(ABC):
Copies files from this integration's ``scripts/`` directory to Copies files from this integration's ``scripts/`` directory to
``.specify/integrations/<key>/scripts/`` in the project. Shell ``.specify/integrations/<key>/scripts/`` in the project. Shell
scripts are made executable. All copied files are recorded in (``.sh``) and Python (``.py``) scripts are made executable. All
*manifest*. copied files are recorded in *manifest*.
Returns the list of files created. Returns the list of files created.
""" """
@@ -513,7 +514,7 @@ class IntegrationBase(ABC):
continue continue
dst_script = scripts_dest / src_script.name dst_script = scripts_dest / src_script.name
shutil.copy2(src_script, dst_script) shutil.copy2(src_script, dst_script)
if dst_script.suffix == ".sh": if dst_script.suffix in (".sh", ".py"):
dst_script.chmod(dst_script.stat().st_mode | 0o111) dst_script.chmod(dst_script.stat().st_mode | 0o111)
self.record_file_in_manifest(dst_script, project_root, manifest) self.record_file_in_manifest(dst_script, project_root, manifest)
created.append(dst_script) created.append(dst_script)
@@ -538,6 +539,47 @@ class IntegrationBase(ABC):
content, content,
) )
@staticmethod
def resolve_python_interpreter(project_root: Path | None = None) -> str:
"""Resolve a portable Python interpreter command for ``{SCRIPT}``.
Used to build the invocation string for the ``py`` script type so
that ``.py`` workflow scripts run consistently across platforms
(notably Windows, where ``.py`` files are not directly executable).
Resolution order:
1. A project virtual environment (``.venv``) interpreter, if one
exists under *project_root* (POSIX ``bin/python`` or Windows
``Scripts/python.exe``). The returned path is **relative to the
project root** (e.g. ``.venv/bin/python``) so generated
``{SCRIPT}`` invocations stay portable and runnable from the
repo root regardless of where the project lives.
2. ``python3`` on ``PATH``.
3. ``python`` on ``PATH``.
Falls back to the running interpreter (``sys.executable``) when
``PATH`` resolution fails so the generated command is guaranteed
to work in the current environment, and finally to ``"python3"``
if even that is unavailable.
"""
if project_root is not None:
# (existence check path, repo-root-relative invocation string)
venv_candidates = (
(project_root / ".venv" / "bin" / "python", ".venv/bin/python"),
(
project_root / ".venv" / "Scripts" / "python.exe",
".venv/Scripts/python.exe",
),
)
for candidate, relative in venv_candidates:
if candidate.exists():
return relative
for name in ("python3", "python"):
if shutil.which(name):
return name
return sys.executable or "python3"
@staticmethod @staticmethod
def process_template( def process_template(
content: str, content: str,
@@ -545,6 +587,7 @@ class IntegrationBase(ABC):
script_type: str, script_type: str,
arg_placeholder: str = "$ARGUMENTS", arg_placeholder: str = "$ARGUMENTS",
invoke_separator: str = ".", invoke_separator: str = ".",
project_root: Path | None = None,
) -> str: ) -> str:
"""Process a raw command template into agent-ready content. """Process a raw command template into agent-ready content.
@@ -578,6 +621,17 @@ class IntegrationBase(ABC):
# 2. Replace {SCRIPT} # 2. Replace {SCRIPT}
if script_command: if script_command:
# For the Python script type, prefix the resolved interpreter so
# the command is portable (``.py`` files are not directly
# executable on Windows).
if script_type == "py":
interpreter = IntegrationBase.resolve_python_interpreter(project_root)
# Quote the interpreter if it contains whitespace (e.g. an
# absolute ``sys.executable`` path under Windows
# ``Program Files``) so it isn't split into multiple args.
if any(ch.isspace() for ch in interpreter):
interpreter = f'"{interpreter}"'
script_command = f"{interpreter} {script_command}"
content = content.replace("{SCRIPT}", script_command) content = content.replace("{SCRIPT}", script_command)
# 3. Strip scripts: section from frontmatter # 3. Strip scripts: section from frontmatter
@@ -784,6 +838,7 @@ class MarkdownIntegration(IntegrationBase):
raw = src_file.read_text(encoding="utf-8") raw = src_file.read_text(encoding="utf-8")
processed = self.process_template( processed = self.process_template(
raw, self.key, script_type, arg_placeholder, raw, self.key, script_type, arg_placeholder,
project_root=project_root,
) )
dst_name = self.command_filename(src_file.stem) dst_name = self.command_filename(src_file.stem)
dst_file = self.write_file_and_record( dst_file = self.write_file_and_record(
@@ -986,6 +1041,7 @@ class TomlIntegration(IntegrationBase):
description = self._extract_description(raw) description = self._extract_description(raw)
processed = self.process_template( processed = self.process_template(
raw, self.key, script_type, arg_placeholder, raw, self.key, script_type, arg_placeholder,
project_root=project_root,
) )
_, body = self._split_frontmatter(processed) _, body = self._split_frontmatter(processed)
toml_content = self._render_toml(description, body) toml_content = self._render_toml(description, body)
@@ -1186,6 +1242,7 @@ class YamlIntegration(IntegrationBase):
processed = self.process_template( processed = self.process_template(
raw, self.key, script_type, arg_placeholder, raw, self.key, script_type, arg_placeholder,
project_root=project_root,
) )
_, body = self._split_frontmatter(processed) _, body = self._split_frontmatter(processed)
yaml_content = self._render_yaml( yaml_content = self._render_yaml(
@@ -1381,6 +1438,7 @@ class SkillsIntegration(IntegrationBase):
# Process body through the standard template pipeline # Process body through the standard template pipeline
processed_body = self.process_template( processed_body = self.process_template(
raw, self.key, script_type, arg_placeholder, raw, self.key, script_type, arg_placeholder,
project_root=project_root,
invoke_separator=self.invoke_separator, invoke_separator=self.invoke_separator,
) )
# Strip the processed frontmatter — we rebuild it for skills. # Strip the processed frontmatter — we rebuild it for skills.

View File

@@ -370,6 +370,7 @@ class CopilotIntegration(IntegrationBase):
raw = src_file.read_text(encoding="utf-8") raw = src_file.read_text(encoding="utf-8")
processed = self.process_template( processed = self.process_template(
raw, self.key, script_type, arg_placeholder, raw, self.key, script_type, arg_placeholder,
project_root=project_root,
) )
dst_name = self.command_filename(src_file.stem) dst_name = self.command_filename(src_file.stem)
dst_file = self.write_file_and_record( dst_file = self.write_file_and_record(

View File

@@ -134,6 +134,7 @@ class ForgeIntegration(MarkdownIntegration):
processed = self.process_template( processed = self.process_template(
raw, self.key, script_type, arg_placeholder, raw, self.key, script_type, arg_placeholder,
invoke_separator=self.invoke_separator, invoke_separator=self.invoke_separator,
project_root=project_root,
) )
# FORGE-SPECIFIC: Ensure any remaining $ARGUMENTS placeholders are # FORGE-SPECIFIC: Ensure any remaining $ARGUMENTS placeholders are

View File

@@ -123,6 +123,7 @@ class GenericIntegration(MarkdownIntegration):
raw = src_file.read_text(encoding="utf-8") raw = src_file.read_text(encoding="utf-8")
processed = self.process_template( processed = self.process_template(
raw, self.key, script_type, arg_placeholder, raw, self.key, script_type, arg_placeholder,
project_root=project_root,
) )
dst_name = self.command_filename(src_file.stem) dst_name = self.command_filename(src_file.stem)
dst_file = self.write_file_and_record( dst_file = self.write_file_and_record(

View File

@@ -140,6 +140,7 @@ class HermesIntegration(SkillsIntegration):
script_type, script_type,
arg_placeholder, arg_placeholder,
invoke_separator=self.invoke_separator, invoke_separator=self.invoke_separator,
project_root=project_root,
) )
# Strip the processed frontmatter — we rebuild it for skills. # Strip the processed frontmatter — we rebuild it for skills.
if processed_body.startswith("---"): if processed_body.startswith("---"):

View File

@@ -1,5 +1,7 @@
"""Tests for IntegrationOption, IntegrationBase, MarkdownIntegration, and primitives.""" """Tests for IntegrationOption, IntegrationBase, MarkdownIntegration, and primitives."""
import sys
import pytest import pytest
from specify_cli.integrations.base import ( from specify_cli.integrations.base import (
@@ -299,3 +301,186 @@ class TestResolveCommandRefs:
text = "__SPECKIT_COMMAND_V2_PLAN__" text = "__SPECKIT_COMMAND_V2_PLAN__"
result = IntegrationBase.resolve_command_refs(text, ".") result = IntegrationBase.resolve_command_refs(text, ".")
assert result == "/speckit.v2.plan" assert result == "/speckit.v2.plan"
class TestResolvePythonInterpreter:
def test_returns_python_on_path(self, monkeypatch):
# Positive: when python3 is on PATH it is preferred over python.
def fake_which(name):
return f"/usr/bin/{name}" if name in ("python3", "python") else None
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which", fake_which
)
assert IntegrationBase.resolve_python_interpreter() == "python3"
def test_falls_back_to_python_when_no_python3(self, monkeypatch):
def fake_which(name):
return "/usr/bin/python" if name == "python" else None
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which", fake_which
)
assert IntegrationBase.resolve_python_interpreter() == "python"
def test_falls_back_to_sys_executable_when_nothing_found(self, monkeypatch):
# Negative: nothing on PATH and no venv -> the running interpreter
# (sys.executable) is used so the command works in this environment.
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which", lambda name: None
)
monkeypatch.setattr(
"specify_cli.integrations.base.sys.executable", "/opt/py/bin/python"
)
assert IntegrationBase.resolve_python_interpreter() == "/opt/py/bin/python"
def test_falls_back_to_python3_when_no_interpreter_at_all(self, monkeypatch):
# Negative edge: neither PATH nor sys.executable resolves.
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which", lambda name: None
)
monkeypatch.setattr(
"specify_cli.integrations.base.sys.executable", ""
)
assert IntegrationBase.resolve_python_interpreter() == "python3"
def test_prefers_project_venv_posix(self, monkeypatch, tmp_path):
venv_python = tmp_path / ".venv" / "bin" / "python"
venv_python.parent.mkdir(parents=True)
venv_python.write_text("")
# Even if python3 is on PATH, the project venv wins. The returned
# path is relative to the project root for portability.
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which",
lambda name: "/usr/bin/python3",
)
result = IntegrationBase.resolve_python_interpreter(tmp_path)
assert result == ".venv/bin/python"
def test_prefers_project_venv_windows(self, monkeypatch, tmp_path):
venv_python = tmp_path / ".venv" / "Scripts" / "python.exe"
venv_python.parent.mkdir(parents=True)
venv_python.write_text("")
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which", lambda name: None
)
result = IntegrationBase.resolve_python_interpreter(tmp_path)
assert result == ".venv/Scripts/python.exe"
def test_ignores_missing_venv(self, monkeypatch, tmp_path):
# Negative: no venv directory -> PATH resolution is used instead.
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which",
lambda name: "/usr/bin/python3" if name == "python3" else None,
)
assert IntegrationBase.resolve_python_interpreter(tmp_path) == "python3"
class TestProcessTemplatePyScriptType:
CONTENT = (
"---\n"
"scripts:\n"
" sh: scripts/bash/check-prerequisites.sh --json\n"
" ps: scripts/powershell/check-prerequisites.ps1 -Json\n"
" py: scripts/python/check-prerequisites.py --json\n"
"---\n"
"Run {SCRIPT} now."
)
def test_py_prefixes_interpreter(self, monkeypatch):
# Positive: py script type prefixes a resolved interpreter and the
# script path is rewritten to the .specify location.
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which",
lambda name: "/usr/bin/python3" if name == "python3" else None,
)
result = IntegrationBase.process_template(self.CONTENT, "agent", "py")
assert "python3 .specify/scripts/python/check-prerequisites.py --json" in result
# The scripts: frontmatter block is stripped.
assert "scripts:" not in result
def test_sh_does_not_prefix_interpreter(self):
# Negative: non-py script types are never prefixed with an interpreter.
result = IntegrationBase.process_template(self.CONTENT, "agent", "sh")
assert ".specify/scripts/bash/check-prerequisites.sh --json" in result
assert "python" not in result
def test_py_quotes_interpreter_with_spaces(self, monkeypatch):
# An interpreter path containing whitespace (e.g. Windows
# ``Program Files``) must be quoted so it isn't split into args.
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which", lambda name: None
)
monkeypatch.setattr(
"specify_cli.integrations.base.sys.executable",
r"C:\Program Files\Python\python.exe",
)
result = IntegrationBase.process_template(self.CONTENT, "agent", "py")
assert (
'"C:\\Program Files\\Python\\python.exe" '
".specify/scripts/python/check-prerequisites.py --json"
) in result
def test_py_does_not_quote_interpreter_without_spaces(self, monkeypatch):
# Negative: a whitespace-free interpreter is left unquoted.
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which",
lambda name: "/usr/bin/python3" if name == "python3" else None,
)
result = IntegrationBase.process_template(self.CONTENT, "agent", "py")
assert '"' not in result.split("check-prerequisites.py")[0]
def test_py_uses_project_venv(self, monkeypatch, tmp_path):
venv_python = tmp_path / ".venv" / "bin" / "python"
venv_python.parent.mkdir(parents=True)
venv_python.write_text("")
result = IntegrationBase.process_template(
self.CONTENT, "agent", "py", project_root=tmp_path
)
assert ".venv/bin/python .specify/scripts/python/check-prerequisites.py" in result
class TestInstallScriptsPython:
def _make_integration_with_scripts(self, monkeypatch, tmp_path):
scripts_src = tmp_path / "bundled_scripts"
scripts_src.mkdir()
(scripts_src / "common.py").write_text("print('hi')\n")
(scripts_src / "common.sh").write_text("echo hi\n")
(scripts_src / "notes.txt").write_text("not executable\n")
integration = StubIntegration()
monkeypatch.setattr(
integration, "integration_scripts_dir", lambda: scripts_src
)
return integration
def test_copies_all_script_files(self, monkeypatch, tmp_path):
# Cross-platform: every bundled file is copied into the project.
integration = self._make_integration_with_scripts(monkeypatch, tmp_path)
project_root = tmp_path / "proj"
project_root.mkdir()
manifest = IntegrationManifest("stub", project_root.resolve())
created = integration.install_scripts(project_root, manifest)
names = {p.name for p in created}
assert {"common.py", "common.sh", "notes.txt"} == names
@pytest.mark.skipif(
sys.platform == "win32", reason="chmod exec bit not reliable on Windows"
)
def test_marks_py_and_sh_executable(self, monkeypatch, tmp_path):
integration = self._make_integration_with_scripts(monkeypatch, tmp_path)
project_root = tmp_path / "proj"
project_root.mkdir()
manifest = IntegrationManifest("stub", project_root.resolve())
integration.install_scripts(project_root, manifest)
dest = project_root / ".specify" / "integrations" / "stub" / "scripts"
py_file = dest / "common.py"
sh_file = dest / "common.sh"
txt_file = dest / "notes.txt"
# Positive: .py and .sh are executable.
assert py_file.stat().st_mode & 0o111
assert sh_file.stat().st_mode & 0o111
# Negative: a non-script file is not made executable.
assert not (txt_file.stat().st_mode & 0o111)

View File

@@ -24,6 +24,20 @@ def test_agent_config_importable():
assert "sh" in SCRIPT_TYPE_CHOICES assert "sh" in SCRIPT_TYPE_CHOICES
def test_script_type_choices_includes_python():
from specify_cli._agent_config import SCRIPT_TYPE_CHOICES
assert SCRIPT_TYPE_CHOICES.get("py") == "Python"
# The three supported variants are sh, ps, and py.
assert {"sh", "ps", "py"} <= set(SCRIPT_TYPE_CHOICES)
def test_workflow_init_valid_script_types_includes_python():
from specify_cli.workflows.steps.init import VALID_SCRIPT_TYPES
assert "py" in VALID_SCRIPT_TYPES
# Negative: an unknown variant is not accepted.
assert "rb" not in VALID_SCRIPT_TYPES
def test_agent_config_re_exported_from_init(): def test_agent_config_re_exported_from_init():
from specify_cli import AGENT_CONFIG, SCRIPT_TYPE_CHOICES from specify_cli import AGENT_CONFIG, SCRIPT_TYPE_CHOICES
assert isinstance(AGENT_CONFIG, dict) assert isinstance(AGENT_CONFIG, dict)