Files
hermes-agent/tests/test_hermes_constants.py
ethernet f9f235ed1a fix(pm): never adopt another install's venv for a checkout
project_venv_dir() fell back to the running interpreter's venv whenever
hermes_constants was loaded from the checkout. Where the code was loaded
from says nothing about who owns the interpreter: with
`PYTHONPATH=<dev checkout> <app install>/venv/bin/python -m hermes_cli.main`
(a shell wrapper around a dev tree), PROJECT_ROOT is the dev checkout but
the venv is the Desktop install's. base_venv() then returned the app's
venv, `hermes update` synced the dev tree into it, and the Desktop
install's venv became an editable install of the dev checkout. From then
on the Desktop shell tracked ~/.hermes/hermes-agent while its backend and
its handed-off `hermes update` ran and pulled the dev tree, so every
in-app update "succeeded" without moving the install. The same misread
let running_from_selected_environment() accept lazy extras into that
venv.

Only fall back to the running venv when its own hermes-agent install
records this checkout in direct_url.json, which every install of a
checkout into a venv writes (installers, uv sync). Otherwise the
checkout gets its own environment, as it did before 4f6c04cd07. The
#116148 out-of-tree layout (a venv installed from the checkout) still
resolves to the running interpreter.
2026-09-25 01:17:12 -04:00

667 lines
28 KiB
Python

"""Tests for hermes_constants module."""
import json
import os
import sys
from pathlib import Path
import pytest
import hermes_constants
from hermes_platform.host import runtime as host_runtime
from hermes_constants import (
agent_browser_runnable,
get_default_hermes_root,
get_hermes_dir,
get_hermes_home,
get_process_hermes_home,
is_container,
node_tool_runnable,
parse_reasoning_effort,
reset_hermes_home_override,
secure_parent_dir,
set_hermes_home_override,
)
class TestGetDefaultHermesRoot:
"""Tests for get_default_hermes_root() — Docker/custom deployment awareness."""
@pytest.mark.platforms("linux")
def test_no_hermes_home_returns_native(self, tmp_path, monkeypatch):
"""When HERMES_HOME is not set, returns ~/.hermes."""
monkeypatch.delenv("HERMES_HOME", raising=False)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
assert get_default_hermes_root() == tmp_path / ".hermes"
def test_docker_profile_active(self, tmp_path, monkeypatch):
"""When a Docker profile is active (HERMES_HOME=<root>/profiles/<name>),
returns the Docker root, not the profile dir."""
docker_root = tmp_path / "opt" / "data"
profile = docker_root / "profiles" / "coder"
profile.mkdir(parents=True)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(profile))
assert get_default_hermes_root() == docker_root
def test_expanded_custom_profile_returns_custom_root(self, tmp_path, monkeypatch):
custom_root = tmp_path / "deployment"
home_token = "$" + "HOME"
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv(
"HERMES_HOME", f"{home_token}/deployment/profiles/research"
)
monkeypatch.setattr(Path, "home", lambda: tmp_path / "native-home")
assert get_default_hermes_root() == custom_root
@pytest.mark.platforms("windows")
def test_no_hermes_home_returns_localappdata_root_on_windows(self, tmp_path, monkeypatch):
"""Native Windows falls back to %LOCALAPPDATA%\\hermes, not ~/.hermes."""
local_appdata = tmp_path / "LocalAppData"
monkeypatch.delenv("HERMES_HOME", raising=False)
monkeypatch.setenv("LOCALAPPDATA", str(local_appdata))
monkeypatch.setattr(Path, "home", lambda: tmp_path / "Home")
assert get_default_hermes_root() == local_appdata / "hermes"
class TestGetHermesHome:
"""Tests for get_hermes_home() platform-aware fallback."""
def test_warn_once_latch_engages_on_first_check_even_without_warning(self, tmp_path, monkeypatch):
"""Regression for #90065: the latch must engage on the first check even when there is
nothing to warn about, otherwise every get_hermes_home() call re-stats active_profile."""
monkeypatch.delenv("HERMES_HOME", raising=False)
monkeypatch.setattr(hermes_constants, "_profile_fallback_warned", False)
monkeypatch.setattr(hermes_constants, "_get_platform_default_hermes_home", lambda: tmp_path)
get_hermes_home()
assert hermes_constants._profile_fallback_warned is True
@pytest.mark.platforms("windows")
def test_windows_fallback_uses_localappdata(self, tmp_path, monkeypatch):
"""When HERMES_HOME is unset on Windows, use %LOCALAPPDATA%\\hermes."""
local_appdata = tmp_path / "LocalAppData"
monkeypatch.delenv("HERMES_HOME", raising=False)
monkeypatch.setenv("LOCALAPPDATA", str(local_appdata))
monkeypatch.setattr(Path, "home", lambda: tmp_path / "Home")
monkeypatch.setattr(hermes_constants, "_profile_fallback_warned", False)
assert get_hermes_home() == local_appdata / "hermes"
class TestGetProcessHermesHome:
"""Tests for get_process_hermes_home() — process launch scope.
Contract: resolve only the process env / platform default, and never
follow the context-local override that per-task profile scoping installs
via set_hermes_home_override().
"""
def test_env_set_returns_that_path(self, tmp_path, monkeypatch):
home = tmp_path / "launch-home"
monkeypatch.setenv("HERMES_HOME", str(home))
assert get_process_hermes_home() == home
def test_process_and_context_homes_expand_environment_and_user_syntax(
self, tmp_path, monkeypatch
):
home_token = "$" + "HOME"
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
for syntax in (home_token, "~"):
process_home = tmp_path / "process-home"
monkeypatch.setenv("HERMES_HOME", f"{syntax}/process-home")
assert get_process_hermes_home() == process_home
override_home = tmp_path / "override-home"
token = set_hermes_home_override(f"{syntax}/override-home")
try:
assert get_hermes_home() == override_home
assert get_process_hermes_home() == process_home
finally:
reset_hermes_home_override(token)
class TestNodeToolRunnable:
"""Empty executable paths cannot be probed."""
def test_none_and_empty_rejected(self):
assert node_tool_runnable(None) is False
assert node_tool_runnable("") is False
class TestIsContainer:
"""Tests for is_container() — Docker/Podman detection."""
def _reset_cache(self, monkeypatch):
"""Reset the cached detection result before each test."""
monkeypatch.setattr(host_runtime, "_container_detected", None)
def test_detects_dockerenv(self, monkeypatch, tmp_path):
"""/.dockerenv triggers container detection."""
self._reset_cache(monkeypatch)
monkeypatch.setattr(os.path, "exists", lambda p: p == "/.dockerenv")
assert is_container() is True
def test_detects_kubernetes_env(self, monkeypatch):
"""KUBERNETES_SERVICE_HOST env var triggers detection (k8s/k3s pod)."""
self._reset_cache(monkeypatch)
monkeypatch.setattr(os.path, "exists", lambda p: False)
monkeypatch.setenv("KUBERNETES_SERVICE_HOST", "10.43.0.1")
assert is_container() is True
def test_cgroup_v2_fallback_inspects_only_the_root_mount(self, tmp_path):
"""#58135: a host that merely RUNS containers exposes each container's overlay lowerdir
(``lowerdir=/var/lib/containerd/...``) at non-root mount points; only the root ('/') line
says whether *this* process lives in a runtime overlay."""
markers = ("kubepods", "containerd", "crio")
host = tmp_path / "host"
host.write_text(
"25 1 259:2 / / rw,relatime shared:1 - ext4 /dev/nvme0n1p2 rw\n"
"469 554 0:94 / /var/lib/docker/rootfs/overlayfs/7dda83 rw,relatime shared:247 - overlay overlay "
"rw,lowerdir=/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/33509/fs\n"
)
container = tmp_path / "container"
container.write_text(
"1 0 0:50 / / rw,relatime - overlay overlay "
"rw,lowerdir=/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/9/fs\n"
"2 1 0:51 / /proc rw,nosuid - proc proc rw\n"
)
assert host_runtime._root_mount_has_marker(str(host), markers) is False
assert host_runtime._root_mount_has_marker(str(container), markers) is True
assert host_runtime._root_mount_has_marker(str(tmp_path / "missing"), markers) is False
class TestParseReasoningEffort:
"""Tests for parse_reasoning_effort() — string → reasoning config dict."""
@pytest.mark.parametrize("value", ["", " ", "\t", "\n"])
def test_empty_or_whitespace_returns_none(self, value):
"""Empty / whitespace-only input falls back to caller default (None)."""
assert parse_reasoning_effort(value) is None
@pytest.mark.parametrize(
"value",
["bogus", "very-high", "0", "off", "true", "default"],
)
def test_unknown_levels_return_none(self, value):
"""Unrecognized strings fall back to the caller default (None)."""
assert parse_reasoning_effort(value) is None
class TestResolvePerModelReasoningEffort:
"""Tests for resolve_per_model_reasoning_effort() — spelling-tolerant
per-model override lookup from agent.reasoning_overrides dict.
Contract: the override key the user writes in config.yaml should match
regardless of how downstream consumers normalize the model string.
normalize_model_for_provider() converts dots to dashes and
adds/strips provider prefixes. Our resolver tolerates these
variations so the user's intent ("this model always gets xhigh")
is honored no matter which code path feeds the model string.
"""
def test_exact_match(self):
"""Exact model string match returns the parsed override."""
from hermes_constants import resolve_per_model_reasoning_effort
overrides = {"claude-opus-4.5": "xhigh"}
result = resolve_per_model_reasoning_effort("claude-opus-4.5", overrides)
assert result == {"enabled": True, "effort": "xhigh"}
def test_empty_model_returns_none(self):
"""Empty model string returns None."""
from hermes_constants import resolve_per_model_reasoning_effort
assert resolve_per_model_reasoning_effort("", {"gpt-5": "low"}) is None
# --- Spelling tolerance layer ---
def test_exact_match_wins_over_variant(self):
"""Ambiguity resolution: exact match takes priority over a variant.
If both 'claude-opus-4.5' (exact) and 'claude-opus-4-5' (dashes
variant) are keys, the exact input matches the exact key first.
"""
from hermes_constants import resolve_per_model_reasoning_effort
overrides = {"claude-opus-4.5": "high", "claude-opus-4-5": "xhigh"}
result = resolve_per_model_reasoning_effort("claude-opus-4.5", overrides)
assert result == {"enabled": True, "effort": "high"}
def test_prefixed_key_matches_bare_model(self):
"""A custom-provider prefixed key (``ollama-local/qwen3.6:27b``) applies to the bare runtime slug.
Fallback entries and named custom providers feed ``agent.model`` without the provider
prefix while the documented key spelling keeps ``provider/model``; a key for a different
model must still miss.
"""
from hermes_constants import resolve_per_model_reasoning_effort
overrides = {"ollama-local/qwen3.6:27b-q4_k_m": "low"}
assert resolve_per_model_reasoning_effort("qwen3.6:27b-q4_k_m", overrides) == {"enabled": True, "effort": "low"}
assert resolve_per_model_reasoning_effort("llama3.2:3b", overrides) is None
def test_direct_match_wins_over_reverse_lookup(self):
"""A direct/variant key match keeps priority over a prefixed reverse match."""
from hermes_constants import resolve_per_model_reasoning_effort
overrides = {"qwen3.6:27b": "medium", "ollama-local/qwen3.6:27b": "low"}
assert resolve_per_model_reasoning_effort("qwen3.6:27b", overrides) == {"enabled": True, "effort": "medium"}
class TestResolveReasoningConfig:
"""Tests for resolve_reasoning_config() — the single shared chokepoint
every surface (CLI, gateway, TUI, cron, /model switch, fallback) calls.
Contract: per-model override > global agent.reasoning_effort; the raw
global value passes through uncoerced (YAML False = disabled); an
explicit model argument wins over the config's model.default.
"""
def _cfg(self, effort: object = "medium", overrides=None, default_model="gpt-5"):
return {
"model": {"default": default_model},
"agent": {
"reasoning_effort": effort,
"reasoning_overrides": overrides or {},
},
}
def test_per_model_override_wins(self):
from hermes_constants import resolve_reasoning_config
cfg = self._cfg(overrides={"claude-opus-4.5": "xhigh"})
result = resolve_reasoning_config(cfg, "claude-opus-4.5")
assert result == {"enabled": True, "effort": "xhigh"}
def test_empty_model_derives_from_config_default(self):
from hermes_constants import resolve_reasoning_config
cfg = self._cfg(overrides={"gpt-5": "high"}, default_model="gpt-5")
assert resolve_reasoning_config(cfg) == {"enabled": True, "effort": "high"}
def test_malformed_sections_tolerated(self):
"""Non-dict agent/model sections must not raise."""
from hermes_constants import resolve_reasoning_config
assert resolve_reasoning_config({"agent": "oops", "model": 42}) is None
assert resolve_reasoning_config({"agent": None, "model": None}) is None
assert resolve_reasoning_config({"agent": {"reasoning_overrides": "bad"}}) is None
def test_invalid_override_value_falls_back_to_global(self):
"""A junk override value for the matching model falls through to global."""
from hermes_constants import resolve_reasoning_config
cfg = self._cfg(effort="medium", overrides={"gpt-5": "turbo-max"})
assert resolve_reasoning_config(cfg, "gpt-5") == {"enabled": True, "effort": "medium"}
def test_dict_form_passes_bespoke_tier_verbatim_globally_and_per_model(self):
"""#93238: providers with custom tiers (fast/thinking) need the dict form to send their
real level; a bare non-ladder string stays rejected so typos never reach the wire."""
from hermes_constants import parse_reasoning_effort, resolve_reasoning_config
cfg = self._cfg(effort={"enabled": True, "effort": "thinking"},
overrides={"lumo-max": {"enabled": True, "effort": "fast"}})
assert resolve_reasoning_config(cfg, "gpt-5") == {"enabled": True, "effort": "thinking"}
assert resolve_reasoning_config(cfg, "my-relay/lumo-max") == {"enabled": True, "effort": "fast"}
assert parse_reasoning_effort("thinking") is None
def test_dict_form_disabled_or_empty_effort(self):
"""enabled:false disables regardless of level; a dict without a level is 'unset'."""
from hermes_constants import parse_reasoning_effort
assert parse_reasoning_effort({"enabled": False, "effort": "low"}) == {"enabled": False}
assert parse_reasoning_effort({"enabled": True}) is None
assert parse_reasoning_effort({"effort": 0}) is None
class TestReasoningOverridesDefaultConfig:
"""Tests for the agent.reasoning_overrides default config key (Task 2)."""
def test_spelling_tolerant_lookup_works_with_user_config(self):
"""resolve_per_model_reasoning_effort works with user-added overrides."""
from hermes_constants import resolve_per_model_reasoning_effort
# User config with one override, query uses different spelling
overrides = {
"anthropic/claude-opus-4.5": "xhigh", # user wrote with dots
}
# Lookup with different spelling (bare, dashes) — should still match
result = resolve_per_model_reasoning_effort("claude-opus-4-5", overrides)
assert result == {"enabled": True, "effort": "xhigh"}
# Another override, bare key
overrides2 = {"gpt-5": "low"}
# Lookup with provider prefix — should match
result2 = resolve_per_model_reasoning_effort("openai/gpt-5", overrides2)
assert result2 == {"enabled": True, "effort": "low"}
class TestSecureParentDir:
"""Tests for secure_parent_dir() — prevents chmod on / or top-level dirs."""
def test_safe_path_calls_chmod(self, tmp_path, monkeypatch):
"""Normal nested path (depth >= 3) should call os.chmod."""
safe_dir = tmp_path / "home" / "user" / ".hermes"
safe_dir.mkdir(parents=True)
target = safe_dir / "auth.json"
target.touch()
called_with = []
monkeypatch.setattr(os, "chmod", lambda p, m: called_with.append((str(p), m)))
secure_parent_dir(target)
assert len(called_with) == 1
assert called_with[0] == (str(safe_dir), 0o700)
def test_root_dir_skipped(self, monkeypatch):
"""Parent resolving to / must NOT be chmod'd."""
called_with = []
monkeypatch.setattr(os, "chmod", lambda p, m: called_with.append((str(p), m)))
# Path("/foo").parent == Path("/")
secure_parent_dir(Path("/foo"))
assert called_with == []
def test_install_tree_skipped(self, monkeypatch):
"""Parent dir equal to (or inside) the install tree must NOT be chmod'd.
Regression test for #93050: secure_parent_dir() chmod'd /opt/hermes to
0700 because it has 3 path parts and passed the ``< 3`` guard, locking
out UID 10000 (hermes user) from traversing the install dir.
"""
install_root = Path(hermes_constants.__file__).resolve().parent
# Directly under the install root (e.g. /opt/hermes/auth.json)
target = install_root / "auth.json"
called_with = []
monkeypatch.setattr(os, "chmod", lambda p, m: called_with.append((str(p), m)))
secure_parent_dir(target)
assert called_with == [], "must not chmod the install root"
# Inside a subdirectory of the install root
sub = install_root / "subdir"
target2 = sub / "auth.json"
called_with2 = []
monkeypatch.setattr(os, "chmod", lambda p, m: called_with2.append((str(p), m)))
secure_parent_dir(target2)
assert called_with2 == [], "must not chmod dirs inside the install tree"
def test_install_tree_siblings_still_hardened(self, monkeypatch):
"""Paths OUTSIDE the install tree must still be chmod'd.
Negative boundary for the install-tree exclusion (#93050): the guard
compares path components, so a sibling directory whose name merely
starts with the install root's name (``<install_root>-data``) must
still receive parent-dir hardening. Pins that the exclusion cannot
silently widen into a string-prefix match.
"""
install_root = Path(hermes_constants.__file__).resolve().parent
# Prefix-named sibling of the install root (/opt/hermes-data/...).
prefix_sibling = Path(str(install_root) + "-data")
called_with = []
monkeypatch.setattr(os, "chmod", lambda p, m: called_with.append((str(p), m)))
secure_parent_dir(prefix_sibling / "auth.json")
assert called_with == [(str(prefix_sibling), 0o700)], (
"prefix-named siblings of the install root must still be hardened"
)
# Ordinary sibling next to the install root (same parent dir).
sibling = install_root.parent / "unrelated-dir"
if len(sibling.parts) >= 3 and install_root not in sibling.parents:
called_with2 = []
monkeypatch.setattr(
os, "chmod", lambda p, m: called_with2.append((str(p), m))
)
secure_parent_dir(sibling / "auth.json")
assert called_with2 == [(str(sibling), 0o700)], (
"siblings of the install root must still be hardened"
)
@pytest.mark.require_symlinks
def test_symlink_resolved(self, tmp_path, monkeypatch):
"""Symlinks should be resolved before checking depth."""
real_dir = tmp_path / "a" / "b"
real_dir.mkdir(parents=True)
target = real_dir / "file.json"
target.touch()
# Create a symlink with fewer path components
link = tmp_path / "link"
link.symlink_to(real_dir)
link_target = link / "file.json"
called_with = []
monkeypatch.setattr(os, "chmod", lambda p, m: called_with.append((str(p), m)))
# Even though /tmp/link has only 3 parts, the resolved path has 4
# The resolved parent (real_dir) has depth 4, so it should be chmod'd
secure_parent_dir(link_target)
assert len(called_with) == 1
assert called_with[0] == (str(real_dir), 0o700)
@pytest.mark.platforms("posix") # POSIX shell stubs; Windows uses .cmd shims
class TestAgentBrowserRunnable:
"""agent_browser_runnable() validates the resolved CLI actually runs.
Regression coverage for issue #48521: a dangling global symlink left by
agent-browser's npm postinstall is reported by ``which`` but fails at exec
with exit 127, silently breaking every browser tool. The validator must
reject it (and other non-runnable candidates) so callers fall through.
"""
def _stub(self, tmp_path, name, body, mode=0o755):
p = tmp_path / name
p.write_text(body)
p.chmod(mode)
return p
def test_none_and_empty_rejected(self):
assert agent_browser_runnable(None) is False
assert agent_browser_runnable("") is False
def test_dangling_symlink_rejected(self, tmp_path):
link = tmp_path / "agent-browser"
link.symlink_to(tmp_path / "does-not-exist")
# exists() follows the link → False, so it's rejected without exec.
assert agent_browser_runnable(str(link)) is False
class TestGetHermesDir:
"""Tests for ``get_hermes_dir(new_subpath, old_name)``.
Contract: prefer the legacy ``<old_name>/`` location, but only when
it has content. An empty legacy stub must fall through to the new
layout so dormant install scaffolds don't orphan populated data at
``<new_subpath>/``. Regression guard for #27602.
"""
def _set_home(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
def test_neither_exists_returns_new(self, tmp_path, monkeypatch):
self._set_home(tmp_path, monkeypatch)
result = get_hermes_dir("platforms/pairing", "pairing")
assert result == tmp_path / "platforms/pairing"
def test_legacy_is_file_treated_as_content(self, tmp_path, monkeypatch):
"""A non-directory file at the legacy path counts as occupied.
Defensive against odd installs where the caller previously wrote a
single file instead of a directory. We honour whatever's there.
"""
self._set_home(tmp_path, monkeypatch)
legacy = tmp_path / "image_cache"
legacy.write_bytes(b"sentinel")
result = get_hermes_dir("cache/images", "image_cache")
assert result == legacy
@pytest.mark.require_symlinks
def test_dangling_legacy_symlink_returns_new(self, tmp_path, monkeypatch):
"""A dangling legacy symlink must NOT shadow populated new-layout data.
``lstat()`` reports the link itself (not its missing target), so the
helper must resolve the link and treat a broken target as absent —
matching the old ``exists()`` gate, which followed the link and
returned False for a dangling one. Otherwise a stale broken symlink
would orphan real data (a stricter variant of the #27602 bug).
"""
self._set_home(tmp_path, monkeypatch)
legacy = tmp_path / "pairing"
legacy.symlink_to(tmp_path / "does-not-exist")
new = tmp_path / "platforms" / "pairing"
new.mkdir(parents=True)
(new / "discord-approved.json").write_text("[]")
result = get_hermes_dir("platforms/pairing", "pairing")
assert result == new
@pytest.mark.require_symlinks
def test_symlink_to_populated_dir_returns_legacy(self, tmp_path, monkeypatch):
"""A legacy symlink pointing at a populated directory is honoured."""
self._set_home(tmp_path, monkeypatch)
real = tmp_path / "real_store"
real.mkdir()
(real / "cached.png").write_bytes(b"x")
legacy = tmp_path / "image_cache"
legacy.symlink_to(real)
result = get_hermes_dir("cache/images", "image_cache")
assert result == legacy
class TestWslPathTranslation:
"""Cross-boundary path translation for a Windows-host UI + WSL backend."""
def test_windows_drive_to_wsl_mount(self):
assert hermes_constants.windows_path_to_wsl(r"C:\Users\alex") == "/mnt/c/Users/alex"
assert hermes_constants.windows_path_to_wsl("C:/Users/alex") == "/mnt/c/Users/alex"
assert hermes_constants.windows_path_to_wsl("D:\\") == "/mnt/d/"
def test_windows_drive_ignores_non_drive_paths(self):
assert hermes_constants.windows_path_to_wsl("/home/alex") is None
assert hermes_constants.windows_path_to_wsl("relative\\dir") is None
def test_translate_maps_windows_and_unc_on_wsl(self, monkeypatch):
monkeypatch.setattr(hermes_constants, "is_wsl", lambda: True)
assert hermes_constants.translate_cwd_for_wsl_backend(r"C:\Users\alex") == "/mnt/c/Users/alex"
assert hermes_constants.translate_cwd_for_wsl_backend(r"\\wsl.localhost\Ubuntu\home\alex") == "/home/alex"
# Already-POSIX paths pass through untouched.
assert hermes_constants.translate_cwd_for_wsl_backend("/home/alex") == "/home/alex"
class TestProjectVenvDirOutOfTree:
"""#116148: a checkout with no in-tree venv whose interpreter lives in ``$HERMES_HOME/venvs/<name>``
(the layout the shipped Windows launchers pin) must resolve to the running interpreter's venv,
never ``None`` — every updater call site turns ``None`` into a fabricated ``<checkout>/venv`` that
uv cannot inspect, so tool dependencies are never refreshed."""
@staticmethod
def _running_from(monkeypatch, checkout, venv):
monkeypatch.setattr(hermes_constants, "__file__", str(checkout / "hermes_constants.py"))
monkeypatch.setattr(sys, "prefix", str(venv))
monkeypatch.setattr(sys, "base_prefix", str(checkout / "no-such-base"))
@staticmethod
def _venv_installed_from(venv, source):
from pm.environments import site_packages
hermes_constants.venv_python_path(venv).parent.mkdir(parents=True)
hermes_constants.venv_python_path(venv).write_text("", encoding="utf-8")
dist_info = site_packages(venv) / "hermes_agent-0.0.0.dist-info"
dist_info.mkdir(parents=True)
(dist_info / "METADATA").write_text("Name: hermes-agent\nVersion: 0.0.0\n", encoding="utf-8")
(dist_info / "direct_url.json").write_text(
json.dumps({"url": source.resolve().as_uri(), "dir_info": {"editable": True}}), encoding="utf-8")
def test_out_of_tree_install_resolves_the_running_interpreter_venv(self, monkeypatch, tmp_path):
checkout = tmp_path / "hermes-agent"
checkout.mkdir()
venv = tmp_path / "venvs" / "hermes"
self._venv_installed_from(venv, checkout)
self._running_from(monkeypatch, checkout, venv)
assert hermes_constants.project_venv_dir(checkout) == venv
def test_another_installs_interpreter_is_never_claimed(self, monkeypatch, tmp_path):
"""``PYTHONPATH=<dev checkout> <app venv>/bin/python``: the code comes from the dev checkout,
but the venv belongs to the app install. Claiming it pointed the dev checkout's update sync at
the app's venv, which became an editable install of the dev tree."""
dev = tmp_path / "dev" / "hermes-agent"
dev.mkdir(parents=True)
app = tmp_path / "app" / "hermes-agent"
app.mkdir(parents=True)
venv = tmp_path / "app" / "venv"
self._venv_installed_from(venv, app)
self._running_from(monkeypatch, dev, venv)
assert hermes_constants.project_venv_dir(dev) is None
def test_foreign_root_and_in_tree_venv_are_unchanged(self, monkeypatch, tmp_path):
"""A temp dir / another clone never claims the running venv; an in-tree venv still wins."""
checkout = tmp_path / "hermes-agent"
checkout.mkdir()
venv = tmp_path / "venvs" / "hermes"
self._venv_installed_from(venv, checkout)
self._running_from(monkeypatch, checkout, venv)
other = tmp_path / "not-our-checkout"
other.mkdir()
assert hermes_constants.project_venv_dir(other) is None
(checkout / ".venv").mkdir()
assert hermes_constants.project_venv_dir(checkout) == checkout / ".venv"