Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/specify_cli/_agent_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,8 @@ def _build_agent_config() -> dict[str, dict[str, Any]]:

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",
}
45 changes: 44 additions & 1 deletion src/specify_cli/integrations/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,7 @@ def install_scripts(
continue
dst_script = scripts_dest / src_script.name
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)
self.record_file_in_manifest(dst_script, project_root, manifest)
created.append(dst_script)
Expand All @@ -538,13 +538,46 @@ def resolve_command_refs(content: str, separator: str = ".") -> str:
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``).
2. ``python3`` on ``PATH``.
3. ``python`` on ``PATH``.

Falls back to ``"python3"`` when nothing is discoverable so the
generated command remains well-formed.
"""
if project_root is not None:
venv_candidates = (
project_root / ".venv" / "bin" / "python",
project_root / ".venv" / "Scripts" / "python.exe",
)
for candidate in venv_candidates:
if candidate.exists():
return str(candidate)
for name in ("python3", "python"):
if shutil.which(name):
return name
return "python3"

@staticmethod
def process_template(
content: str,
agent_name: str,
script_type: str,
arg_placeholder: str = "$ARGUMENTS",
invoke_separator: str = ".",
project_root: Path | None = None,
) -> str:
"""Process a raw command template into agent-ready content.

Expand Down Expand Up @@ -578,6 +611,12 @@ def process_template(

# 2. Replace {SCRIPT}
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)
script_command = f"{interpreter} {script_command}"
content = content.replace("{SCRIPT}", script_command)

# 3. Strip scripts: section from frontmatter
Expand Down Expand Up @@ -784,6 +823,7 @@ def setup(
raw = src_file.read_text(encoding="utf-8")
processed = self.process_template(
raw, self.key, script_type, arg_placeholder,
project_root=project_root,
)
dst_name = self.command_filename(src_file.stem)
dst_file = self.write_file_and_record(
Expand Down Expand Up @@ -986,6 +1026,7 @@ def setup(
description = self._extract_description(raw)
processed = self.process_template(
raw, self.key, script_type, arg_placeholder,
project_root=project_root,
)
_, body = self._split_frontmatter(processed)
toml_content = self._render_toml(description, body)
Expand Down Expand Up @@ -1186,6 +1227,7 @@ def setup(

processed = self.process_template(
raw, self.key, script_type, arg_placeholder,
project_root=project_root,
)
_, body = self._split_frontmatter(processed)
yaml_content = self._render_yaml(
Expand Down Expand Up @@ -1381,6 +1423,7 @@ def setup(
# Process body through the standard template pipeline
processed_body = self.process_template(
raw, self.key, script_type, arg_placeholder,
project_root=project_root,
invoke_separator=self.invoke_separator,
)
# Strip the processed frontmatter — we rebuild it for skills.
Expand Down
1 change: 1 addition & 0 deletions src/specify_cli/integrations/copilot/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,7 @@ def _setup_default(
raw = src_file.read_text(encoding="utf-8")
processed = self.process_template(
raw, self.key, script_type, arg_placeholder,
project_root=project_root,
)
dst_name = self.command_filename(src_file.stem)
dst_file = self.write_file_and_record(
Expand Down
1 change: 1 addition & 0 deletions src/specify_cli/integrations/forge/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ def setup(
processed = self.process_template(
raw, self.key, script_type, arg_placeholder,
invoke_separator=self.invoke_separator,
project_root=project_root,
)

# FORGE-SPECIFIC: Ensure any remaining $ARGUMENTS placeholders are
Expand Down
1 change: 1 addition & 0 deletions src/specify_cli/integrations/generic/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ def setup(
raw = src_file.read_text(encoding="utf-8")
processed = self.process_template(
raw, self.key, script_type, arg_placeholder,
project_root=project_root,
)
dst_name = self.command_filename(src_file.stem)
dst_file = self.write_file_and_record(
Expand Down
1 change: 1 addition & 0 deletions src/specify_cli/integrations/hermes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ def setup(
script_type,
arg_placeholder,
invoke_separator=self.invoke_separator,
project_root=project_root,
)
# Strip the processed frontmatter — we rebuild it for skills.
if processed_body.startswith("---"):
Expand Down
131 changes: 131 additions & 0 deletions tests/integrations/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,3 +299,134 @@ def test_placeholder_with_digits(self):
text = "__SPECKIT_COMMAND_V2_PLAN__"
result = IntegrationBase.resolve_command_refs(text, ".")
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_python3_when_nothing_found(self, monkeypatch):
# Negative: nothing on PATH and no venv -> well-formed default.
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which", lambda name: None
)
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.
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which",
lambda name: "/usr/bin/python3",
)
result = IntegrationBase.resolve_python_interpreter(tmp_path)
assert result == str(venv_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 == str(venv_python)

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_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 str(venv_python) 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_py_and_marks_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())

created = integration.install_scripts(project_root, manifest)
names = {p.name for p in created}
assert {"common.py", "common.sh", "notes.txt"} == names

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)
14 changes: 14 additions & 0 deletions tests/test_commands_package.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,20 @@ def test_agent_config_importable():
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():
from specify_cli import AGENT_CONFIG, SCRIPT_TYPE_CHOICES
assert isinstance(AGENT_CONFIG, dict)
Expand Down
Loading