|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Pre-publish guard: agent-assembly's dependency floors must co-resolve with the |
| 3 | +supported agent frameworks. |
| 4 | +
|
| 5 | +WHY this exists (AAASM-4518): a well-meaning "pin the floor to the resolved |
| 6 | +version" bump (AAASM-4434) raised ``pydantic``/``protobuf`` floors above what |
| 7 | +CrewAI, Semantic Kernel, and AutoGen permit. Because the floors are metadata, the |
| 8 | +break only surfaced *after* the wheel was published to PyPI (rc.4 is immutable) — |
| 9 | +downstream users could no longer ``pip install agent-assembly`` alongside their |
| 10 | +framework. This guard turns that post-publish surprise into a PR-time failure. |
| 11 | +
|
| 12 | +It resolves the SDK's *declared runtime floors* (read live from |
| 13 | +``[project.dependencies]``) together with each framework at its current release |
| 14 | +floor, using ``uv pip compile`` as an offline-metadata SAT solve. It never builds |
| 15 | +or imports agent-assembly, so it is fast and runs on any platform. A floor that |
| 16 | +excludes a framework's supported range makes the union unsatisfiable and fails the |
| 17 | +guard, naming the conflicting constraint. |
| 18 | +
|
| 19 | +Usage:: |
| 20 | +
|
| 21 | + python scripts/ci/check_framework_resolution.py # check current tree |
| 22 | + python scripts/ci/check_framework_resolution.py --python-version 3.12 |
| 23 | + python scripts/ci/check_framework_resolution.py --pyproject path/to/pyproject.toml |
| 24 | +
|
| 25 | +Exit code 0 = every framework co-resolves; 1 = at least one conflict (details |
| 26 | +printed); 2 = harness error (uv missing, pyproject unreadable). |
| 27 | +""" |
| 28 | + |
| 29 | +from __future__ import annotations |
| 30 | + |
| 31 | +import argparse |
| 32 | +import subprocess |
| 33 | +import sys |
| 34 | +import tempfile |
| 35 | +import tomllib |
| 36 | +from pathlib import Path |
| 37 | + |
| 38 | +# The frameworks agent-assembly ships quick-start examples for, each pinned at the |
| 39 | +# CURRENT release floor. The floor matters: an unconstrained `framework` lets the |
| 40 | +# resolver pick an ancient version that trivially resolves, hiding the very conflict |
| 41 | +# we guard against. Bump these floors when a framework's supported release moves. |
| 42 | +# (Mirrors the frameworks called out in AAASM-4518 / examples#268.) |
| 43 | +FRAMEWORK_MATRIX: list[str] = [ |
| 44 | + "autogen-core>=0.7.5", # caps protobuf<5.30 |
| 45 | + "crewai>=1.15.2", # caps pydantic<2.13 |
| 46 | + "semantic-kernel>=1.30", # caps pydantic<2.12 |
| 47 | +] |
| 48 | + |
| 49 | +DEFAULT_PYTHON_VERSION = "3.12" # the SDK's minimum supported interpreter |
| 50 | + |
| 51 | + |
| 52 | +def read_runtime_floors(pyproject: Path) -> list[str]: |
| 53 | + """Return the ``[project.dependencies]`` specifiers verbatim from pyproject.toml.""" |
| 54 | + with pyproject.open("rb") as fh: |
| 55 | + data = tomllib.load(fh) |
| 56 | + deps = data.get("project", {}).get("dependencies") |
| 57 | + if not deps: |
| 58 | + raise SystemExit(f"error: no [project.dependencies] found in {pyproject}") |
| 59 | + # Drop self-referential extras (e.g. `agent-assembly[runtime]`) — they are not |
| 60 | + # third-party floors and would force a build of the local project. |
| 61 | + return [d for d in deps if not d.replace(" ", "").startswith("agent-assembly")] |
| 62 | + |
| 63 | + |
| 64 | +def resolve(reqs: list[str], python_version: str) -> tuple[bool, str]: |
| 65 | + """Try to resolve ``reqs`` together. Returns (ok, combined resolver output).""" |
| 66 | + with tempfile.TemporaryDirectory() as tmp: |
| 67 | + req_in = Path(tmp) / "req.in" |
| 68 | + req_out = Path(tmp) / "req.txt" |
| 69 | + req_in.write_text("\n".join(reqs) + "\n") |
| 70 | + proc = subprocess.run( |
| 71 | + [ |
| 72 | + "uv", |
| 73 | + "pip", |
| 74 | + "compile", |
| 75 | + str(req_in), |
| 76 | + "--python-version", |
| 77 | + python_version, |
| 78 | + "--output-file", |
| 79 | + str(req_out), |
| 80 | + "--quiet", |
| 81 | + "--no-header", |
| 82 | + ], |
| 83 | + capture_output=True, |
| 84 | + text=True, |
| 85 | + ) |
| 86 | + return proc.returncode == 0, (proc.stderr + proc.stdout).strip() |
| 87 | + |
| 88 | + |
| 89 | +def main() -> int: |
| 90 | + parser = argparse.ArgumentParser(description=__doc__) |
| 91 | + parser.add_argument( |
| 92 | + "--pyproject", |
| 93 | + default=str(Path(__file__).resolve().parents[2] / "pyproject.toml"), |
| 94 | + help="path to pyproject.toml (default: repo root)", |
| 95 | + ) |
| 96 | + parser.add_argument( |
| 97 | + "--python-version", |
| 98 | + default=DEFAULT_PYTHON_VERSION, |
| 99 | + help=f"target interpreter for resolution (default: {DEFAULT_PYTHON_VERSION})", |
| 100 | + ) |
| 101 | + args = parser.parse_args() |
| 102 | + |
| 103 | + try: |
| 104 | + floors = read_runtime_floors(Path(args.pyproject)) |
| 105 | + except (OSError, tomllib.TOMLDecodeError) as exc: |
| 106 | + print(f"error: cannot read {args.pyproject}: {exc}", file=sys.stderr) |
| 107 | + return 2 |
| 108 | + |
| 109 | + print("agent-assembly runtime floors under test:") |
| 110 | + for spec in floors: |
| 111 | + print(f" {spec}") |
| 112 | + print(f"target python: {args.python_version}\n") |
| 113 | + |
| 114 | + failures: list[tuple[str, str]] = [] |
| 115 | + for framework in FRAMEWORK_MATRIX: |
| 116 | + ok, output = resolve(floors + [framework], args.python_version) |
| 117 | + status = "OK " if ok else "FAIL" |
| 118 | + print(f"[{status}] agent-assembly floors + {framework}") |
| 119 | + if not ok: |
| 120 | + failures.append((framework, output)) |
| 121 | + |
| 122 | + if failures: |
| 123 | + print("\nFramework resolution conflicts (a floor excludes a supported framework):") |
| 124 | + for framework, output in failures: |
| 125 | + print(f"\n--- {framework} ---\n{output}") |
| 126 | + print( |
| 127 | + "\nRelax the offending floor to the widest range the SDK actually " |
| 128 | + "supports before publishing. See AAASM-4518." |
| 129 | + ) |
| 130 | + return 1 |
| 131 | + |
| 132 | + print("\nAll frameworks co-resolve with the current dependency floors.") |
| 133 | + return 0 |
| 134 | + |
| 135 | + |
| 136 | +if __name__ == "__main__": |
| 137 | + raise SystemExit(main()) |
0 commit comments