Skip to content

Commit 9f62ddb

Browse files
Python: add public API compatibility checks (microsoft#8271)
* Python: add public API compatibility workflow Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d1fa3e1a-596f-446f-b77f-5c84ec1eaa38 * Python: address API compatibility review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d1fa3e1a-596f-446f-b77f-5c84ec1eaa38 * Python: align API checks with merge refs and stages Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d1fa3e1a-596f-446f-b77f-5c84ec1eaa38 * CI: validate API compatibility workflow edits Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d1fa3e1a-596f-446f-b77f-5c84ec1eaa38 * CI: separate breaking intent from approval Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d1fa3e1a-596f-446f-b77f-5c84ec1eaa38 --------- Copilot-Session: d1fa3e1a-596f-446f-b77f-5c84ec1eaa38
1 parent 557a061 commit 9f62ddb

7 files changed

Lines changed: 665 additions & 3 deletions

File tree

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
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

Comments
 (0)