hermes-agent/tests/hermes_cli/test_gateway_python_path.py
linux2010 a844ff0ddc fix(gateway): use PROJECT_ROOT/venv/bin/python as fallback instead of sys.executable
## What broke
After `hermes gateway install --system`, the systemd service ExecStart
uses the uv-managed Python path instead of the venv Python path. This
causes immediate crash with `ModuleNotFoundError: No module named 'yaml'`
because the uv Python has no packages installed.

ExecStart=/root/.local/share/uv/python/cpython-3.11.15-linux-x86_64-gnu/bin/python3.11
Should be:
ExecStart=/root/.hermes/hermes-agent/venv/bin/python3.11

## Root cause
`get_python_path()` in hermes_cli/gateway.py fell back to `sys.executable`
when `_detect_venv_dir()` returned None. In uv-managed environments,
`sys.executable` returns the uv Python path which has no packages.

Meanwhile, `generate_systemd_unit()` already uses `PROJECT_ROOT/venv` as
fallback for `venv_dir` (line 819), creating an inconsistency:
- `venv_dir` = PROJECT_ROOT/venv (correct)
- `python_path` = sys.executable (wrong - uv path)

## Why this fix is minimal
Added 11 lines to `get_python_path()` to add a middle fallback tier:
1. detected_venv/bin/python (existing - highest priority)
2. PROJECT_ROOT/venv/bin/python (NEW - matches generate_systemd_unit)
3. sys.executable (existing - final fallback)

This makes `get_python_path()` consistent with `venv_dir` calculation
in `generate_systemd_unit()`. No behavior change when venv is detected.

## What I tested
Added test suite tests/hermes_cli/test_gateway_python_path.py with 8 tests:
- test_fallback_to_project_root_venv_when_detect_fails
- test_detect_venv_dir_returns_valid_venv
- test_fallback_chain_order
- test_consistency_with_venv_dir_in_systemd_unit
- test_windows_fallback_uses_scripts_python_exe
- test_execstart_not_uv_python_path
- test_system_unit_execstart_uses_venv_python
- test_user_unit_execstart_uses_venv_python

All verify ExecStart uses venv/bin/python, not uv Python path.

## What I intentionally did not change
- No changes to _detect_venv_dir() logic
- No changes to generate_systemd_unit() venv_dir calculation
- No opportunistic refactoring
- No changes to launchd plist generation (similar pattern)

Fixes #9201
2026-04-14 11:46:21 +00:00

152 lines
No EOL
6.7 KiB
Python

"""Test for get_python_path() fallback to PROJECT_ROOT/venv/bin/python.
Issue: #9201 - systemd service ExecStart uses uv Python instead of venv Python
when uv manages the Python environment.
Root cause: get_python_path() fell back to sys.executable when _detect_venv_dir()
returned None, but this returns uv's Python path which has no packages installed.
"""
import sys
import pytest
from pathlib import Path
from unittest.mock import patch, MagicMock
class TestGetPythonPathFallback:
"""Test that get_python_path() correctly falls back to PROJECT_ROOT/venv."""
def test_fallback_to_project_root_venv_when_detect_fails(self):
"""When _detect_venv_dir() returns None, should use PROJECT_ROOT/venv."""
from hermes_cli.gateway import get_python_path, PROJECT_ROOT
# Mock _detect_venv_dir to return None (simulate uv environment)
with patch("hermes_cli.gateway._detect_venv_dir", return_value=None):
# Mock the venv python to exist
with patch.object(Path, "exists", return_value=True):
result = get_python_path()
# Should be PROJECT_ROOT/venv/bin/python, not sys.executable
expected = str(PROJECT_ROOT / "venv" / "bin" / "python")
assert result == expected
def test_detect_venv_dir_returns_valid_venv(self):
"""When _detect_venv_dir() returns a valid venv, should use it."""
from hermes_cli.gateway import get_python_path
# Mock _detect_venv_dir to return a venv
mock_venv = MagicMock(spec=Path)
mock_venv.is_dir.return_value = True
with patch("hermes_cli.gateway._detect_venv_dir", return_value=mock_venv):
# Mock the venv python to exist
mock_python = MagicMock(spec=Path)
mock_python.exists.return_value = True
with patch.object(Path, "__truediv__", return_value=mock_python):
result = get_python_path()
# Should return the venv python path
assert result is not None
def test_fallback_chain_order(self):
"""Fallback order should be: detected_venv -> PROJECT_ROOT/venv -> sys.executable."""
from hermes_cli.gateway import get_python_path, PROJECT_ROOT
# Test 1: detected_venv has priority
mock_venv = MagicMock(spec=Path)
mock_venv_python = MagicMock(spec=Path)
mock_venv_python.exists.return_value = True
with patch("hermes_cli.gateway._detect_venv_dir", return_value=mock_venv):
with patch.object(Path, "__truediv__", return_value=mock_venv_python):
result = get_python_path()
# Should use detected_venv, not PROJECT_ROOT fallback
# Test 2: PROJECT_ROOT/venv fallback when detect fails but venv exists
with patch("hermes_cli.gateway._detect_venv_dir", return_value=None):
with patch.object(Path, "exists", return_value=True):
result = get_python_path()
expected = str(PROJECT_ROOT / "venv" / "bin" / "python")
assert result == expected
# Test 3: sys.executable when all fallbacks fail
with patch("hermes_cli.gateway._detect_venv_dir", return_value=None):
with patch.object(Path, "exists", return_value=False):
result = get_python_path()
assert result == sys.executable
def test_consistency_with_venv_dir_in_systemd_unit(self):
"""python_path should be consistent with venv_dir in generate_systemd_unit."""
from hermes_cli.gateway import (
get_python_path,
generate_systemd_unit,
PROJECT_ROOT,
_detect_venv_dir,
)
# Simulate scenario where _detect_venv_dir returns None
with patch("hermes_cli.gateway._detect_venv_dir", return_value=None):
with patch.object(Path, "exists", return_value=True):
python_path = get_python_path()
unit = generate_systemd_unit(system=False)
# python_path should match venv/bin/python in ExecStart
assert "venv/bin/python" in python_path
assert python_path in unit
def test_windows_fallback_uses_scripts_python_exe(self):
"""On Windows, fallback should use venv/Scripts/python.exe."""
from hermes_cli.gateway import get_python_path, PROJECT_ROOT
with patch("hermes_cli.gateway._detect_venv_dir", return_value=None):
with patch("hermes_cli.gateway.is_windows", return_value=True):
with patch.object(Path, "exists", return_value=True):
result = get_python_path()
expected = str(PROJECT_ROOT / "venv" / "Scripts" / "python.exe")
assert result == expected
def test_execstart_not_uv_python_path(self):
"""ExecStart should NOT contain uv Python path patterns."""
from hermes_cli.gateway import generate_systemd_unit
with patch("hermes_cli.gateway._detect_venv_dir", return_value=None):
with patch.object(Path, "exists", return_value=True):
unit = generate_systemd_unit(system=False)
# Should NOT have uv-specific paths
assert ".local/share/uv/python" not in unit
assert "cpython-" not in unit
# Should have venv path
assert "venv/bin/python" in unit
class TestSystemdUnitGeneration:
"""Test systemd unit file generation with correct Python path."""
def test_system_unit_execstart_uses_venv_python(self):
"""System unit ExecStart should use venv/bin/python."""
from hermes_cli.gateway import generate_systemd_unit, PROJECT_ROOT
with patch("hermes_cli.gateway._detect_venv_dir", return_value=None):
with patch.object(Path, "exists", return_value=True):
unit = generate_systemd_unit(system=True, run_as_user="root")
# ExecStart should contain venv/bin/python
assert "venv/bin/python" in unit
assert "ExecStart=" in unit
def test_user_unit_execstart_uses_venv_python(self):
"""User unit ExecStart should use venv/bin/python."""
from hermes_cli.gateway import generate_systemd_unit, PROJECT_ROOT
with patch("hermes_cli.gateway._detect_venv_dir", return_value=None):
with patch.object(Path, "exists", return_value=True):
unit = generate_systemd_unit(system=False)
# ExecStart should contain venv/bin/python
assert "venv/bin/python" in unit
if __name__ == "__main__":
pytest.main([__file__, "-v"])