-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathhanzo.yml
More file actions
338 lines (327 loc) · 16.8 KB
/
Copy pathhanzo.yml
File metadata and controls
338 lines (327 loc) · 16.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
# 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.
- name: declared-imports
run: |
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/'"