Skip to content

Commit 720f890

Browse files
committed
Address some of the comments in the PR
1 parent cefb695 commit 720f890

File tree

1 file changed

+65
-25
lines changed

1 file changed

+65
-25
lines changed

toolshed/check_cython_abi.py

Lines changed: 65 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@
66
"""
77
Tool to check for Cython ABI changes in a given package.
88
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+
916
The workflow is basically:
1017
1118
1) Build and install a "clean" upstream version of the package.
@@ -23,9 +30,9 @@
2330
python check_cython_abi.py check <package_name> <dir>
2431
"""
2532

33+
import ctypes
2634
import importlib
2735
import json
28-
import re
2936
import sys
3037
import sysconfig
3138
from pathlib import Path
@@ -34,6 +41,15 @@
3441
ABI_SUFFIX = ".abi.json"
3542

3643

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+
3753
def short_stem(name: str) -> str:
3854
return name.split(".", 1)[0]
3955

@@ -59,24 +75,24 @@ def abi_path_to_so_path(abi_path: Path, build_dir: Path, abi_dir: Path) -> Path:
5975
return build_dir / abi_path.parent.relative_to(abi_dir) / so_name
6076

6177

62-
def pyx_capi_to_json(d: dict[str, object]) -> dict[str, str]:
63-
"""
64-
Converts the __pyx_capi__ dictionary to a JSON-serializable dictionary,
65-
removing any memory addresses that are irrelevant for comparison.
66-
"""
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__")
6781

68-
def extract_name(v: object) -> str:
69-
v = str(v)
70-
match = re.match(r'<capsule object "([^\"]+)" at 0x[0-9a-fA-F]+>', v)
71-
if match is None:
72-
raise ValueError(f"Could not parse __pyx_capi__ entry: {v}")
73-
return match.group(1)
7482

83+
def module_to_json(module: object) -> dict:
84+
"""
85+
Converts extracts information about a Cython-compiled .so into JSON-serializable information.
86+
"""
7587
# Sort the dictionary by keys to make diffs in the JSON files smaller
76-
return {k: extract_name(d[k]) for k in sorted(d.keys())}
88+
pyx_capi = module.__pyx_capi__
7789

90+
return {
91+
"functions": {k: get_capsule_name(pyx_capi[k]) for k in sorted(pyx_capi.keys())},
92+
}
7893

79-
def check_abi(expected: dict[str, str], found: dict[str, str]) -> tuple[bool, bool]:
94+
95+
def check_functions(expected: dict[str, str], found: dict[str, str]) -> tuple[bool, bool]:
8096
has_errors = False
8197
has_allowed_changes = False
8298
for k, v in expected.items():
@@ -93,6 +109,17 @@ def check_abi(expected: dict[str, str], found: dict[str, str]) -> tuple[bool, bo
93109
return has_errors, has_allowed_changes
94110

95111

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+
96123
def check(package: str, abi_dir: Path) -> bool:
97124
build_dir = get_package_path(package)
98125

@@ -101,17 +128,22 @@ def check(package: str, abi_dir: Path) -> bool:
101128
for abi_path in Path(abi_dir).glob(f"**/*{ABI_SUFFIX}"):
102129
so_path = abi_path_to_so_path(abi_path, build_dir, abi_dir)
103130
if so_path.is_file():
104-
module = import_from_path(package, build_dir, so_path)
105-
if hasattr(module, "__pyx_capi__"):
106-
found_json = pyx_capi_to_json(module.__pyx_capi__)
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)
107139
with open(abi_path, encoding="utf-8") as f:
108140
expected_json = json.load(f)
109141
print(f"Checking module: {so_path.relative_to(build_dir)}")
110-
check_errors, check_allowed_changes = check_abi(expected_json, found_json)
142+
check_errors, check_allowed_changes = compare(expected_json, found_json)
111143
has_errors |= check_errors
112144
has_allowed_changes |= check_allowed_changes
113145
else:
114-
print(f"Module no longer has an exposed ABI: {so_path.relative_to(build_dir)}")
146+
print(f"Module no longer has an exposed ABI or is no longer Cython: {so_path.relative_to(build_dir)}")
115147
has_errors = True
116148
else:
117149
print(f"No module found for {abi_path.relative_to(abi_dir)}")
@@ -125,27 +157,35 @@ def check(package: str, abi_dir: Path) -> bool:
125157
print(f"New module added {so_path.relative_to(build_dir)}")
126158
has_allowed_changes = True
127159

160+
print()
128161
if has_errors:
129162
print("ERRORS FOUND")
130163
return True
131164
elif has_allowed_changes:
132165
print("Allowed changes found.")
166+
else:
167+
print("No changes found.")
133168
return False
134169

135170

136171
def regenerate(package: str, abi_dir: Path) -> bool:
137-
if not abi_dir.is_dir():
138-
abi_dir.mkdir(parents=True, exist_ok=True)
172+
if abi_dir.is_dir():
173+
print(f"ABI directory {abi_dir} already exists. Please remove it before regenerating.")
174+
return True
139175

140176
build_dir = get_package_path(package)
141177
for so_path in Path(build_dir).glob(f"**/*{EXT_SUFFIX}"):
142-
print(f"Generating ABI from {so_path.relative_to(build_dir)}")
143-
module = import_from_path(package, build_dir, so_path)
144-
if hasattr(module, "__pyx_capi__"):
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)}")
145185
abi_path = so_path_to_abi_path(so_path, build_dir, abi_dir)
146186
abi_path.parent.mkdir(parents=True, exist_ok=True)
147187
with open(abi_path, "w", encoding="utf-8") as f:
148-
json.dump(pyx_capi_to_json(module.__pyx_capi__), f, indent=2)
188+
json.dump(module_to_json(module), f, indent=2)
149189

150190
return False
151191

0 commit comments

Comments
 (0)