66"""
77Tool 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+
916The workflow is basically:
1017
11181) Build and install a "clean" upstream version of the package.
2330 python check_cython_abi.py check <package_name> <dir>
2431"""
2532
33+ import ctypes
2634import importlib
2735import json
28- import re
2936import sys
3037import sysconfig
3138from pathlib import Path
3441ABI_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+
3753def 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+
96123def 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
136171def 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