Importing hermes_cli on a host whose stdout is not UTF-8 (a cp1252 pipe on
Windows, a latin-1 locale on a Pi) set PYTHONUTF8/PYTHONIOENCODING in
os.environ as a side effect. Every library importer -- gateway.relay, the
compute host, agent.auxiliary_client -- then leaked that into each child it
spawned, which is what
test_library_imports_of_dual_use_entry_modules_stay_side_effect_free
catches on Windows (changed == [PYTHONIOENCODING, PYTHONUTF8]).
The package import now only repairs its own streams and records whether it
had to. hermes_cli.main.main() turns that record into the child-process
hint, so `hermes` still steers its Python children to UTF-8 on such a host.
On Windows the entry-point bootstrap and configure_windows_stdio() already
export both variables; the tool subprocess env builders set them for
children independently.
The test was unmarked, so the OS lanes (`-m "platforms and not
integration"`) deselected it and Windows never ran it. Mark it
platforms("any"): list_os_marked_tests.py lists the file for every lane and
the -m expression now selects the test.
77 lines
3.5 KiB
Python
77 lines
3.5 KiB
Python
"""Hermes CLI - Unified command-line interface for Hermes Agent."""
|
|
|
|
import sys
|
|
|
|
__release_date__ = "2026.9.24"
|
|
# Declared for type checkers and the old-updater surface audit; served lazily by __getattr__.
|
|
__version__: str
|
|
|
|
|
|
def __getattr__(name: str) -> str:
|
|
"""Old-updater compat: shipped updaters import ``__version__`` after the checkout swap.
|
|
|
|
tests/compat/old_updater_surface.json freezes that import. In-tree code resolves
|
|
identity through hermes_cli.version_info.get_version_info(); this reads only the
|
|
install stamp -- never git -- and keeps the pre-stamp placeholder when a checkout
|
|
has no stamp.
|
|
|
|
Lazy because ``pm`` is not importable when this package loads: a venv
|
|
editable-installed from a pre-PM tree maps only the top-level packages it knew
|
|
at install time, and the repo root reaches ``sys.path`` only once
|
|
``hermes_bootstrap`` runs -- after this ``__init__``, from ``hermes_cli.main``.
|
|
"""
|
|
if name != "__version__":
|
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
from hermes_cli.steward import read_install_stamp
|
|
try:
|
|
from pm.paths import repo_root
|
|
except ModuleNotFoundError as exc:
|
|
if exc.name != "pm" and not (exc.name or "").startswith("pm."):
|
|
raise
|
|
# The old editable finder may not know the new pm package yet.
|
|
import json
|
|
from pathlib import Path
|
|
try:
|
|
stamp = json.loads((Path(__file__).resolve().parents[1] / "install-stamp.json").read_text(encoding="utf-8-sig"))
|
|
return str(stamp.get("baseVersion") or "0.0.0")
|
|
except (OSError, ValueError, AttributeError):
|
|
return "0.0.0"
|
|
return str(read_install_stamp(repo_root()).get("baseVersion") or "0.0.0")
|
|
|
|
|
|
def _ensure_utf8() -> bool:
|
|
"""Force UTF-8 stdout/stderr to prevent UnicodeEncodeError crashes; True when a stream was repaired.
|
|
|
|
The CLI prints box-drawing characters and the ☤ glyph in the setup wizard, doctor, and status
|
|
banners; under a non-UTF-8 codec that raises before the command can even start (e.g.
|
|
`hermes setup` on a fresh Pi).
|
|
"""
|
|
repaired = False
|
|
for stream_name in ("stdout", "stderr"):
|
|
stream = getattr(sys, stream_name, None)
|
|
if stream is None:
|
|
continue
|
|
try:
|
|
if (getattr(stream, "encoding", "") or "").lower().replace("-", "") == "utf8":
|
|
continue
|
|
# Preferred: reconfigure in place, preserving object identity so code already holding
|
|
# a reference to the old sys.stdout benefits from the repair too.
|
|
reconfigure = getattr(stream, "reconfigure", None)
|
|
if callable(reconfigure):
|
|
reconfigure(encoding="utf-8", errors="replace")
|
|
else:
|
|
# No reconfigure(): reopen the fd as UTF-8 (closefd=False keeps the original fd open).
|
|
new_stream = open(stream.fileno(), "w", encoding="utf-8", errors="replace", # windows-footgun: ok (stdout re-open for write, not a read)
|
|
buffering=1, closefd=False)
|
|
setattr(sys, stream_name, new_stream)
|
|
repaired = True
|
|
except (AttributeError, OSError, ValueError):
|
|
pass
|
|
return repaired
|
|
|
|
|
|
# Import repairs only this process's streams. Gateway, compute host, and test code import this
|
|
# package as a library; rewriting their os.environ would leak into every child they spawn, so the
|
|
# child-process UTF-8 hint is applied by the CLI entry point (hermes_cli.main.main) instead.
|
|
_stdio_repaired = _ensure_utf8()
|