Skip to content

Commit 9ea219d

Browse files
committed
Add Cython ABI checking tool to toolshed
1 parent fd5700d commit 9ea219d

File tree

1 file changed

+167
-0
lines changed

1 file changed

+167
-0
lines changed

toolshed/check_cython_abi.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
6+
"""
7+
Tool to check for Cython ABI changes in a given package.
8+
9+
The workflow is basically:
10+
11+
1) Build and install a "clean" upstream version of the package.
12+
13+
2) Generate ABI files from the package by running (in the same venv in which the
14+
package is installed), where `package_name` is the import path to the package,
15+
e.g. `cuda.bindings`:
16+
17+
python check_cython_abi.py generate <package_name> <dir>
18+
19+
3) Checkout a version with the changes to by tested, and build and install.
20+
21+
4) Check the ABI against the previously generated files by running:
22+
23+
python check_cython_abi.py check <package_name> <dir>
24+
"""
25+
26+
import importlib
27+
import json
28+
import re
29+
import sysconfig
30+
from pathlib import Path
31+
32+
EXT_SUFFIX = sysconfig.get_config_var("EXT_SUFFIX")
33+
ABI_SUFFIX = ".abi.json"
34+
35+
36+
def short_stem(name: str) -> str:
37+
return name[: name.find(".")]
38+
39+
40+
def get_package_path(package_name: str) -> Path:
41+
package = importlib.import_module(package_name)
42+
return Path(package.__file__).parent
43+
44+
45+
def import_from_path(root_package: str, root_dir: Path, path: Path) -> object:
46+
path = path.relative_to(root_dir)
47+
parts = [root_package] + list(path.parts[:-1]) + [short_stem(path.name)]
48+
return importlib.import_module(".".join(parts))
49+
50+
51+
def so_path_to_abi_path(so_path: Path, build_dir: Path, abi_dir: Path) -> Path:
52+
abi_name = short_stem(so_path.name) + ABI_SUFFIX
53+
return abi_dir / so_path.parent.relative_to(build_dir) / abi_name
54+
55+
56+
def abi_path_to_so_path(abi_path: Path, build_dir: Path, abi_dir: Path) -> Path:
57+
so_name = short_stem(abi_path.name) + EXT_SUFFIX
58+
return build_dir / abi_path.parent.relative_to(abi_dir) / so_name
59+
60+
61+
def pyx_capi_to_json(d: dict[str, object]) -> dict[str, str]:
62+
"""
63+
Converts the __pyx_capi__ dictionary to a JSON-serializable dictionary,
64+
removing any memory addresses that are irrelevant for comparison.
65+
"""
66+
67+
def extract_name(v: object) -> str:
68+
v = str(v)
69+
match = re.match(r'<capsule object "([^\"]+)" at 0x[0-9a-fA-F]+>', v)
70+
assert match, f"Could not parse __pyx_capi__ entry: {v}"
71+
return match.group(1)
72+
73+
# Sort the dictionary by keys to make diffs in the JSON files smaller
74+
return {k: extract_name(d[k]) for k in sorted(d.keys())}
75+
76+
77+
def check_abi(expected: dict[str, str], found: dict[str, str]) -> tuple[bool, bool]:
78+
has_errors = False
79+
has_allowed_changes = False
80+
for k, v in expected.items():
81+
if k not in found:
82+
print(f" Missing symbol: {k}")
83+
has_errors = True
84+
elif found[k] != v:
85+
print(f" Changed symbol: {k}: expected {v}, got {found[k]}")
86+
has_errors = True
87+
for k, v in found.items():
88+
if k not in expected:
89+
print(f" Added symbol: {k}")
90+
has_allowed_changes = True
91+
return has_errors, has_allowed_changes
92+
93+
94+
def check(package: str, abi_dir: Path) -> tuple[bool, bool]:
95+
build_dir = get_package_path(package)
96+
97+
has_errors = False
98+
has_allowed_changes = False
99+
for abi_path in Path(abi_dir).glob(f"**/*{ABI_SUFFIX}"):
100+
so_path = abi_path_to_so_path(abi_path, build_dir, abi_dir)
101+
if so_path.is_file():
102+
module = import_from_path(package, build_dir, so_path)
103+
if hasattr(module, "__pyx_capi__"):
104+
found_json = pyx_capi_to_json(module.__pyx_capi__)
105+
with open(abi_path, encoding="utf-8") as f:
106+
expected_json = json.load(f)
107+
print(f"Checking module: {so_path.relative_to(build_dir)}")
108+
check_errors, check_allowed_changes = check_abi(expected_json, found_json)
109+
has_errors |= check_errors
110+
has_allowed_changes |= check_allowed_changes
111+
else:
112+
print(f"Module no longer has an exposed ABI: {so_path.relative_to(build_dir)}")
113+
has_errors = True
114+
else:
115+
print(f"No module found for {abi_path.relative_to(abi_dir)}")
116+
has_errors = True
117+
118+
for so_path in Path(build_dir).glob(f"**/*{EXT_SUFFIX}"):
119+
module = import_from_path(package, build_dir, so_path)
120+
if hasattr(module, "__pyx_capi__"):
121+
abi_path = so_path_to_abi_path(so_path, build_dir, abi_dir)
122+
if not abi_path.is_file():
123+
print(f"New module added {so_path.relative_to(build_dir)}")
124+
has_allowed_changes = True
125+
126+
if has_errors:
127+
print("ERRORS FOUND")
128+
elif has_allowed_changes:
129+
print("Allowed changes found.")
130+
131+
132+
def regenerate(package: str, abi_dir: Path) -> None:
133+
if not abi_dir.is_dir():
134+
abi_dir.mkdir(parents=True, exist_ok=True)
135+
136+
build_dir = get_package_path(package)
137+
for so_path in Path(build_dir).glob(f"**/*{EXT_SUFFIX}"):
138+
print(f"Generating ABI from {so_path.relative_to(build_dir)}")
139+
module = import_from_path(package, build_dir, so_path)
140+
if hasattr(module, "__pyx_capi__"):
141+
abi_path = so_path_to_abi_path(so_path, build_dir, abi_dir)
142+
abi_path.parent.mkdir(parents=True, exist_ok=True)
143+
with open(abi_path, "w", encoding="utf-8") as f:
144+
json.dump(pyx_capi_to_json(module.__pyx_capi__), f, indent=2)
145+
146+
147+
if __name__ == "__main__":
148+
import argparse
149+
150+
parser = argparse.ArgumentParser(
151+
prog="check_cython_abi", description="Checks for changes in the Cython ABI of a given package"
152+
)
153+
154+
subparsers = parser.add_subparsers()
155+
156+
regen_parser = subparsers.add_parser("generate", help="Regenerate the ABI files")
157+
regen_parser.set_defaults(func=regenerate)
158+
regen_parser.add_argument("package", help="Python package to collect data from")
159+
regen_parser.add_argument("dir", help="Output directory to save data to")
160+
161+
check_parser = subparsers.add_parser("check", help="Check the API against existing ABI files")
162+
check_parser.set_defaults(func=check)
163+
check_parser.add_argument("package", help="Python package to collect data from")
164+
check_parser.add_argument("dir", help="Input directory to read data from")
165+
166+
args = parser.parse_args()
167+
args.func(args.package, Path(args.dir))

0 commit comments

Comments
 (0)