|
| 1 | +# Copyright (c) Microsoft. All rights reserved. |
| 2 | + |
| 3 | +"""Check Python API compatibility against a pull request's base commit.""" |
| 4 | + |
| 5 | +from __future__ import annotations |
| 6 | + |
| 7 | +import argparse |
| 8 | +import os |
| 9 | +import re |
| 10 | +import subprocess |
| 11 | +import tempfile |
| 12 | +from collections.abc import Iterator |
| 13 | +from contextlib import contextmanager |
| 14 | +from dataclasses import dataclass |
| 15 | +from importlib.metadata import version |
| 16 | +from pathlib import Path |
| 17 | + |
| 18 | +from griffe import ( |
| 19 | + Alias, |
| 20 | + AliasResolutionError, |
| 21 | + ExplanationStyle, |
| 22 | + Object, |
| 23 | + find_breaking_changes, |
| 24 | + load, |
| 25 | +) |
| 26 | + |
| 27 | +_UNRELEASED_DECORATORS = frozenset( |
| 28 | + { |
| 29 | + "agent_framework._feature_stage.experimental", |
| 30 | + "agent_framework._feature_stage.release_candidate", |
| 31 | + } |
| 32 | +) |
| 33 | +_GRIFFE_VERSION = version("griffe") |
| 34 | +_PACKAGE_STATUS_ROW = re.compile( |
| 35 | + r"^\| `(?P<name>[^`]+)` \| `(?P<path>python/packages/[^`]+)` \| `(?P<state>[^`]+)` \|$" |
| 36 | +) |
| 37 | + |
| 38 | + |
| 39 | +@dataclass(frozen=True, order=True) |
| 40 | +class ModuleSpec: |
| 41 | + """A top-level import package and its owning distribution.""" |
| 42 | + |
| 43 | + module: str |
| 44 | + search_path: Path |
| 45 | + |
| 46 | + |
| 47 | +def _git(repo: Path, *args: str) -> str: |
| 48 | + result = subprocess.run( |
| 49 | + ["git", *args], |
| 50 | + cwd=repo, |
| 51 | + check=True, |
| 52 | + capture_output=True, |
| 53 | + text=True, |
| 54 | + ) |
| 55 | + return result.stdout |
| 56 | + |
| 57 | + |
| 58 | +@contextmanager |
| 59 | +def _base_source(repo: Path, base_sha: str) -> Iterator[Path]: |
| 60 | + archive = subprocess.run( |
| 61 | + ["git", "archive", base_sha, "python"], |
| 62 | + cwd=repo, |
| 63 | + check=True, |
| 64 | + capture_output=True, |
| 65 | + ).stdout |
| 66 | + with tempfile.TemporaryDirectory(prefix="python-api-base-") as directory: |
| 67 | + subprocess.run( |
| 68 | + ["tar", "-xf", "-", "-C", directory], |
| 69 | + input=archive, |
| 70 | + check=True, |
| 71 | + ) |
| 72 | + yield Path(directory) |
| 73 | + |
| 74 | + |
| 75 | +def _package_states(repo: Path, base_sha: str) -> dict[Path, str]: |
| 76 | + status_document = _git(repo, "show", f"{base_sha}:python/PACKAGE_STATUS.md") |
| 77 | + states: dict[Path, str] = {} |
| 78 | + |
| 79 | + for line in status_document.splitlines(): |
| 80 | + match = _PACKAGE_STATUS_ROW.match(line) |
| 81 | + if match: |
| 82 | + states[Path(match["path"])] = match["state"] |
| 83 | + |
| 84 | + if not states: |
| 85 | + raise RuntimeError( |
| 86 | + "No package lifecycle states were found in python/PACKAGE_STATUS.md" |
| 87 | + ) |
| 88 | + |
| 89 | + return states |
| 90 | + |
| 91 | + |
| 92 | +def _module_specs( |
| 93 | + repo: Path, base_sha: str, package_states: dict[Path, str] |
| 94 | +) -> list[ModuleSpec]: |
| 95 | + tracked_files = _git( |
| 96 | + repo, "ls-tree", "-r", "--name-only", base_sha, "--", "python/packages" |
| 97 | + ) |
| 98 | + specs: set[ModuleSpec] = set() |
| 99 | + |
| 100 | + for file_name in tracked_files.splitlines(): |
| 101 | + parts = Path(file_name).parts |
| 102 | + if ( |
| 103 | + len(parts) != 5 |
| 104 | + or parts[:2] != ("python", "packages") |
| 105 | + or parts[4] != "__init__.py" |
| 106 | + or parts[3] in {"build", "tests"} |
| 107 | + ): |
| 108 | + continue |
| 109 | + |
| 110 | + package_path = Path(*parts[:3]) |
| 111 | + if package_path.name == "lab": |
| 112 | + continue |
| 113 | + if package_path not in package_states: |
| 114 | + raise RuntimeError( |
| 115 | + f"{package_path} is missing from python/PACKAGE_STATUS.md" |
| 116 | + ) |
| 117 | + if package_states[package_path] != "released": |
| 118 | + continue |
| 119 | + |
| 120 | + specs.add(ModuleSpec(module=parts[3], search_path=package_path)) |
| 121 | + |
| 122 | + return sorted(specs) |
| 123 | + |
| 124 | + |
| 125 | +def _resolved(obj: Object | Alias) -> Object | None: |
| 126 | + try: |
| 127 | + return obj.final_target if isinstance(obj, Alias) else obj |
| 128 | + except AliasResolutionError: |
| 129 | + return None |
| 130 | + |
| 131 | + |
| 132 | +def _unreleased_owner(obj: Object | Alias) -> str | None: |
| 133 | + current = _resolved(obj) |
| 134 | + while current is not None: |
| 135 | + decorators = getattr(current, "decorators", ()) |
| 136 | + if any( |
| 137 | + decorator.callable_path in _UNRELEASED_DECORATORS |
| 138 | + for decorator in decorators |
| 139 | + ): |
| 140 | + return str(current.path) |
| 141 | + current = current.parent |
| 142 | + return None |
| 143 | + |
| 144 | + |
| 145 | +def _write_summary( |
| 146 | + *, |
| 147 | + checked: int, |
| 148 | + breakages: int, |
| 149 | + acknowledged: bool, |
| 150 | +) -> None: |
| 151 | + summary_path = os.getenv("GITHUB_STEP_SUMMARY") |
| 152 | + if not summary_path: |
| 153 | + return |
| 154 | + |
| 155 | + lines = [ |
| 156 | + "## Python public API compatibility", |
| 157 | + "", |
| 158 | + f"Compared {checked} released import packages with the PR base using Griffe {_GRIFFE_VERSION}.", |
| 159 | + "", |
| 160 | + f"- Published API breakages: {breakages}", |
| 161 | + ] |
| 162 | + if breakages and acknowledged: |
| 163 | + lines.extend( |
| 164 | + [ |
| 165 | + "", |
| 166 | + "Published API breakages were acknowledged by the `breaking change` label.", |
| 167 | + ] |
| 168 | + ) |
| 169 | + elif breakages: |
| 170 | + lines.extend( |
| 171 | + [ |
| 172 | + "", |
| 173 | + "Restore compatibility or acknowledge the intentional break with the `breaking change` label.", |
| 174 | + ] |
| 175 | + ) |
| 176 | + |
| 177 | + with Path(summary_path).open("a") as summary: |
| 178 | + summary.write("\n".join(lines) + "\n") |
| 179 | + |
| 180 | + |
| 181 | +def main() -> int: |
| 182 | + parser = argparse.ArgumentParser(description=__doc__) |
| 183 | + parser.add_argument( |
| 184 | + "--base-sha", required=True, help="Trusted pull request base commit" |
| 185 | + ) |
| 186 | + parser.add_argument( |
| 187 | + "--repo", type=Path, default=Path.cwd(), help="Pull request checkout" |
| 188 | + ) |
| 189 | + parser.add_argument("--acknowledge-breaking-changes", action="store_true") |
| 190 | + args = parser.parse_args() |
| 191 | + |
| 192 | + repo = args.repo.resolve() |
| 193 | + os.chdir(repo) |
| 194 | + package_states = _package_states(repo, args.base_sha) |
| 195 | + specs = _module_specs(repo, args.base_sha, package_states) |
| 196 | + breakage_count = 0 |
| 197 | + |
| 198 | + with _base_source(repo, args.base_sha) as base_source: |
| 199 | + for spec in specs: |
| 200 | + print(f"::group::{spec.module}") |
| 201 | + old_package = load( |
| 202 | + spec.module, |
| 203 | + search_paths=[base_source / spec.search_path], |
| 204 | + allow_inspection=False, |
| 205 | + resolve_aliases=True, |
| 206 | + ) |
| 207 | + try: |
| 208 | + new_package = load( |
| 209 | + spec.module, |
| 210 | + search_paths=[spec.search_path], |
| 211 | + allow_inspection=False, |
| 212 | + resolve_aliases=True, |
| 213 | + ) |
| 214 | + except ModuleNotFoundError: |
| 215 | + breakage_count += 1 |
| 216 | + print( |
| 217 | + f"::warning title={spec.module}::" |
| 218 | + "Released public import package was removed" |
| 219 | + ) |
| 220 | + print("::endgroup::") |
| 221 | + continue |
| 222 | + |
| 223 | + for breakage in find_breaking_changes(old_package, new_package): |
| 224 | + try: |
| 225 | + old_breakage_obj = old_package.modules_collection.get_member( |
| 226 | + breakage.obj.path |
| 227 | + ) |
| 228 | + except KeyError: |
| 229 | + old_breakage_obj = breakage.obj |
| 230 | + unreleased_owner = _unreleased_owner(old_breakage_obj) |
| 231 | + |
| 232 | + if unreleased_owner is None: |
| 233 | + breakage_count += 1 |
| 234 | + print(breakage.explain(style=ExplanationStyle.GITHUB)) |
| 235 | + |
| 236 | + print("::endgroup::") |
| 237 | + |
| 238 | + _write_summary( |
| 239 | + checked=len(specs), |
| 240 | + breakages=breakage_count, |
| 241 | + acknowledged=args.acknowledge_breaking_changes, |
| 242 | + ) |
| 243 | + |
| 244 | + if breakage_count and not args.acknowledge_breaking_changes: |
| 245 | + return 1 |
| 246 | + return 0 |
| 247 | + |
| 248 | + |
| 249 | +if __name__ == "__main__": |
| 250 | + raise SystemExit(main()) |
0 commit comments