Skip to content
Merged
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
11 changes: 8 additions & 3 deletions loopx/claude_goal_mode/scripts/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ def _shell_command(value: str, *, powershell: bool) -> str:


def _launches_python(py: str) -> bool:
"""True when running `py` actually starts an interpreter.
"""True when running `py` starts a supported Python 3.11+ interpreter.

`shutil.which` only proves a file is executable, not that it runs Python. On
Windows it can return the 0-byte `WindowsApps` App Execution Alias, which
Expand All @@ -147,15 +147,20 @@ def _launches_python(py: str) -> bool:
bakes that path (the PreToolUse hook, the statusline, `/loopx`) then
silently does nothing."""
try:
return subprocess.run([py, "-c", ""], capture_output=True, timeout=10).returncode == 0
return subprocess.run(
[py, "-c", "import sys; raise SystemExit(sys.version_info < (3, 11))"],
capture_output=True,
timeout=10,
).returncode == 0
except (OSError, subprocess.SubprocessError):
return False


def _python_cmd() -> str:
"""Interpreter to bake into hook / statusline / command strings — robust where
only `python` (not `python3`) is registered, and where `python3` resolves to a
launcher that cannot run Python (the Windows `WindowsApps` alias)."""
launcher that cannot run supported Python (including the Windows
`WindowsApps` alias and an older PATH interpreter)."""
for candidate in (shutil.which("python3"), shutil.which("python")):
if candidate and _launches_python(candidate):
return candidate
Expand Down
36 changes: 36 additions & 0 deletions tests/test_claude_goal_install_python_launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,42 @@ class Result:
assert claude_install._launches_python("python3") is True


@pytest.mark.parametrize(
("version", "supported"),
[((3, 8), False), ((3, 10), False), ((3, 11), True), ((3, 12), True)],
)
def test_interpreter_probe_requires_the_supported_python_floor(
monkeypatch: pytest.MonkeyPatch, version: tuple[int, int], supported: bool,
) -> None:
real_run = subprocess.run

def probe(command, **kwargs):
# Execute the actual probe with a controlled reported version; no
# obsolete Python installation or host configuration is required.
return real_run(
[sys.executable, "-c", f"import sys; sys.version_info = {version!r}; " + command[2]],
**kwargs,
)

monkeypatch.setattr(claude_install.subprocess, "run", probe)
assert claude_install._launches_python("candidate-python") is supported


def test_python_cmd_skips_a_runnable_but_unsupported_python3(monkeypatch: pytest.MonkeyPatch) -> None:
real_run = subprocess.run
monkeypatch.setattr(claude_install.shutil, "which", lambda name: name)

def probe(command, **kwargs):
version = (3, 10) if command[0] == "python3" else (3, 11)
return real_run(
[sys.executable, "-c", f"import sys; sys.version_info = {version!r}; " + command[2]],
**kwargs,
)

monkeypatch.setattr(claude_install.subprocess, "run", probe)
assert claude_install._python_cmd() == "python"


def _literal_interpreter(tmp_path: Path, dirname: str, request: pytest.FixtureRequest) -> Path:
"""A real launcher for `sys.executable` under `tmp_path / dirname`.

Expand Down
Loading