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
16 changes: 12 additions & 4 deletions hermes_cli/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -720,11 +720,19 @@ def get_python_path() -> str:
venv = _detect_venv_dir()
if venv is not None:
if is_windows():
venv_python = venv / "Scripts" / "python.exe"
candidates = [venv / "Scripts" / "python.exe"]
else:
venv_python = venv / "bin" / "python"
if venv_python.exists():
return str(venv_python)
major_minor = f"python{sys.version_info.major}.{sys.version_info.minor}"
# uv-managed venvs may omit the bare ``python`` symlink, so fall
# back to ``python3`` / ``pythonX.Y`` before giving up on the venv.
candidates = [
venv / "bin" / "python",
venv / "bin" / "python3",
venv / "bin" / major_minor,
]
for candidate in candidates:
if candidate.exists():
return str(candidate)
return sys.executable


Expand Down
56 changes: 56 additions & 0 deletions tests/hermes_cli/test_gateway_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import os
import pwd
import sys
from pathlib import Path
from types import SimpleNamespace

Expand Down Expand Up @@ -599,6 +600,61 @@ def test_returns_none_when_no_virtualenv(self, tmp_path, monkeypatch):
assert result is None


class TestGetPythonPath:
"""Tests for get_python_path() interpreter resolution."""

def _make_venv(self, tmp_path, *names):
venv = tmp_path / "venv"
bin_dir = venv / "bin"
bin_dir.mkdir(parents=True)
for name in names:
(bin_dir / name).write_text("")
return venv

def test_prefers_bare_python_when_available(self, tmp_path, monkeypatch):
venv = self._make_venv(tmp_path, "python", "python3", "python3.11")
monkeypatch.setattr(gateway_cli, "_detect_venv_dir", lambda: venv)
monkeypatch.setattr(gateway_cli, "is_windows", lambda: False)

result = gateway_cli.get_python_path()
assert result == str(venv / "bin" / "python")

def test_falls_back_to_python3_when_bare_python_missing(self, tmp_path, monkeypatch):
# Reproduces the uv-managed venv layout that broke systemd ExecStart:
# no bare ``python`` symlink, only ``python3`` / ``pythonX.Y``.
venv = self._make_venv(tmp_path, "python3", "python3.11")
monkeypatch.setattr(gateway_cli, "_detect_venv_dir", lambda: venv)
monkeypatch.setattr(gateway_cli, "is_windows", lambda: False)

result = gateway_cli.get_python_path()
assert result == str(venv / "bin" / "python3")

def test_falls_back_to_versioned_python(self, tmp_path, monkeypatch):
major_minor = f"python{sys.version_info.major}.{sys.version_info.minor}"
venv = self._make_venv(tmp_path, major_minor)
monkeypatch.setattr(gateway_cli, "_detect_venv_dir", lambda: venv)
monkeypatch.setattr(gateway_cli, "is_windows", lambda: False)

result = gateway_cli.get_python_path()
assert result == str(venv / "bin" / major_minor)

def test_returns_sys_executable_when_venv_has_no_interpreter(self, tmp_path, monkeypatch):
venv = self._make_venv(tmp_path) # empty bin dir
monkeypatch.setattr(gateway_cli, "_detect_venv_dir", lambda: venv)
monkeypatch.setattr(gateway_cli, "is_windows", lambda: False)
monkeypatch.setattr("sys.executable", "/opt/fallback/python")

result = gateway_cli.get_python_path()
assert result == "/opt/fallback/python"

def test_returns_sys_executable_when_no_venv_detected(self, monkeypatch):
monkeypatch.setattr(gateway_cli, "_detect_venv_dir", lambda: None)
monkeypatch.setattr("sys.executable", "/usr/bin/python3")

result = gateway_cli.get_python_path()
assert result == "/usr/bin/python3"


class TestSystemUnitHermesHome:
"""HERMES_HOME in system units must reference the target user, not root."""

Expand Down