mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
* 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:
@@ -1,5 +1,7 @@
|
||||
"""Tests for IntegrationOption, IntegrationBase, MarkdownIntegration, and primitives."""
|
||||
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.integrations.base import (
|
||||
@@ -299,3 +301,186 @@ class TestResolveCommandRefs:
|
||||
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_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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user