mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
Hanzo CI/CD / cicd (push) Successful in 2m21s
CI/CD / cicd (push) Successful in 2m21s
v8.5.160 regenerated the client and its release run passed the client check, then stopped at declared-imports: the step called bare `python`, the runner has none on its PATH, and exit 127 from a step that is report only ended the run before anything was published. It now runs through uv like the three steps beside it. Verified by running the whole test: block with no python on PATH. Co-authored-by: Hanzo Dev <[email protected]>
348 lines
17 KiB
YAML
348 lines
17 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 this repo delivers is a distribution, declared
|
|
# below, and the rest of the file is the gate in front of it.
|
|
#
|
|
# 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".
|
|
|
|
# The root project is `hanzoai`, the generated cloud client; everything under
|
|
# pkg/ is a distribution of its own. A glob rather than 65 names, so a package
|
|
# added under pkg/ publishes without anyone remembering to list it — which the
|
|
# retired per-repo workflow's hardcoded lists could not do.
|
|
#
|
|
# A `v*` tag releases whatever moved (twine skips the versions already on the
|
|
# index); `hanzo-mcp-v1.2.3` releases that one. hanzoai/ci reads the name out of
|
|
# each pyproject, so the tag and the distribution cannot disagree.
|
|
pypi: [., pkg/*]
|
|
|
|
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)
|
|
'
|
|
|
|
# Which packages import something they do not declare.
|
|
#
|
|
# REPORT ONLY, and the reason is a limit of reading rather than a soft
|
|
# opinion: from the manifest alone, an import that works because a sibling
|
|
# dependency happens to pull it in is indistinguishable from one that will
|
|
# fail on a clean install. hanzo-zap imports `zap` and declares `zap-proto`,
|
|
# which provides it. hanzo-tools-iam imports `mcp` and declares
|
|
# hanzo-tools-core, which depends on it. Neither is broken; both look
|
|
# identical to a parser. Failing on 92 of these on day one is how a gate gets
|
|
# switched off.
|
|
#
|
|
# The authoritative check is in .github/workflows/publish-pypi.yml, which
|
|
# installs each built wheel alone into a clean venv and imports it before
|
|
# twine sees it. That one can tell the difference, because it tries.
|
|
#
|
|
# This still earns its place: relying on a transitive dependency is a break
|
|
# waiting for the day that sibling drops it, and 92 rows is a map of where
|
|
# that risk sits. Read it, do not gate on it.
|
|
#
|
|
# It runs under the interpreter the steps beside it fetch. The runner has no
|
|
# `python` on its PATH, so the bare name exited 127 and a step that is report
|
|
# only stopped a release.
|
|
- name: declared-imports
|
|
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 - <<'PY'
|
|
import ast, pathlib, sys, tomllib
|
|
|
|
# import name -> distribution name, where they differ.
|
|
ALIAS = {
|
|
"yaml": "pyyaml", "PIL": "pillow", "dotenv": "python-dotenv",
|
|
"jwt": "pyjwt", "dateutil": "python-dateutil", "sklearn": "scikit-learn",
|
|
"cv2": "opencv-python", "bs4": "beautifulsoup4", "magic": "python-magic",
|
|
"google": "google-api-python-client", "OpenSSL": "pyopenssl",
|
|
"zoneinfo": "backports.zoneinfo", "pkg_resources": "setuptools",
|
|
"attr": "attrs", "serial": "pyserial", "usb": "pyusb",
|
|
# Ours: the distribution and the import name differ.
|
|
"zap": "zap-proto",
|
|
}
|
|
norm = lambda s: s.lower().replace("_", "-")
|
|
|
|
def declared(doc):
|
|
out = set()
|
|
proj = doc.get("project", {})
|
|
for d in proj.get("dependencies", []) or []:
|
|
out.add(norm(d.split(";")[0].strip().split("[")[0]
|
|
.split("==")[0].split(">=")[0].split("<")[0]
|
|
.split("~=")[0].split("!")[0].strip()))
|
|
for group in (proj.get("optional-dependencies") or {}).values():
|
|
for d in group:
|
|
out.add(norm(d.split(";")[0].strip().split("[")[0]
|
|
.split("==")[0].split(">=")[0].split("<")[0]
|
|
.split("~=")[0].split("!")[0].strip()))
|
|
poetry = doc.get("tool", {}).get("poetry", {})
|
|
for name in (poetry.get("dependencies") or {}):
|
|
out.add(norm(name))
|
|
for group in (poetry.get("group") or {}).values():
|
|
for name in (group.get("dependencies") or {}):
|
|
out.add(norm(name))
|
|
for group in (poetry.get("extras") or {}).values():
|
|
for name in group:
|
|
out.add(norm(name))
|
|
out.discard("python")
|
|
return out
|
|
|
|
def tops(path):
|
|
try:
|
|
tree = ast.parse(path.read_text(encoding="utf-8", errors="replace"))
|
|
except SyntaxError:
|
|
return set()
|
|
seen = set()
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Import):
|
|
for a in node.names:
|
|
seen.add(a.name.split(".")[0])
|
|
elif isinstance(node, ast.ImportFrom):
|
|
# level > 0 is relative and belongs to the package itself.
|
|
if not node.level and node.module:
|
|
seen.add(node.module.split(".")[0])
|
|
return seen
|
|
|
|
missing, unmapped = [], []
|
|
for manifest in sorted(pathlib.Path("pkg").glob("*/pyproject.toml")):
|
|
pkg = manifest.parent
|
|
with manifest.open("rb") as fh:
|
|
doc = tomllib.load(fh)
|
|
have = declared(doc)
|
|
used = set()
|
|
for py in pkg.rglob("*.py"):
|
|
parts = py.parts
|
|
if any(p in ("test", "tests", "examples", "build", ".venv") for p in parts):
|
|
continue
|
|
used |= tops(py)
|
|
for name in sorted(used):
|
|
if name in sys.stdlib_module_names or name.startswith("_"):
|
|
continue
|
|
# First party: this repo's own packages, and the package itself.
|
|
if name.startswith("hanzo") or norm(name) == norm(pkg.name):
|
|
continue
|
|
if (pkg / name).exists() or (pkg / f"{name}.py").exists():
|
|
continue
|
|
dist = ALIAS.get(name, norm(name))
|
|
if dist in have or norm(name) in have:
|
|
continue
|
|
(unmapped if name not in ALIAS and "." not in name and len(name) <= 2
|
|
else missing).append(f"{pkg.name}: imports {name!r}, declares no {dist!r}")
|
|
|
|
for line in sorted(missing + unmapped):
|
|
print("UNDECLARED", line)
|
|
total = len(list(pathlib.Path("pkg").glob("*/pyproject.toml")))
|
|
print(f"{total} packages checked, {len(missing) + len(unmapped)} imports not declared")
|
|
print("Not a failure: the publish lane installs each wheel clean and imports it,")
|
|
print("which is the check that can tell a transitive dependency from a missing one.")
|
|
PY
|
|
|
|
- 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 ("models", "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:
|
|
# WHICH cloud, said out loud. hanzoai/ci defaults this to `hanzoai/cloud`, and
|
|
# that repo is now the re-rooted OSS core — a short line sharing no ancestor
|
|
# with the product. So the default resolved to a tree that does not contain the
|
|
# ref this projection is pinned to, and every run of the client lane, check or
|
|
# move, died on `curl: (22) ... 404` fetching a document that was never there.
|
|
#
|
|
# It reads as a credential problem and is not one: the same token fetches the
|
|
# same ref from hanzo-inc/cloud with a 200. The product line is the one whose
|
|
# openapi.yaml this SDK is a projection of, so it is the one named here.
|
|
#
|
|
# This is also the field `.spec-lock`'s own `repo=` line does NOT set — ci reads
|
|
# the repo from here and uses the lock's copy only for a commit message, so the
|
|
# two can disagree silently. They are one fact; keep them equal.
|
|
spec:
|
|
repo: hanzo-inc/cloud
|
|
generate: ./scripts/generate.sh
|
|
version: "pyproject.toml:grep -m1 '^version' pyproject.toml | sed -E 's/.*\"(.*)\".*/\\1/'"
|