Files
python-sdk/pkg/hanzo-mcp/tests/test_tool_entrypoints_load.py
T
zeekay f34b5535d1 test(hanzo-mcp): guard every hanzo.tools entry point imports cleanly
Regression guard for the hanzo-tools 0.3.0 core re-export gap that silently
dropped fs/exec/code/git/fetch/agent, leaving hanzo-mcp with only 13 tools.
Fails loudly if any entry point stops loading or the core error contract
(ToolError/ConflictError/InvalidParamsError/NotFoundError) regresses.
2026-07-02 23:57:21 -07:00

93 lines
3.3 KiB
Python

"""Regression: every `hanzo.tools` entry point must import cleanly.
The bug this guards against: PyPI ``hanzo-tools`` 0.3.0 shipped a
``hanzo_tools/core/__init__.py`` that did NOT re-export the HIP-0300 error
classes (``ToolError``, ``ConflictError``, ``InvalidParamsError``,
``NotFoundError``). Six tool packages import those names at entry-point load:
fs -> ConflictError
shell -> ToolError (exec)
agent -> ToolError
code -> InvalidParamsError
vcs -> InvalidParamsError (git)
net -> InvalidParamsError (fetch)
When the re-export was missing, each ``ep.load()`` raised ``ImportError`` and
was swallowed by the loader's broad ``except``, so hanzo-mcp silently exposed
only 13 tools instead of the full axis set (fs/exec/code/git/fetch/agent were
the ones dropped — the load-bearing ones). This test fails loudly if any
entry point stops importing, instead of degrading to a partial tool surface.
"""
from importlib.metadata import entry_points
import pytest
TOOLS_ENTRY_POINT_GROUP = "hanzo.tools"
# The exact tools that vanished when core stopped re-exporting the error
# classes. Their absence is the fingerprint of this regression.
LOAD_BEARING_TOOLS = {"fs", "exec", "code", "git", "fetch", "agent"}
def _load_all_entry_points():
"""Load every hanzo.tools entry point, returning {name: (tools|exception)}."""
results: dict[str, object] = {}
for ep in entry_points(group=TOOLS_ENTRY_POINT_GROUP):
try:
results[ep.name] = ep.load()
except Exception as e: # noqa: BLE001 — we WANT to capture the failure
results[ep.name] = e
return results
def test_core_reexports_the_error_contract():
"""The exact names the tool packages import must live on hanzo_tools.core."""
from hanzo_tools import core
for name in (
"ToolError",
"ConflictError",
"NotFoundError",
"InvalidParamsError",
"ActionHandler",
"Paging",
"Range",
"ErrorCode",
"content_hash",
"file_uri",
):
assert hasattr(core, name), f"hanzo_tools.core is missing re-export: {name}"
def test_every_tools_entry_point_imports_cleanly():
"""No `hanzo.tools` entry point may fail to load — a failure silently drops the tool."""
results = _load_all_entry_points()
failures = {
name: f"{type(obj).__name__}: {obj}"
for name, obj in results.items()
if isinstance(obj, Exception)
}
assert not failures, "entry points failed to import: " + "; ".join(
f"{n} -> {msg}" for n, msg in sorted(failures.items())
)
def test_load_bearing_tools_are_present():
"""fs/exec/code/git/fetch/agent must be discoverable — the regression's signature."""
results = _load_all_entry_points()
discovered: set[str] = set()
for obj in results.values():
if isinstance(obj, Exception) or not isinstance(obj, list):
continue
for tool_cls in obj:
name = getattr(tool_cls, "name", None)
if isinstance(name, str):
discovered.add(name)
missing = LOAD_BEARING_TOOLS - discovered
assert not missing, f"core tools missing from entry points: {sorted(missing)}"
if __name__ == "__main__": # pragma: no cover
raise SystemExit(pytest.main([__file__, "-v"]))