mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-09 03:11:58 +00:00
teknium1 hit ModuleNotFoundError: No module named 'hermes_bootstrap' after a code update, on both his Windows machine AND his Linux workstation. The failure mode is real and affects every user who updates hermes by any path OTHER than a fully-successful ``hermes update``. ## What happens hermes_bootstrap.py is a top-level module registered via pyproject.toml's ``py-modules`` list (added by Brooklyn's Windows UTF-8 stdio work). It must be registered in the venv's editable-install .pth file before Python can find it as a bare ``import hermes_bootstrap``. ``hermes update`` handles this correctly: (1) git reset --hard, (2) clear __pycache__, (3) uv pip install -e . (re-registers the package including the new py-modules list), (4) restart. BUT if any step AFTER (1) fails — network blip during pip install, PEP 668 on a system Python, venv locked, uv not in PATH, a crash mid-update — the user is left with new code that references hermes_bootstrap and a venv that doesn't know about it. Every hermes invocation after that crashes with ModuleNotFoundError, including ``hermes update`` itself. No recovery path without manual `uv pip install -e .`. Also affects users who ``git pull`` the repo directly without running hermes update — relatively common for developers. ## Fix Wrap ``import hermes_bootstrap`` in a try/except ModuleNotFoundError across all 6 entry points (hermes_cli/main, run_agent, gateway/run, acp_adapter/entry, cli, batch_runner). On Windows, missing bootstrap means the UTF-8 stdio setup doesn't run — degraded behavior (Unicode chars may fail to print) but NOT a crash. POSIX is unaffected either way since the bootstrap is a no-op there. Once hermes is running again, the user can ``hermes update`` to fully recover. ## Test update tests/test_hermes_bootstrap.py::test_entry_point_imports_bootstrap scans for the first top-level import in each entry point and asserts it is hermes_bootstrap. Extended the check to accept a Try block whose body is a lone Import of hermes_bootstrap — that's the recovery-friendly form we just introduced. Verified behavior by ``mv hermes_bootstrap.py hermes_bootstrap.py.bak`` and confirming ``python -c "import hermes_cli.main"`` succeeds. 82/82 tests pass (hermes_bootstrap + windows-native + windows-compat).
314 lines
12 KiB
Python
314 lines
12 KiB
Python
"""Tests for hermes_bootstrap — Windows UTF-8 stdio shim.
|
|
|
|
The bootstrap module is imported at the top of every Hermes entry point
|
|
(hermes, hermes-agent, hermes-acp, gateway, batch_runner, cli.py). It
|
|
fixes Python's Windows UTF-8 defaults so print("café") doesn't crash and
|
|
subprocess children inherit UTF-8 mode.
|
|
|
|
Key invariants covered by these tests:
|
|
|
|
1. Windows: env vars get set, stdio reconfigured, non-ASCII print works
|
|
2. POSIX: complete no-op (we don't touch LANG/LC_* or anything else)
|
|
3. Idempotent: safe to call multiple times
|
|
4. Respects user opt-out: if the user explicitly sets PYTHONUTF8=0 or
|
|
PYTHONIOENCODING=something-else, we leave those alone
|
|
5. Load order: every Hermes entry point imports hermes_bootstrap as its
|
|
first non-docstring import (before anything that might do file I/O
|
|
or print to stdout)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import textwrap
|
|
import unittest.mock as mock
|
|
|
|
import pytest
|
|
|
|
|
|
# Import the module under test via an import-time side-effect check path.
|
|
# We need to be able to reset its state between tests, so we import it
|
|
# fresh in each test that manipulates _IS_WINDOWS.
|
|
def _fresh_import():
|
|
"""Return a freshly-imported hermes_bootstrap module.
|
|
|
|
Drops any cached copy from sys.modules first so module-level code
|
|
runs again and the platform check re-evaluates.
|
|
"""
|
|
sys.modules.pop("hermes_bootstrap", None)
|
|
import hermes_bootstrap # noqa: WPS433
|
|
return hermes_bootstrap
|
|
|
|
|
|
class TestWindowsBehavior:
|
|
"""Windows: the bootstrap does its job."""
|
|
|
|
@pytest.mark.skipif(
|
|
sys.platform != "win32",
|
|
reason="Windows-specific behavior",
|
|
)
|
|
def test_env_vars_set_on_windows(self, monkeypatch):
|
|
# Clear any pre-existing values and re-run bootstrap.
|
|
monkeypatch.delenv("PYTHONUTF8", raising=False)
|
|
monkeypatch.delenv("PYTHONIOENCODING", raising=False)
|
|
hb = _fresh_import()
|
|
# Module-level apply_windows_utf8_bootstrap() ran during import.
|
|
assert os.environ.get("PYTHONUTF8") == "1"
|
|
assert os.environ.get("PYTHONIOENCODING") == "utf-8"
|
|
assert hb._bootstrap_applied is True
|
|
|
|
@pytest.mark.skipif(
|
|
sys.platform != "win32",
|
|
reason="Windows-specific behavior",
|
|
)
|
|
def test_stdout_reconfigured_to_utf8_on_windows(self):
|
|
# The live process's stdout should now be UTF-8 (the Hermes CLI
|
|
# runs on Windows with a pytest console that's cp1252 by default).
|
|
# If reconfigure succeeded, sys.stdout.encoding is 'utf-8'.
|
|
_fresh_import()
|
|
# pytest may capture stdout, which makes encoding check flaky —
|
|
# so instead verify the reconfigure call succeeded on the real
|
|
# stream by attempting the failure case.
|
|
out = sys.stdout
|
|
reconfigure = getattr(out, "reconfigure", None)
|
|
if reconfigure is None:
|
|
pytest.skip("pytest replaced sys.stdout with a non-reconfigurable stream")
|
|
# After bootstrap, encoding should be utf-8 (or the reconfigure
|
|
# skipped because pytest's capture already set it to utf-8).
|
|
assert out.encoding.lower() in {"utf-8", "utf8"}, (
|
|
f"stdout encoding is {out.encoding!r} — bootstrap should have "
|
|
"reconfigured it to UTF-8"
|
|
)
|
|
|
|
@pytest.mark.skipif(
|
|
sys.platform != "win32",
|
|
reason="Windows-specific behavior",
|
|
)
|
|
def test_child_process_inherits_utf8_mode(self):
|
|
"""A subprocess spawned from this process should inherit
|
|
PYTHONUTF8=1 and be able to print non-ASCII to stdout."""
|
|
_fresh_import()
|
|
# Non-ASCII chars that would crash under cp1252: arrow, emoji.
|
|
script = textwrap.dedent("""
|
|
import sys
|
|
print("em-dash \\u2014 arrow \\u2192 emoji \\U0001f680")
|
|
sys.exit(0)
|
|
""").strip()
|
|
# Don't pass env= — let the child inherit os.environ, which
|
|
# now contains PYTHONUTF8=1 courtesy of the bootstrap.
|
|
result = subprocess.run(
|
|
[sys.executable, "-c", script],
|
|
capture_output=True,
|
|
timeout=15,
|
|
)
|
|
assert result.returncode == 0, (
|
|
f"Child crashed printing non-ASCII despite UTF-8 bootstrap:\n"
|
|
f" stdout: {result.stdout!r}\n"
|
|
f" stderr: {result.stderr!r}"
|
|
)
|
|
decoded = result.stdout.decode("utf-8")
|
|
assert "\u2014" in decoded
|
|
assert "\u2192" in decoded
|
|
assert "\U0001f680" in decoded
|
|
|
|
|
|
class TestUserOptOut:
|
|
"""If the user has explicitly set PYTHONUTF8 / PYTHONIOENCODING in
|
|
their environment, we respect that (setdefault, not overwrite)."""
|
|
|
|
@pytest.mark.skipif(
|
|
sys.platform != "win32",
|
|
reason="Only meaningful on Windows where we'd otherwise set these",
|
|
)
|
|
def test_user_pythonutf8_zero_preserved(self, monkeypatch):
|
|
monkeypatch.setenv("PYTHONUTF8", "0")
|
|
_fresh_import()
|
|
assert os.environ["PYTHONUTF8"] == "0", (
|
|
"bootstrap must not overwrite an explicit user setting"
|
|
)
|
|
|
|
@pytest.mark.skipif(
|
|
sys.platform != "win32",
|
|
reason="Only meaningful on Windows where we'd otherwise set these",
|
|
)
|
|
def test_user_pythonioencoding_preserved(self, monkeypatch):
|
|
monkeypatch.setenv("PYTHONIOENCODING", "latin-1")
|
|
_fresh_import()
|
|
assert os.environ["PYTHONIOENCODING"] == "latin-1"
|
|
|
|
|
|
class TestPosixNoOp:
|
|
"""POSIX: zero behavior change. We don't touch LANG, LC_*, or any
|
|
stdio. The goal is that Linux/macOS behave identically before and
|
|
after this module is imported."""
|
|
|
|
def test_noop_on_fake_posix(self, monkeypatch):
|
|
"""Even when imported, the bootstrap function must return False
|
|
and leave env untouched when _IS_WINDOWS is False."""
|
|
hb = _fresh_import()
|
|
# Reset + fake POSIX
|
|
hb._IS_WINDOWS = False
|
|
hb._bootstrap_applied = False
|
|
monkeypatch.delenv("PYTHONUTF8", raising=False)
|
|
monkeypatch.delenv("PYTHONIOENCODING", raising=False)
|
|
|
|
result = hb.apply_windows_utf8_bootstrap()
|
|
|
|
assert result is False
|
|
assert "PYTHONUTF8" not in os.environ
|
|
assert "PYTHONIOENCODING" not in os.environ
|
|
assert hb._bootstrap_applied is False
|
|
|
|
@pytest.mark.skipif(
|
|
sys.platform == "win32",
|
|
reason="Real POSIX required for this check",
|
|
)
|
|
def test_real_posix_bootstrap_is_noop(self, monkeypatch):
|
|
"""On actual Linux/macOS, importing the module must not set
|
|
PYTHONUTF8 or reconfigure stdio."""
|
|
monkeypatch.delenv("PYTHONUTF8", raising=False)
|
|
monkeypatch.delenv("PYTHONIOENCODING", raising=False)
|
|
hb = _fresh_import()
|
|
assert hb._bootstrap_applied is False
|
|
assert "PYTHONUTF8" not in os.environ
|
|
assert "PYTHONIOENCODING" not in os.environ
|
|
|
|
|
|
class TestIdempotence:
|
|
"""Calling apply_windows_utf8_bootstrap() multiple times must be safe."""
|
|
|
|
def test_second_call_returns_false(self):
|
|
hb = _fresh_import()
|
|
# First call already happened at import time.
|
|
result = hb.apply_windows_utf8_bootstrap()
|
|
assert result is False, (
|
|
"Second call should return False (idempotent no-op)"
|
|
)
|
|
|
|
def test_no_exceptions_on_repeated_calls(self):
|
|
hb = _fresh_import()
|
|
for _ in range(5):
|
|
hb.apply_windows_utf8_bootstrap()
|
|
|
|
|
|
class TestStdioReconfigureErrorHandling:
|
|
"""If sys.stdout/stderr/stdin have been replaced with streams that
|
|
don't support reconfigure (e.g. by a test harness), the bootstrap
|
|
must degrade gracefully rather than crash."""
|
|
|
|
def test_non_reconfigurable_stream_does_not_crash(self, monkeypatch):
|
|
"""Replace sys.stdout with a BytesIO (no reconfigure method),
|
|
then run the bootstrap and make sure it doesn't raise."""
|
|
hb = _fresh_import()
|
|
hb._IS_WINDOWS = True
|
|
hb._bootstrap_applied = False
|
|
|
|
fake = io.BytesIO() # no .reconfigure attribute
|
|
monkeypatch.setattr(sys, "stdout", fake)
|
|
try:
|
|
# Must not raise.
|
|
hb.apply_windows_utf8_bootstrap()
|
|
except Exception as exc:
|
|
pytest.fail(f"bootstrap raised on non-reconfigurable stdout: {exc}")
|
|
|
|
def test_reconfigure_oserror_is_caught(self, monkeypatch):
|
|
"""If reconfigure() itself raises (closed stream, etc.), swallow
|
|
the error — the env-var half of the fix still applies."""
|
|
hb = _fresh_import()
|
|
hb._IS_WINDOWS = True
|
|
hb._bootstrap_applied = False
|
|
|
|
class _BrokenStream:
|
|
encoding = "utf-8"
|
|
def reconfigure(self, **kwargs):
|
|
raise OSError("simulated: stream already closed")
|
|
|
|
monkeypatch.setattr(sys, "stdout", _BrokenStream())
|
|
monkeypatch.setattr(sys, "stderr", _BrokenStream())
|
|
# Must not raise.
|
|
hb.apply_windows_utf8_bootstrap()
|
|
|
|
|
|
class TestEntryPointsImportBootstrap:
|
|
"""Every Hermes entry point must import hermes_bootstrap as its
|
|
first non-docstring import. We check this by scanning source files
|
|
rather than invoking the entry points (which would require a full
|
|
agent context)."""
|
|
|
|
# Entry points that invoke Hermes as a process. Each one must
|
|
# import hermes_bootstrap before doing any file I/O or stdout writes.
|
|
ENTRY_POINTS = [
|
|
"hermes_cli/main.py", # hermes CLI (console_script)
|
|
"run_agent.py", # hermes-agent (console_script)
|
|
"acp_adapter/entry.py", # hermes-acp (console_script)
|
|
"gateway/run.py", # gateway
|
|
"batch_runner.py", # batch mode
|
|
"cli.py", # legacy direct-launch CLI
|
|
]
|
|
|
|
@pytest.mark.parametrize("path", ENTRY_POINTS)
|
|
def test_entry_point_imports_bootstrap(self, path):
|
|
"""The file must contain 'import hermes_bootstrap' and that
|
|
line must appear before the first 'import' of anything else.
|
|
|
|
We're lenient about the docstring (can be arbitrarily long) and
|
|
about comment lines — just need to verify the first import
|
|
statement is the bootstrap.
|
|
|
|
Also lenient about a try/except wrapper around the import: entry
|
|
points may guard the import against ``ModuleNotFoundError`` so a
|
|
half-finished ``hermes update`` (git-reset landed new code but
|
|
``uv pip install -e .`` didn't finish re-registering
|
|
``hermes_bootstrap`` as a top-level module) leaves hermes
|
|
recoverable instead of crashing on every invocation. When the
|
|
first top-level node is such a guarded-import block, we peek
|
|
inside it to verify bootstrap is the imported module.
|
|
"""
|
|
# Resolve relative to the hermes-agent repo root. Tests live
|
|
# at tests/test_hermes_bootstrap.py, so go up one dir.
|
|
import pathlib
|
|
here = pathlib.Path(__file__).resolve()
|
|
repo_root = here.parent.parent # tests/ -> repo root
|
|
full_path = repo_root / path
|
|
assert full_path.exists(), f"entry point missing: {full_path}"
|
|
|
|
source = full_path.read_text(encoding="utf-8")
|
|
|
|
# Find the first non-comment, non-blank line that starts with
|
|
# 'import ' or 'from ', or a Try block whose body is the import.
|
|
import ast
|
|
tree = ast.parse(source)
|
|
|
|
first_import_node = None
|
|
for node in ast.iter_child_nodes(tree):
|
|
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
first_import_node = node
|
|
break
|
|
# Accept a guarded-import Try block where the body is a lone
|
|
# Import node — this is the recovery-friendly form that lets
|
|
# hermes start even when hermes_bootstrap hasn't been
|
|
# re-registered in the venv yet.
|
|
if isinstance(node, ast.Try) and len(node.body) == 1 and isinstance(
|
|
node.body[0], (ast.Import, ast.ImportFrom)
|
|
):
|
|
first_import_node = node.body[0]
|
|
break
|
|
|
|
assert first_import_node is not None, (
|
|
f"{path}: no top-level imports found at all"
|
|
)
|
|
|
|
if isinstance(first_import_node, ast.Import):
|
|
first_import_name = first_import_node.names[0].name
|
|
else: # ImportFrom
|
|
first_import_name = first_import_node.module or ""
|
|
|
|
assert first_import_name == "hermes_bootstrap", (
|
|
f"{path}: first top-level import is {first_import_name!r}, "
|
|
f"but it must be 'hermes_bootstrap' so UTF-8 stdio is "
|
|
f"configured before anything else initializes. Move the "
|
|
f"'import hermes_bootstrap' line to be the first import."
|
|
)
|