mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
README: PyPI serves 3.2.1 and this tree is 3.2.11, so the install line is the git one. Between the two the default version left the operation ids — 3.2.1 carries get_v1_keys/get_v1_tools (182 api modules, 2172 models) and this carries get_keys/get_tools — so `pip install hanzoai` above a `get_keys()` quickstart hands the reader a client that cannot run it. Counts are measured off the pinned document: 1814 paths, 2479 operations, 191 tags + default_api = 192 API classes over 2460 models, 2659 importable modules. Auth was wrong everywhere and it was the same root cause: the document declares no securityScheme, so every operation got an empty auth_settings and Configuration(access_token=…) is read by nothing. Serializing get_keys from such a Configuration produces a request with no Authorization header at all — the five flows have been calling anonymously and reading the refusal as a bad key. examples/client.py now passes the bearer through ApiClient's header_name/value pair, verified against a local server that echoes what it received, and a smoke test pins both halves so the day cloud emits the scheme, the test fails and the header moves back into Configuration. The examples gate resolved class names and stopped there, which is how `money` and `tools` once passed it while every call in them named a method that no longer existed. It now walks the syntax tree for attribute accesses on anything bound to a *Api and asks the class: ten names resolve, and a stale get_v1_tools fails in the same second the import passes. Deleted five example scripts that imported hanzoai.Hanzo, hanzoai.cluster, hanzoai.agents and hanzoai.completion — none of which exist — or were dict literals captioned "in practice this would be called through the MCP interface"; compileall passed all five, because syntax is not a claim. Deleted three docs that were fabrication rather than drift and that nothing rendered: FEATURES.md, QUICKSTART.md, GPT5_ORCHESTRATION.md. pkg/hanzo-agent said `pip install hanzoai` and `from hanzoai import Agent, Runner`; the client has no Agent. Its own `full` extra resolved to hanzoai[web3,tee,marketplace,cli], extras the client does not declare, so it installed the client and none of the extensions. pyproject said BSD-3-Clause while LICENSE is the Apache 2.0 text. test_smoke imported tracker_api and no /v1/tracker path is emitted any more, so the suite carried a failing test naming a module the client does not have; it reads analytics_api now, off the client.
207 lines
10 KiB
YAML
207 lines
10 KiB
YAML
# Hanzo CI — the ONE way this repo is gated, read by hanzoai/ci.
|
|
#
|
|
# No `images:` and no `deploy:`: these are libraries. A consumer installs them
|
|
# from PyPI at a tag, so what CI owes is a gate, not an artifact. Publishing
|
|
# stays where it already is — .hanzo/workflows/publish-pypi.yml on our own
|
|
# runners, which is the canonical path because it reads the PyPI token from KMS
|
|
# like every other publish credential in the fleet. Adding a second publish here
|
|
# would be one too many; this file only gates.
|
|
#
|
|
# Scope is the generated cloud client and the flows that exercise it. The other
|
|
# 64 packages under pkg/ are hand-written and carry their own tests; pulling
|
|
# them in here would make a red gate mean "something, somewhere" instead of
|
|
# "the client the spec just produced is broken".
|
|
|
|
test:
|
|
- name: cloud-client
|
|
# Import every generated module. For generated code this IS the build step:
|
|
# there is no compiler to catch a bad $ref or a model that references a class
|
|
# the generator declined to emit — the import is what turns those into a
|
|
# failure instead of an AttributeError in a user's app six months later.
|
|
#
|
|
# IT DOES NOT CATCH A NAME COLLISION, and this comment used to claim it did.
|
|
# A class body that declares the same field twice is valid Python: the second
|
|
# binding replaces the first and the module imports clean. 3.2.0 shipped to
|
|
# PyPI that way — O11yGettableAgentCheckIn declared `integration_config` and
|
|
# `removed_at` twice each, so the agent's value was read and dropped — and
|
|
# this gate was green the whole time. `duplicate-fields` below is what sees
|
|
# it, because it reads the syntax tree rather than the import.
|
|
#
|
|
# The arc runner image promises no interpreter, so provision one with uv
|
|
# when it is missing. A gate that silently no-ops is worse than no gate.
|
|
run: |
|
|
set -e
|
|
if command -v uv >/dev/null 2>&1; then UV=uv; else
|
|
curl -fsSL https://astral.sh/uv/install.sh | sh
|
|
UV="$HOME/.local/bin/uv"
|
|
fi
|
|
PYTHONPATH=pkg "$UV" run --no-project --python 3.12 \
|
|
--with pydantic --with python-dateutil --with urllib3 --with typing-extensions \
|
|
python -c '
|
|
import pkgutil, importlib, sys
|
|
import hanzoai.cloud.api as A, hanzoai.cloud.models as M
|
|
bad = []
|
|
for pkg, label in ((A, "api"), (M, "models")):
|
|
n = 0
|
|
for m in pkgutil.iter_modules(pkg.__path__):
|
|
try:
|
|
importlib.import_module(f"{pkg.__name__}.{m.name}"); n += 1
|
|
except Exception as e:
|
|
bad.append(f"{pkg.__name__}.{m.name}: {e!r}")
|
|
print(f"{label}: {n} modules imported")
|
|
for b in bad[:20]: print("FAIL", b, file=sys.stderr)
|
|
sys.exit(1 if bad else 0)
|
|
'
|
|
|
|
- name: duplicate-fields
|
|
# Read the syntax tree, because the import above cannot see this.
|
|
#
|
|
# `x: int` twice in one class body is legal Python. The second annotated
|
|
# assignment rebinds the name, the first is gone, and nothing complains —
|
|
# not the interpreter, not pydantic, not the import gate. The model then
|
|
# advertises a field it does not have, and the value that arrives on the
|
|
# wire under the shadowed name is read and dropped in silence. That is a
|
|
# data-loss bug that presents as no bug at all.
|
|
#
|
|
# This is not hypothetical and it is not rare: it is what the generator does
|
|
# every time two distinct spec properties normalise to one Python
|
|
# identifier. hanzoai 3.2.0 is on PyPI with exactly that — two collisions in
|
|
# O11yGettableAgentCheckIn — and the gate above was green for it.
|
|
#
|
|
# Stdlib `ast` only, no deps: a scan that needs an install is a scan that
|
|
# can fail to run for reasons unrelated to the code it is reading.
|
|
run: |
|
|
set -e
|
|
if command -v uv >/dev/null 2>&1; then UV=uv; else
|
|
curl -fsSL https://astral.sh/uv/install.sh | sh
|
|
UV="$HOME/.local/bin/uv"
|
|
fi
|
|
"$UV" run --no-project --python 3.12 python -c '
|
|
import ast, pathlib, sys
|
|
root = pathlib.Path("pkg/hanzoai/cloud")
|
|
hits, n = [], 0
|
|
for p in sorted(root.rglob("*.py")):
|
|
n += 1
|
|
try:
|
|
tree = ast.parse(p.read_text(encoding="utf-8", errors="replace"))
|
|
except SyntaxError as e:
|
|
hits.append("%s: does not parse: %r" % (p, e))
|
|
continue
|
|
for node in ast.walk(tree):
|
|
if not isinstance(node, ast.ClassDef):
|
|
continue
|
|
seen = {}
|
|
for s in node.body:
|
|
# AnnAssign is `name: Type = ...`, how every field is declared
|
|
if isinstance(s, ast.AnnAssign) and isinstance(s.target, ast.Name):
|
|
if not s.target.id.startswith("__"):
|
|
seen.setdefault(s.target.id, []).append(s.lineno)
|
|
for f, lines in sorted(seen.items()):
|
|
if len(lines) > 1:
|
|
hits.append("%s: %s.%s declared %d times, lines %s"
|
|
% (p, node.name, f, len(lines), lines))
|
|
print("duplicate-fields: scanned %d generated modules" % n)
|
|
# A zero here must mean "looked and found nothing", never "looked at
|
|
# nothing". If the tree moves, this fails loudly instead of passing empty.
|
|
if n == 0:
|
|
print("FAIL scanned no modules under", root, file=sys.stderr)
|
|
sys.exit(1)
|
|
for h in hits:
|
|
print("FAIL", h, file=sys.stderr)
|
|
sys.exit(1 if hits else 0)
|
|
'
|
|
|
|
- name: examples
|
|
# The canonical flows, imported against the client that was just generated.
|
|
# Each `from hanzoai.cloud import X` is an assertion that the symbol still
|
|
# exists, so a spec change that renames or drops a MODEL or an API class
|
|
# goes red here rather than in a user's app.
|
|
#
|
|
# Importing is enough for the CLASS names BECAUSE each flow guards its call
|
|
# behind `if __name__ == "__main__"` — the module body resolves every name
|
|
# without opening a socket, so the gate needs no API key and no network.
|
|
#
|
|
# It is NOT enough for the METHOD names, and that gap has shipped twice. An
|
|
# import resolves the `from … import` line and nothing else; a method is
|
|
# looked up at call time, so a renamed OPERATION passes an import and fails
|
|
# in a user's app. Measured: when the `<svc>_` prefix left every operationId,
|
|
# four flows failed on their imports and `money` and `tools` went on passing
|
|
# while every call in them named a method that no longer existed — the gate
|
|
# saw four of six.
|
|
#
|
|
# So the check below reads the syntax tree for attribute accesses on anything
|
|
# bound to a `*Api` and asks the class whether it has them. Ten names resolve
|
|
# today. The receiver is resolved through its assignment, so it catches
|
|
# `KeysApi.get_tools` as readily as a stale `get_v1_tools`.
|
|
#
|
|
# `chat` is NOT in this list, and its absence is a measurement rather than a
|
|
# decision. cloud declares POST /v1/chat/completions with no `requestBody`
|
|
# and no `responses` at all, so the generated method takes no body and
|
|
# returns None: the one call a chat example exists to make cannot be
|
|
# expressed. Inventing the type here, or hand-rolling the HTTP inside a
|
|
# generated client, is the exact drift these SDKs exist to prevent, and
|
|
# hanzoai/js-sdk dropped its own chat flow at 2.0.7 for this same reason.
|
|
# Restoring it is one step and the test is one line: when
|
|
# paths['/v1/chat/completions'] in cloud's openapi.yaml carries a
|
|
# requestBody, add examples/chat back and put "chat" back in the tuple.
|
|
run: |
|
|
set -e
|
|
if command -v uv >/dev/null 2>&1; then UV=uv; else
|
|
curl -fsSL https://astral.sh/uv/install.sh | sh
|
|
UV="$HOME/.local/bin/uv"
|
|
fi
|
|
PYTHONPATH=pkg "$UV" run --no-project --python 3.12 \
|
|
--with pydantic --with python-dateutil --with urllib3 --with typing-extensions \
|
|
python -c '
|
|
import ast, compileall, importlib, pathlib, sys
|
|
import hanzoai.cloud as C
|
|
ok = compileall.compile_dir("examples", quiet=1, maxlevels=3)
|
|
bad = []
|
|
for flow in ("hello", "money", "store", "agent", "tools"):
|
|
try:
|
|
importlib.import_module(f"examples.{flow}.__main__")
|
|
except Exception as e:
|
|
bad.append(f"examples.{flow}: {e!r}")
|
|
continue
|
|
tree = ast.parse(pathlib.Path(f"examples/{flow}/__main__.py").read_text())
|
|
bound = {}
|
|
for n in ast.walk(tree):
|
|
if (isinstance(n, ast.Assign) and isinstance(n.value, ast.Call)
|
|
and isinstance(n.value.func, ast.Name) and n.value.func.id.endswith("Api")):
|
|
bound.update({t.id: n.value.func.id for t in n.targets if isinstance(t, ast.Name)})
|
|
names = 0
|
|
for n in ast.walk(tree):
|
|
if not isinstance(n, ast.Attribute):
|
|
continue
|
|
v = n.value
|
|
cls = bound.get(v.id) if isinstance(v, ast.Name) else (
|
|
v.func.id if isinstance(v, ast.Call) and isinstance(v.func, ast.Name)
|
|
and v.func.id.endswith("Api") else None)
|
|
if not cls:
|
|
continue
|
|
names += 1
|
|
if not hasattr(getattr(C, cls), n.attr):
|
|
bad.append(f"examples.{flow}: {cls} has no {n.attr}")
|
|
print(f"examples.{flow}: OK, {names} method name(s) resolved")
|
|
for b in bad: print("FAIL", b, file=sys.stderr)
|
|
sys.exit(1 if (bad or not ok) else 0)
|
|
'
|
|
|
|
# THE CLIENT LANE — this repo is a PROJECTION of one API document at one version.
|
|
#
|
|
# hanzoai/cloud's release sends `repository_dispatch: spec-update` carrying
|
|
# (version, sha, spec_sha256); hanzoai/ci fetches openapi.yaml AT THAT SHA,
|
|
# REFUSES if the bytes hash to anything else, runs `generate:`, writes
|
|
# `.spec-lock` beside the code, and — only after the `test:` block below has
|
|
# passed over exactly those bytes — commits and cuts a patch.
|
|
#
|
|
# The document moved from hanzoai/openapi `hanzo.yaml` (hand-merged, 1742 paths,
|
|
# fed by nothing, and NOTHING HAS EVER SENT the spec-update this repo listens
|
|
# for) to hanzoai/cloud `openapi.yaml` — emitted from the code by each app's own
|
|
# router, and gated on every cloud release. It was 1058 paths when it took over
|
|
# and is 1814 now, which is the point: it grows from the routers rather than from
|
|
# an editor, so a true document overtook the unverified one on its own.
|
|
client:
|
|
generate: ./scripts/generate.sh
|
|
version: "pyproject.toml:grep -m1 '^version' pyproject.toml | sed -E 's/.*\"(.*)\".*/\\1/'"
|