Skip to content

Commit 56b53ca

Browse files
mdboomCopilot
andauthored
Add Cython ABI checking tool to toolshed (#1929)
* Add Cython ABI checking tool to toolshed * Update toolshed/check_cython_abi.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update toolshed/check_cython_abi.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update toolshed/check_cython_abi.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Address comments in PR * Address some of the comments in the PR --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent f73c1c8 commit 56b53ca

File tree

1 file changed

+218
-0
lines changed

1 file changed

+218
-0
lines changed

toolshed/check_cython_abi.py

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
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+
There are different types of ABI changes, only one of which is covered by this tool:
10+
11+
- cdef function signatures (capsule strings) — covered here
12+
- cdef class struct size (tp_basicsize) — not covered
13+
- cdef class vtable layout / method reordering — not covered, and this one fails as silent UB rather than an import-time error
14+
- Fused specialization ordering — partially covered (reorders manifest as capsule-name deltas, but the mapping is non-obvious)
15+
16+
The workflow is basically:
17+
18+
1) Build and install a "clean" upstream version of the package.
19+
20+
2) Generate ABI files from the package by running (in the same venv in which the
21+
package is installed), where `package_name` is the import path to the package,
22+
e.g. `cuda.bindings`:
23+
24+
python check_cython_abi.py generate <package_name> <dir>
25+
26+
3) Checkout a version with the changes to be tested, and build and install.
27+
28+
4) Check the ABI against the previously generated files by running:
29+
30+
python check_cython_abi.py check <package_name> <dir>
31+
"""
32+
33+
import ctypes
34+
import importlib
35+
import json
36+
import sys
37+
import sysconfig
38+
from pathlib import Path
39+
40+
EXT_SUFFIX = sysconfig.get_config_var("EXT_SUFFIX")
41+
ABI_SUFFIX = ".abi.json"
42+
43+
44+
_pycapsule_get_name = ctypes.pythonapi.PyCapsule_GetName
45+
_pycapsule_get_name.restype = ctypes.c_char_p
46+
_pycapsule_get_name.argtypes = [ctypes.py_object]
47+
48+
49+
def get_capsule_name(v: object) -> str:
50+
return _pycapsule_get_name(v).decode("utf-8")
51+
52+
53+
def short_stem(name: str) -> str:
54+
return name.split(".", 1)[0]
55+
56+
57+
def get_package_path(package_name: str) -> Path:
58+
package = importlib.import_module(package_name)
59+
return Path(package.__file__).parent
60+
61+
62+
def import_from_path(root_package: str, root_dir: Path, path: Path) -> object:
63+
path = path.relative_to(root_dir)
64+
parts = [root_package] + list(path.parts[:-1]) + [short_stem(path.name)]
65+
return importlib.import_module(".".join(parts))
66+
67+
68+
def so_path_to_abi_path(so_path: Path, build_dir: Path, abi_dir: Path) -> Path:
69+
abi_name = short_stem(so_path.name) + ABI_SUFFIX
70+
return abi_dir / so_path.parent.relative_to(build_dir) / abi_name
71+
72+
73+
def abi_path_to_so_path(abi_path: Path, build_dir: Path, abi_dir: Path) -> Path:
74+
so_name = short_stem(abi_path.name) + EXT_SUFFIX
75+
return build_dir / abi_path.parent.relative_to(abi_dir) / so_name
76+
77+
78+
def is_cython_module(module: object) -> bool:
79+
# This is kind of quick-and-dirty, but seems to work
80+
return hasattr(module, "__pyx_capi__")
81+
82+
83+
def module_to_json(module: object) -> dict:
84+
"""
85+
Converts extracts information about a Cython-compiled .so into JSON-serializable information.
86+
"""
87+
# Sort the dictionary by keys to make diffs in the JSON files smaller
88+
pyx_capi = module.__pyx_capi__
89+
90+
return {
91+
"functions": {k: get_capsule_name(pyx_capi[k]) for k in sorted(pyx_capi.keys())},
92+
}
93+
94+
95+
def check_functions(expected: dict[str, str], found: dict[str, str]) -> tuple[bool, bool]:
96+
has_errors = False
97+
has_allowed_changes = False
98+
for k, v in expected.items():
99+
if k not in found:
100+
print(f" Missing symbol: {k}")
101+
has_errors = True
102+
elif found[k] != v:
103+
print(f" Changed symbol: {k}: expected {v}, got {found[k]}")
104+
has_errors = True
105+
for k, v in found.items():
106+
if k not in expected:
107+
print(f" Added symbol: {k}")
108+
has_allowed_changes = True
109+
return has_errors, has_allowed_changes
110+
111+
112+
def compare(expected: dict, found: dict) -> tuple[bool, bool]:
113+
has_errors = False
114+
has_allowed_changes = False
115+
116+
errors, allowed_changes = check_functions(expected["functions"], found["functions"])
117+
has_errors |= errors
118+
has_allowed_changes |= allowed_changes
119+
120+
return has_errors, has_allowed_changes
121+
122+
123+
def check(package: str, abi_dir: Path) -> bool:
124+
build_dir = get_package_path(package)
125+
126+
has_errors = False
127+
has_allowed_changes = False
128+
for abi_path in Path(abi_dir).glob(f"**/*{ABI_SUFFIX}"):
129+
so_path = abi_path_to_so_path(abi_path, build_dir, abi_dir)
130+
if so_path.is_file():
131+
try:
132+
module = import_from_path(package, build_dir, so_path)
133+
except ImportError:
134+
print(f"Failed to import module for {so_path.relative_to(build_dir)}")
135+
has_errors = True
136+
continue
137+
if is_cython_module(module):
138+
found_json = module_to_json(module)
139+
with open(abi_path, encoding="utf-8") as f:
140+
expected_json = json.load(f)
141+
print(f"Checking module: {so_path.relative_to(build_dir)}")
142+
check_errors, check_allowed_changes = compare(expected_json, found_json)
143+
has_errors |= check_errors
144+
has_allowed_changes |= check_allowed_changes
145+
else:
146+
print(f"Module no longer has an exposed ABI or is no longer Cython: {so_path.relative_to(build_dir)}")
147+
has_errors = True
148+
else:
149+
print(f"No module found for {abi_path.relative_to(abi_dir)}")
150+
has_errors = True
151+
152+
for so_path in Path(build_dir).glob(f"**/*{EXT_SUFFIX}"):
153+
module = import_from_path(package, build_dir, so_path)
154+
if hasattr(module, "__pyx_capi__"):
155+
abi_path = so_path_to_abi_path(so_path, build_dir, abi_dir)
156+
if not abi_path.is_file():
157+
print(f"New module added {so_path.relative_to(build_dir)}")
158+
has_allowed_changes = True
159+
160+
print()
161+
if has_errors:
162+
print("ERRORS FOUND")
163+
return True
164+
elif has_allowed_changes:
165+
print("Allowed changes found.")
166+
else:
167+
print("No changes found.")
168+
return False
169+
170+
171+
def regenerate(package: str, abi_dir: Path) -> bool:
172+
if abi_dir.is_dir():
173+
print(f"ABI directory {abi_dir} already exists. Please remove it before regenerating.")
174+
return True
175+
176+
build_dir = get_package_path(package)
177+
for so_path in Path(build_dir).glob(f"**/*{EXT_SUFFIX}"):
178+
try:
179+
module = import_from_path(package, build_dir, so_path)
180+
except ImportError:
181+
print(f"Failed to import module: {so_path.relative_to(build_dir)}")
182+
continue
183+
if is_cython_module(module):
184+
print(f"Generating ABI from {so_path.relative_to(build_dir)}")
185+
abi_path = so_path_to_abi_path(so_path, build_dir, abi_dir)
186+
abi_path.parent.mkdir(parents=True, exist_ok=True)
187+
with open(abi_path, "w", encoding="utf-8") as f:
188+
json.dump(module_to_json(module), f, indent=2)
189+
190+
return False
191+
192+
193+
if __name__ == "__main__":
194+
import argparse
195+
196+
parser = argparse.ArgumentParser(
197+
prog="check_cython_abi", description="Checks for changes in the Cython ABI of a given package"
198+
)
199+
200+
subparsers = parser.add_subparsers()
201+
202+
regen_parser = subparsers.add_parser("generate", help="Regenerate the ABI files")
203+
regen_parser.set_defaults(func=regenerate)
204+
regen_parser.add_argument("package", help="Python package to collect data from")
205+
regen_parser.add_argument("dir", help="Output directory to save data to")
206+
207+
check_parser = subparsers.add_parser("check", help="Check the API against existing ABI files")
208+
check_parser.set_defaults(func=check)
209+
check_parser.add_argument("package", help="Python package to collect data from")
210+
check_parser.add_argument("dir", help="Input directory to read data from")
211+
212+
args = parser.parse_args()
213+
if hasattr(args, "func"):
214+
if args.func(args.package, Path(args.dir)):
215+
sys.exit(1)
216+
else:
217+
parser.print_help()
218+
sys.exit(1)

0 commit comments

Comments
 (0)