|
1 | 1 | """Tests for IntegrationOption, IntegrationBase, MarkdownIntegration, and primitives.""" |
2 | 2 |
|
| 3 | +import sys |
| 4 | + |
3 | 5 | import pytest |
4 | 6 |
|
5 | 7 | from specify_cli.integrations.base import ( |
@@ -299,3 +301,186 @@ def test_placeholder_with_digits(self): |
299 | 301 | text = "__SPECKIT_COMMAND_V2_PLAN__" |
300 | 302 | result = IntegrationBase.resolve_command_refs(text, ".") |
301 | 303 | assert result == "/speckit.v2.plan" |
| 304 | + |
| 305 | + |
| 306 | +class TestResolvePythonInterpreter: |
| 307 | + def test_returns_python_on_path(self, monkeypatch): |
| 308 | + # Positive: when python3 is on PATH it is preferred over python. |
| 309 | + def fake_which(name): |
| 310 | + return f"/usr/bin/{name}" if name in ("python3", "python") else None |
| 311 | + |
| 312 | + monkeypatch.setattr( |
| 313 | + "specify_cli.integrations.base.shutil.which", fake_which |
| 314 | + ) |
| 315 | + assert IntegrationBase.resolve_python_interpreter() == "python3" |
| 316 | + |
| 317 | + def test_falls_back_to_python_when_no_python3(self, monkeypatch): |
| 318 | + def fake_which(name): |
| 319 | + return "/usr/bin/python" if name == "python" else None |
| 320 | + |
| 321 | + monkeypatch.setattr( |
| 322 | + "specify_cli.integrations.base.shutil.which", fake_which |
| 323 | + ) |
| 324 | + assert IntegrationBase.resolve_python_interpreter() == "python" |
| 325 | + |
| 326 | + def test_falls_back_to_sys_executable_when_nothing_found(self, monkeypatch): |
| 327 | + # Negative: nothing on PATH and no venv -> the running interpreter |
| 328 | + # (sys.executable) is used so the command works in this environment. |
| 329 | + monkeypatch.setattr( |
| 330 | + "specify_cli.integrations.base.shutil.which", lambda name: None |
| 331 | + ) |
| 332 | + monkeypatch.setattr( |
| 333 | + "specify_cli.integrations.base.sys.executable", "/opt/py/bin/python" |
| 334 | + ) |
| 335 | + assert IntegrationBase.resolve_python_interpreter() == "/opt/py/bin/python" |
| 336 | + |
| 337 | + def test_falls_back_to_python3_when_no_interpreter_at_all(self, monkeypatch): |
| 338 | + # Negative edge: neither PATH nor sys.executable resolves. |
| 339 | + monkeypatch.setattr( |
| 340 | + "specify_cli.integrations.base.shutil.which", lambda name: None |
| 341 | + ) |
| 342 | + monkeypatch.setattr( |
| 343 | + "specify_cli.integrations.base.sys.executable", "" |
| 344 | + ) |
| 345 | + assert IntegrationBase.resolve_python_interpreter() == "python3" |
| 346 | + |
| 347 | + def test_prefers_project_venv_posix(self, monkeypatch, tmp_path): |
| 348 | + venv_python = tmp_path / ".venv" / "bin" / "python" |
| 349 | + venv_python.parent.mkdir(parents=True) |
| 350 | + venv_python.write_text("") |
| 351 | + # Even if python3 is on PATH, the project venv wins. The returned |
| 352 | + # path is relative to the project root for portability. |
| 353 | + monkeypatch.setattr( |
| 354 | + "specify_cli.integrations.base.shutil.which", |
| 355 | + lambda name: "/usr/bin/python3", |
| 356 | + ) |
| 357 | + result = IntegrationBase.resolve_python_interpreter(tmp_path) |
| 358 | + assert result == ".venv/bin/python" |
| 359 | + |
| 360 | + def test_prefers_project_venv_windows(self, monkeypatch, tmp_path): |
| 361 | + venv_python = tmp_path / ".venv" / "Scripts" / "python.exe" |
| 362 | + venv_python.parent.mkdir(parents=True) |
| 363 | + venv_python.write_text("") |
| 364 | + monkeypatch.setattr( |
| 365 | + "specify_cli.integrations.base.shutil.which", lambda name: None |
| 366 | + ) |
| 367 | + result = IntegrationBase.resolve_python_interpreter(tmp_path) |
| 368 | + assert result == ".venv/Scripts/python.exe" |
| 369 | + |
| 370 | + def test_ignores_missing_venv(self, monkeypatch, tmp_path): |
| 371 | + # Negative: no venv directory -> PATH resolution is used instead. |
| 372 | + monkeypatch.setattr( |
| 373 | + "specify_cli.integrations.base.shutil.which", |
| 374 | + lambda name: "/usr/bin/python3" if name == "python3" else None, |
| 375 | + ) |
| 376 | + assert IntegrationBase.resolve_python_interpreter(tmp_path) == "python3" |
| 377 | + |
| 378 | + |
| 379 | +class TestProcessTemplatePyScriptType: |
| 380 | + CONTENT = ( |
| 381 | + "---\n" |
| 382 | + "scripts:\n" |
| 383 | + " sh: scripts/bash/check-prerequisites.sh --json\n" |
| 384 | + " ps: scripts/powershell/check-prerequisites.ps1 -Json\n" |
| 385 | + " py: scripts/python/check-prerequisites.py --json\n" |
| 386 | + "---\n" |
| 387 | + "Run {SCRIPT} now." |
| 388 | + ) |
| 389 | + |
| 390 | + def test_py_prefixes_interpreter(self, monkeypatch): |
| 391 | + # Positive: py script type prefixes a resolved interpreter and the |
| 392 | + # script path is rewritten to the .specify location. |
| 393 | + monkeypatch.setattr( |
| 394 | + "specify_cli.integrations.base.shutil.which", |
| 395 | + lambda name: "/usr/bin/python3" if name == "python3" else None, |
| 396 | + ) |
| 397 | + result = IntegrationBase.process_template(self.CONTENT, "agent", "py") |
| 398 | + assert "python3 .specify/scripts/python/check-prerequisites.py --json" in result |
| 399 | + # The scripts: frontmatter block is stripped. |
| 400 | + assert "scripts:" not in result |
| 401 | + |
| 402 | + def test_sh_does_not_prefix_interpreter(self): |
| 403 | + # Negative: non-py script types are never prefixed with an interpreter. |
| 404 | + result = IntegrationBase.process_template(self.CONTENT, "agent", "sh") |
| 405 | + assert ".specify/scripts/bash/check-prerequisites.sh --json" in result |
| 406 | + assert "python" not in result |
| 407 | + |
| 408 | + def test_py_quotes_interpreter_with_spaces(self, monkeypatch): |
| 409 | + # An interpreter path containing whitespace (e.g. Windows |
| 410 | + # ``Program Files``) must be quoted so it isn't split into args. |
| 411 | + monkeypatch.setattr( |
| 412 | + "specify_cli.integrations.base.shutil.which", lambda name: None |
| 413 | + ) |
| 414 | + monkeypatch.setattr( |
| 415 | + "specify_cli.integrations.base.sys.executable", |
| 416 | + r"C:\Program Files\Python\python.exe", |
| 417 | + ) |
| 418 | + result = IntegrationBase.process_template(self.CONTENT, "agent", "py") |
| 419 | + assert ( |
| 420 | + '"C:\\Program Files\\Python\\python.exe" ' |
| 421 | + ".specify/scripts/python/check-prerequisites.py --json" |
| 422 | + ) in result |
| 423 | + |
| 424 | + def test_py_does_not_quote_interpreter_without_spaces(self, monkeypatch): |
| 425 | + # Negative: a whitespace-free interpreter is left unquoted. |
| 426 | + monkeypatch.setattr( |
| 427 | + "specify_cli.integrations.base.shutil.which", |
| 428 | + lambda name: "/usr/bin/python3" if name == "python3" else None, |
| 429 | + ) |
| 430 | + result = IntegrationBase.process_template(self.CONTENT, "agent", "py") |
| 431 | + assert '"' not in result.split("check-prerequisites.py")[0] |
| 432 | + |
| 433 | + def test_py_uses_project_venv(self, monkeypatch, tmp_path): |
| 434 | + venv_python = tmp_path / ".venv" / "bin" / "python" |
| 435 | + venv_python.parent.mkdir(parents=True) |
| 436 | + venv_python.write_text("") |
| 437 | + result = IntegrationBase.process_template( |
| 438 | + self.CONTENT, "agent", "py", project_root=tmp_path |
| 439 | + ) |
| 440 | + assert ".venv/bin/python .specify/scripts/python/check-prerequisites.py" in result |
| 441 | + |
| 442 | + |
| 443 | +class TestInstallScriptsPython: |
| 444 | + def _make_integration_with_scripts(self, monkeypatch, tmp_path): |
| 445 | + scripts_src = tmp_path / "bundled_scripts" |
| 446 | + scripts_src.mkdir() |
| 447 | + (scripts_src / "common.py").write_text("print('hi')\n") |
| 448 | + (scripts_src / "common.sh").write_text("echo hi\n") |
| 449 | + (scripts_src / "notes.txt").write_text("not executable\n") |
| 450 | + integration = StubIntegration() |
| 451 | + monkeypatch.setattr( |
| 452 | + integration, "integration_scripts_dir", lambda: scripts_src |
| 453 | + ) |
| 454 | + return integration |
| 455 | + |
| 456 | + def test_copies_all_script_files(self, monkeypatch, tmp_path): |
| 457 | + # Cross-platform: every bundled file is copied into the project. |
| 458 | + integration = self._make_integration_with_scripts(monkeypatch, tmp_path) |
| 459 | + project_root = tmp_path / "proj" |
| 460 | + project_root.mkdir() |
| 461 | + manifest = IntegrationManifest("stub", project_root.resolve()) |
| 462 | + |
| 463 | + created = integration.install_scripts(project_root, manifest) |
| 464 | + names = {p.name for p in created} |
| 465 | + assert {"common.py", "common.sh", "notes.txt"} == names |
| 466 | + |
| 467 | + @pytest.mark.skipif( |
| 468 | + sys.platform == "win32", reason="chmod exec bit not reliable on Windows" |
| 469 | + ) |
| 470 | + def test_marks_py_and_sh_executable(self, monkeypatch, tmp_path): |
| 471 | + integration = self._make_integration_with_scripts(monkeypatch, tmp_path) |
| 472 | + project_root = tmp_path / "proj" |
| 473 | + project_root.mkdir() |
| 474 | + manifest = IntegrationManifest("stub", project_root.resolve()) |
| 475 | + |
| 476 | + integration.install_scripts(project_root, manifest) |
| 477 | + |
| 478 | + dest = project_root / ".specify" / "integrations" / "stub" / "scripts" |
| 479 | + py_file = dest / "common.py" |
| 480 | + sh_file = dest / "common.sh" |
| 481 | + txt_file = dest / "notes.txt" |
| 482 | + # Positive: .py and .sh are executable. |
| 483 | + assert py_file.stat().st_mode & 0o111 |
| 484 | + assert sh_file.stat().st_mode & 0o111 |
| 485 | + # Negative: a non-script file is not made executable. |
| 486 | + assert not (txt_file.stat().st_mode & 0o111) |
0 commit comments