Map the imports no package declares

hanzo-agent 0.1.0 went to PyPI importing numpy and httpx and declaring
neither. In this repo the workspace supplies every sibling, so its own tests
all passed; `pip install hanzo-agent` then failed at import for everybody else.
An outside adopter hit that, gave up on three of our packages, and wrote their
own clients — the most expensive kind of bug we ship, because it is invisible
from inside the tree.

This reports every import across the 64 packages that its own manifest does not
declare. 90 of them.

It does NOT fail, and the reason is a limit of reading rather than leniency:
hanzo-zap imports `zap` and declares `zap-proto`, which provides it;
hanzo-tools-iam imports `mcp` and declares a sibling that depends on it.
Neither is broken and both look identical to a parser. Failing 90 rows on day
one is how a gate gets switched off.

It still earns its place. Relying on a transitive dependency is a break waiting
for the day that sibling drops it, and 90 rows is a map of where that sits.

The authoritative check — install each wheel alone in a clean venv and import
it — belongs in the shared publish lane rather than a copy here, which is the
point dd889c29 already made when it removed this repo's own lane.

The `zap` row was mine: the first run said 92 because the alias table did not
know zap-proto provides it.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-09-10 23:04:17 -07:00
co-authored by Hanzo Dev
parent ca850e5cc9
commit e6f6ff34d7
+110
View File
@@ -60,6 +60,116 @@ test:
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.
#