From 5c3f82666a9070f8ac812a08fbcbf597d908fc39 Mon Sep 17 00:00:00 2001 From: Hugo Biais Date: Sun, 12 Apr 2026 01:09:42 +0200 Subject: [PATCH] fix(gateway): fall back to python3 when venv lacks bare python symlink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `get_python_path()` only checked `venv/bin/python`, so if that symlink was missing the helper fell through to `sys.executable`, which on a uv-managed install resolves to the base interpreter. `generate_systemd_unit()` then baked the base interpreter into `ExecStart`, while still setting `VIRTUAL_ENV=` and `PATH=` to the venv — causing the service to start without the venv's `site-packages` and crash with `ModuleNotFoundError` for any dependency installed into the venv (e.g. `yaml`). Try `python`, then `python3`, then `pythonX.Y` inside the venv before giving up on it, so the generated unit uses an interpreter that can actually import the Hermes dependencies. Fixes #7976 --- hermes_cli/gateway.py | 16 +++++-- tests/hermes_cli/test_gateway_service.py | 56 ++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 908d8992a09ce..e1c4a54c46cf6 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -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 diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py index cba3a8192f1d8..3e73d60675645 100644 --- a/tests/hermes_cli/test_gateway_service.py +++ b/tests/hermes_cli/test_gateway_service.py @@ -2,6 +2,7 @@ import os import pwd +import sys from pathlib import Path from types import SimpleNamespace @@ -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."""