Files
hermes-agent/tests/test_packaging_py_modules.py
ethernet 283c4f058c fix(packaging): derive root py-modules from the tree in setup.py
The static py-modules list in pyproject.toml drifted from the source
tree each time the root layout changed. An installed wheel then
raised ModuleNotFoundError on import: hermes_state_common, and every
gateway or CLI start failed. The list missed hermes_state_holders,
mini_swe_runner, and the 15 new hermes_state_* modules from this
branch.

setup.py now derives py_modules from the source tree at build time.
setuptools package discovery sees only directories with an
__init__.py, so root single-file modules need py_modules in every
wheel build. setup() kwargs merge with pyproject.toml, and setup.py
is the only legitimate wheel or sdist builder, so the derived list is
the single source of truth.

The nix build is the only wheel consumer. Its source filter keeps
every root .py file, so the build sandbox derives the same set as the
checkout. Editable installs do not read py_modules: build_editable
never runs bdist_wheel.

Verified: wheel and sdist built with HERMES_NIX_BUILD=1 carry all 38
root modules. The guard still blocks builds without the nix env var.
The sealed uv2nix venv from nix build .#default imports
hermes_state_holders, hermes_state_sessions, mini_swe_runner, and
hermes_startup_watchdog.

(cherry picked from commit e23d467b52)

Invariant test updated to pin the derived list (no static py-modules; every root module packaged code imports ships).
2026-09-03 12:22:50 -07:00

75 lines
3.1 KiB
Python

"""Packaging invariant: every root-level module that packaged code imports ships in the wheel.
``packages.find`` only sees directories with ``__init__.py``; root single-file modules
(``run_agent``, ``hermes_state``, ``toolsets``...) reach the wheel through
``setup.py::_root_py_modules()``, which derives the list from the tree at build time. A
static list drifted every time the root layout changed and broke installed wheels with
``ModuleNotFoundError`` on ``import hermes_state``. This test pins the two halves of that
contract: the derived list covers every root module packaged code can import, and the
helper stays the single source (no static ``py-modules`` creeping back into pyproject).
"""
from __future__ import annotations
import ast
import importlib.util
import sys
import tomllib
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
PACKAGES = ("agent", "tools", "hermes_cli", "gateway", "tui_gateway", "cron", "acp_adapter", "plugins", "providers")
def _root_py_modules() -> set[str]:
spec = importlib.util.spec_from_file_location("_hermes_setup_py", REPO_ROOT / "setup.py")
mod = importlib.util.module_from_spec(spec)
saved = sys.argv
sys.argv = ["setup.py", "--name"] # setup() must not try to build anything on import
try:
try:
spec.loader.exec_module(mod)
except SystemExit:
pass
finally:
sys.argv = saved
return set(mod._root_py_modules())
def _imported_root_names(paths) -> dict[str, set[str]]:
root_files = {p.stem for p in REPO_ROOT.glob("*.py")}
hits: dict[str, set[str]] = {}
for path in paths:
try:
tree = ast.parse(path.read_text(encoding="utf-8", errors="ignore"))
except SyntaxError:
continue
for node in ast.walk(tree):
names = []
if isinstance(node, ast.Import):
names = [a.name.split(".")[0] for a in node.names]
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
names = [node.module.split(".")[0]]
for n in names:
if n in root_files:
hits.setdefault(str(path.relative_to(REPO_ROOT)), set()).add(n)
return hits
def test_pyproject_has_no_static_py_modules_list():
cfg = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
assert "py-modules" not in cfg["tool"]["setuptools"], (
"root modules are derived in setup.py::_root_py_modules(); a static py-modules list drifts "
"from the tree and breaks installed wheels. Do not add it back."
)
def test_every_root_module_imported_by_packaged_code_is_shipped():
shipped = _root_py_modules()
paths = [REPO_ROOT / f"{n}.py" for n in shipped]
for pkg in PACKAGES:
paths.extend((REPO_ROOT / pkg).rglob("*.py"))
missing = {f: sorted(n for n in names if n not in shipped) for f, names in _imported_root_names(paths).items()}
missing = {f: v for f, v in missing.items() if v}
assert not missing, f"packaged code imports root modules the wheel would not ship: {missing}"
assert "hermes_state" in shipped and "setup" not in shipped