From 2221bb54a609719a7db864e5471b138f6c8f87ac Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:41:55 -0500 Subject: [PATCH 1/5] feat(cli): add `py` script type & Python interpreter resolution (#3278) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- src/specify_cli/_agent_config.py | 6 +- src/specify_cli/integrations/base.py | 45 +++++- .../integrations/copilot/__init__.py | 1 + .../integrations/forge/__init__.py | 1 + .../integrations/generic/__init__.py | 1 + .../integrations/hermes/__init__.py | 1 + tests/integrations/test_base.py | 131 ++++++++++++++++++ tests/test_commands_package.py | 14 ++ 8 files changed, 198 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/_agent_config.py b/src/specify_cli/_agent_config.py index 6e3a9e6890..3befc19643 100644 --- a/src/specify_cli/_agent_config.py +++ b/src/specify_cli/_agent_config.py @@ -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", +} diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index c820fd4eed..aeedcf7af4 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -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) @@ -538,6 +538,38 @@ 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, @@ -545,6 +577,7 @@ def process_template( 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. @@ -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 @@ -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( @@ -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) @@ -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( @@ -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. diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py index 5cc34d2b1d..922340bc3a 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -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( diff --git a/src/specify_cli/integrations/forge/__init__.py b/src/specify_cli/integrations/forge/__init__.py index 8c21353fec..d0a8cc7abb 100644 --- a/src/specify_cli/integrations/forge/__init__.py +++ b/src/specify_cli/integrations/forge/__init__.py @@ -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 diff --git a/src/specify_cli/integrations/generic/__init__.py b/src/specify_cli/integrations/generic/__init__.py index d874273559..a2fd430f75 100644 --- a/src/specify_cli/integrations/generic/__init__.py +++ b/src/specify_cli/integrations/generic/__init__.py @@ -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( diff --git a/src/specify_cli/integrations/hermes/__init__.py b/src/specify_cli/integrations/hermes/__init__.py index e094dcfcfe..f4bc43be6e 100644 --- a/src/specify_cli/integrations/hermes/__init__.py +++ b/src/specify_cli/integrations/hermes/__init__.py @@ -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("---"): diff --git a/tests/integrations/test_base.py b/tests/integrations/test_base.py index 9ec7d236c1..b1d3923ca6 100644 --- a/tests/integrations/test_base.py +++ b/tests/integrations/test_base.py @@ -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) diff --git a/tests/test_commands_package.py b/tests/test_commands_package.py index e2252ffc11..b8cd262e89 100644 --- a/tests/test_commands_package.py +++ b/tests/test_commands_package.py @@ -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) From 24c0e2fa89984b1f1cf548409299541c74a51bd4 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:56:36 -0500 Subject: [PATCH 2/5] 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> --- src/specify_cli/integrations/base.py | 21 ++++++++++++++------- tests/integrations/test_base.py | 9 +++++---- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index aeedcf7af4..1ccc241945 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -495,8 +495,8 @@ def install_scripts( Copies files from this integration's ``scripts/`` directory to ``.specify/integrations//scripts/`` in the project. Shell - scripts are made executable. All copied files are recorded in - *manifest*. + (``.sh``) and Python (``.py``) scripts are made executable. All + copied files are recorded in *manifest*. Returns the list of files created. """ @@ -550,7 +550,10 @@ def resolve_python_interpreter(project_root: Path | None = None) -> str: 1. A project virtual environment (``.venv``) interpreter, if one exists under *project_root* (POSIX ``bin/python`` or Windows - ``Scripts/python.exe``). + ``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``. @@ -558,13 +561,17 @@ def resolve_python_interpreter(project_root: Path | None = None) -> str: generated command remains well-formed. """ if project_root is not None: + # (existence check path, repo-root-relative invocation string) venv_candidates = ( - project_root / ".venv" / "bin" / "python", - project_root / ".venv" / "Scripts" / "python.exe", + (project_root / ".venv" / "bin" / "python", ".venv/bin/python"), + ( + project_root / ".venv" / "Scripts" / "python.exe", + ".venv/Scripts/python.exe", + ), ) - for candidate in venv_candidates: + for candidate, relative in venv_candidates: if candidate.exists(): - return str(candidate) + return relative for name in ("python3", "python"): if shutil.which(name): return name diff --git a/tests/integrations/test_base.py b/tests/integrations/test_base.py index b1d3923ca6..a165d93492 100644 --- a/tests/integrations/test_base.py +++ b/tests/integrations/test_base.py @@ -332,13 +332,14 @@ 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. + # 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 == str(venv_python) + assert result == ".venv/bin/python" def test_prefers_project_venv_windows(self, monkeypatch, tmp_path): venv_python = tmp_path / ".venv" / "Scripts" / "python.exe" @@ -348,7 +349,7 @@ def test_prefers_project_venv_windows(self, monkeypatch, tmp_path): "specify_cli.integrations.base.shutil.which", lambda name: None ) result = IntegrationBase.resolve_python_interpreter(tmp_path) - assert result == str(venv_python) + assert result == ".venv/Scripts/python.exe" def test_ignores_missing_venv(self, monkeypatch, tmp_path): # Negative: no venv directory -> PATH resolution is used instead. @@ -395,7 +396,7 @@ def test_py_uses_project_venv(self, monkeypatch, tmp_path): result = IntegrationBase.process_template( self.CONTENT, "agent", "py", project_root=tmp_path ) - assert str(venv_python) in result + assert ".venv/bin/python .specify/scripts/python/check-prerequisites.py" in result class TestInstallScriptsPython: From e1534e4a6e9263bf3a744e7180b60faddfca4622 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:09:20 -0500 Subject: [PATCH 3/5] 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. --- src/specify_cli/integrations/base.py | 9 ++++++--- tests/integrations/test_base.py | 18 ++++++++++++++++-- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 1ccc241945..18f0d5b530 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -17,6 +17,7 @@ import re import shlex import shutil +import sys from abc import ABC from dataclasses import dataclass from pathlib import Path @@ -557,8 +558,10 @@ def resolve_python_interpreter(project_root: Path | None = None) -> str: 2. ``python3`` on ``PATH``. 3. ``python`` on ``PATH``. - Falls back to ``"python3"`` when nothing is discoverable so the - generated command remains well-formed. + 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) @@ -575,7 +578,7 @@ def resolve_python_interpreter(project_root: Path | None = None) -> str: for name in ("python3", "python"): if shutil.which(name): return name - return "python3" + return sys.executable or "python3" @staticmethod def process_template( diff --git a/tests/integrations/test_base.py b/tests/integrations/test_base.py index a165d93492..56c45c24d1 100644 --- a/tests/integrations/test_base.py +++ b/tests/integrations/test_base.py @@ -321,11 +321,25 @@ def fake_which(name): ) 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. + 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): From 6329ef44c44687ac96c20f06ebee235c91a8b5fc Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:26:13 -0500 Subject: [PATCH 4/5] 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. --- src/specify_cli/integrations/base.py | 5 +++++ tests/integrations/test_base.py | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 18f0d5b530..d5ebce78e2 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -626,6 +626,11 @@ def process_template( # 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) diff --git a/tests/integrations/test_base.py b/tests/integrations/test_base.py index 56c45c24d1..12be654e97 100644 --- a/tests/integrations/test_base.py +++ b/tests/integrations/test_base.py @@ -403,6 +403,31 @@ def test_sh_does_not_prefix_interpreter(self): 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) From 802d521530d86a243a45692c6593372de3e3eb40 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:25:46 -0500 Subject: [PATCH 5/5] test: guard executable-bit assertions from Windows chmod semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- tests/integrations/test_base.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/integrations/test_base.py b/tests/integrations/test_base.py index 12be654e97..fe531e6245 100644 --- a/tests/integrations/test_base.py +++ b/tests/integrations/test_base.py @@ -1,5 +1,7 @@ """Tests for IntegrationOption, IntegrationBase, MarkdownIntegration, and primitives.""" +import sys + import pytest from specify_cli.integrations.base import ( @@ -451,7 +453,8 @@ def _make_integration_with_scripts(self, monkeypatch, tmp_path): ) return integration - def test_copies_py_and_marks_executable(self, monkeypatch, tmp_path): + 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() @@ -461,6 +464,17 @@ def test_copies_py_and_marks_executable(self, monkeypatch, tmp_path): 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"