mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-26 11:49:36 +00:00
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]>
157 lines
5.8 KiB
Python
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:")
|