Files
python-sdk/pkg/hanzo-tools-api/tests/test_spec_catalog.py
T
zeekayandHanzo Dev d0c125f571 tools: generate the hanzo surface from cloud's OpenAPI registry; delete the vector fake
The `hanzo` dispatcher hand-listed 10 services in SERVICE_TOOL_PATHS, so the
other ~150 cloud products were unreachable until someone cut a Python release.
Project the surface from cloud's /v1/openapi.json instead: cloud tags every
operation with its product (the first path segment after /v1/), so that document
already IS a service/action catalog. 166 services are now reachable, and a newly
mounted app is callable the moment cloud serves it.

Still exactly one MCP tool — agents degrade badly with hundreds — now shaped
hanzo(service, action, params, method), with `services` listing the catalog and
an empty action listing a service's actions.

Delete the local Infinity vector store (3,546 lines). It shipped no embedder, so
its only possible output was random vectors: it ranked "Bananas are yellow" above
an auth document for the query "authentication oauth jwt". A previous fix put the
mock behind HANZO_VECTOR_ALLOW_MOCK, but a fake that a flag can re-enable is
still a fake, and a tool that lies to an agent is worse than a missing one. The
package keeps only the cloud-backed VectorTool, and a test now fails if any
module in it imports `random`.

Make the legacy-tool gate honest: hide the per-service tools only once the
unified tool genuinely imports. It does not import against released hanzo-tools
(HanzoCloud is newer), and disabling them unconditionally would have left no
cloud tools at all.

- spec.py: catalog projection, cache keyed per spec source, stale cache beats
  failing a call. HANZO_OPENAPI_URL decouples catalog from target so a partial
  local host can be driven with the full registry.
- HanzoCloud.call: one generic seam for the verbs the spec names (PUT/PATCH/
  DELETE); auth also resolves the hanzo CLI's IAM session, memoized.
- 40 new tests covering action naming, template binding, method inference,
  param split, and cache fallback.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 20:25:54 -07:00

157 lines
5.8 KiB
Python

"""Tests for the OpenAPI -> service/action projection.
Pure mapping tests: no network. These pin the contract that lets one MCP tool
stand in for every cloud service, so a regression here silently mis-routes
calls rather than failing loudly.
"""
import json
import pytest
from hanzo_tools.api import spec as openapi
SPEC = {
"paths": {
"/v1/billing/plans": {"get": {"tags": ["billing"]}},
"/v1/billing/spend-alerts/{id}": {"delete": {"tags": ["billing"]}},
"/v1/code/ask": {"get": {"tags": ["code"]}, "post": {"tags": ["code"]}},
"/v1/kb/search": {"post": {"tags": ["kb"], "responses": {"200": {}}}},
"/v1/iam": {"get": {"tags": ["iam"]}},
"/v1/vector/{name}": {"get": {"tags": ["vector"]}},
"/v1/vector/stats": {"get": {"tags": ["vector"]}},
"/healthz": {"get": {}}, # untagged: still addressable
}
}
@pytest.fixture
def catalog():
return openapi.Catalog(SPEC)
class TestActionNaming:
@pytest.mark.parametrize(
"path,tag,expected",
[
("/v1/billing/plans", "billing", "plans"),
("/v1/iam", "iam", ""),
("/v1/vector/{name}", "vector", "{name}"),
("/v1/billing/spend-alerts/{id}", "billing", "spend-alerts/{id}"),
("/healthz", "_untagged", "healthz"),
],
)
def test_action_of(self, path, tag, expected):
assert openapi.action_of(path, tag) == expected
class TestCatalog:
def test_services_are_tags(self, catalog):
assert catalog.services == [
"_untagged",
"billing",
"code",
"iam",
"kb",
"vector",
]
def test_summary_counts_typed_ops(self, catalog):
summary = catalog.summary()
assert summary["kb"]["typed"] == 1 # has responses
assert summary["billing"]["typed"] == 0 # router-shape only
assert summary["code"]["operations"] == 2
def test_describe_groups_methods_per_action(self, catalog):
actions = catalog.describe("code")["actions"]
assert actions["ask"]["methods"] == ["GET", "POST"]
def test_unknown_service_raises(self, catalog):
with pytest.raises(KeyError):
catalog.resolve("nope", "x")
def test_unknown_action_lists_known_ones(self, catalog):
with pytest.raises(KeyError, match="plans"):
catalog.resolve("billing", "nope")
class TestResolution:
def test_exact_action(self, catalog):
assert catalog.resolve("billing", "plans") == openapi.Route(
"GET", "/v1/billing/plans", {}
)
def test_root_action(self, catalog):
assert catalog.resolve("iam", "").path == "/v1/iam"
def test_template_binds_from_action(self, catalog):
route = catalog.resolve("vector", "my-coll")
assert route.path == "/v1/vector/my-coll"
assert route.bound == {"name": "my-coll"}
def test_literal_beats_template(self, catalog):
"""`stats` is a real route; it must not be swallowed by {name}."""
route = catalog.resolve("vector", "stats")
assert route.path == "/v1/vector/stats" and route.bound == {}
def test_nested_template(self, catalog):
route = catalog.resolve("billing", "spend-alerts/a1")
assert route.path == "/v1/billing/spend-alerts/a1"
def test_method_override(self, catalog):
assert catalog.resolve("code", "ask", method="post").method == "POST"
def test_bad_method_reports_available(self, catalog):
with pytest.raises(KeyError, match="GET"):
catalog.resolve("billing", "plans", method="delete")
def test_params_imply_write_absence_implies_read(self, catalog):
assert catalog.resolve("code", "ask", has_params=True).method == "POST"
assert catalog.resolve("code", "ask", has_params=False).method == "GET"
class TestParamSplit:
def test_body_methods_get_a_body(self):
route = openapi.Route("POST", "/v1/kb/search", {})
query, body = openapi.split_params(route, {"query": "x"})
assert query is None and body == {"query": "x"}
def test_read_methods_get_a_query(self):
route = openapi.Route("GET", "/v1/billing/plans", {})
query, body = openapi.split_params(route, {"limit": 5})
assert query == {"limit": 5} and body is None
def test_bound_path_params_are_not_resent(self):
route = openapi.Route("GET", "/v1/vector/c1", {"name": "c1"})
query, _ = openapi.split_params(route, {"name": "c1", "limit": 2})
assert query == {"limit": 2}
class TestSpecSource:
def test_default_is_the_target_itself(self, monkeypatch):
monkeypatch.delenv("HANZO_OPENAPI_URL", raising=False)
assert openapi.spec_url("https://api.hanzo.ai") == (
"https://api.hanzo.ai/v1/openapi.json"
)
def test_override_decouples_catalog_from_target(self, monkeypatch):
monkeypatch.setenv("HANZO_OPENAPI_URL", "https://api.hanzo.ai/v1/openapi.json")
assert openapi.spec_url("http://127.0.0.1:18080").startswith("https://")
def test_cache_file_is_per_source(self, monkeypatch):
monkeypatch.delenv("HANZO_OPENAPI_URL", raising=False)
a = openapi.cache_file("https://api.hanzo.ai")
b = openapi.cache_file("http://127.0.0.1:18080")
assert a != b
def test_stale_cache_beats_failing_the_call(self, monkeypatch, tmp_path):
"""A catalog is only route names; a stale one still routes."""
cached = tmp_path / "openapi.json"
cached.write_text(json.dumps(SPEC))
monkeypatch.setattr(openapi, "cache_file", lambda _: cached)
monkeypatch.setattr(
openapi, "fetch", lambda *a, **k: (_ for _ in ()).throw(RuntimeError("down"))
)
document, source = openapi.load("https://api.hanzo.ai", refresh=True)
assert document == SPEC and source.startswith("cache-stale:")