Skip to content

Commit 4993943

Browse files
mdboomclaude
andcommitted
toolshed: handle anonymous struct renames in check_cython_abi.py
The name of an anonymous struct is an implementation detail, so it is acceptable for the name to change between builds (e.g. from the old anon_structXX / anon_unionXX scheme to the newer MODULE__anon_podXX scheme that Cython now emits). This change makes the checker verify that anonymous structs have the same *structure*, while tolerating name changes, so spurious "Missing" / "Added" errors are no longer reported for pure renames. Three improvements: 1. `_format_base_type_name`: unwrap `CConstOrVolatileTypeNode` instead of falling through to its class name. Const/volatile qualifiers do not affect ABI layout, and the old behaviour stored the Python class name literally ("CConstOrVolatileTypeNode*") in generated .abi.json files, which caused field-type mismatches when comparing builds where the qualifier was expressed differently. 2. `_build_anon_rename_map`: new iterative bottom-up pass that builds a mapping from old-style anon names (expected) to new-style anon_pod names (found) by matching field content. Leaf structs (no anon-type fields) are matched first; each round normalises the remaining candidates using already-known renames, allowing parent structs that embed renamed children to match in subsequent rounds. 3. `_normalize_type`: uses `re.sub` with `\b` word-boundary anchors instead of plain `str.replace`, so a shorter name like "anon_struct1" cannot corrupt a longer one like "anon_struct12" if iteration order happens to process the shorter key first. `check_structs` is updated to look up each expected struct through the rename map, normalise expected field types before comparing, and skip the "Added" report for new-style names that are confirmed renames. Note: baselines generated with the old tool (containing "CConstOrVolatileTypeNode*" type strings) must be regenerated with the fixed tool before the rename-matching logic can work correctly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 786d91a commit 4993943

1 file changed

Lines changed: 79 additions & 6 deletions

File tree

toolshed/check_cython_abi.py

Lines changed: 79 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
import ctypes
3939
import importlib
4040
import json
41+
import re
4142
import sys
4243
import sysconfig
4344
from io import StringIO
@@ -114,6 +115,9 @@ def _format_base_type_name(bt: object) -> str:
114115
if cls == "CComplexBaseTypeNode":
115116
inner = _format_base_type_name(bt.base_type)
116117
return _unwrap_declarator(inner, bt.declarator)[0]
118+
if cls == "CConstOrVolatileTypeNode":
119+
# Discard const/volatile qualifiers; they don't affect ABI layout
120+
return _format_base_type_name(bt.base_type)
117121
return cls
118122

119123

@@ -263,6 +267,62 @@ def get_structs(module: object) -> dict:
263267
return dict(sorted(structs.items()))
264268

265269

270+
_OLD_ANON_RE = re.compile(r"^anon_(struct|union)\d+$")
271+
_NEW_ANON_RE = re.compile(r"^\w+__anon_pod\d+$")
272+
273+
274+
def _is_anon_name(name: str) -> bool:
275+
return bool(_OLD_ANON_RE.match(name)) or bool(_NEW_ANON_RE.match(name))
276+
277+
278+
def _normalize_type(type_str: str, rename_map: dict) -> str:
279+
"""Replace old anon_struct/union names in a type string using rename_map."""
280+
for old, new in rename_map.items():
281+
# Use word-boundary matching so e.g. "anon_struct1" doesn't corrupt "anon_struct12"
282+
type_str = re.sub(r"\b" + re.escape(old) + r"\b", new, type_str)
283+
return type_str
284+
285+
286+
def _build_anon_rename_map(expected: dict, found: dict) -> dict:
287+
"""Match old anon_struct/union names to new MODULE__anon_pod names by field content.
288+
289+
Works iteratively bottom-up: leaf anon structs (whose fields contain no anon
290+
type references) are matched first. Their old->new mapping is then used to
291+
normalize the field types of the remaining unmatched anon structs, which may
292+
allow the next round to match parent structs that embed them. Repeats until
293+
no new matches are found in a round.
294+
"""
295+
old_anons = {name: info for name, info in expected.items() if _is_anon_name(name)}
296+
new_pods = {name: info for name, info in found.items() if _is_anon_name(name)}
297+
298+
rename_map = {}
299+
matched_pods = set()
300+
unmatched_old = dict(old_anons)
301+
302+
while True:
303+
matched_this_round = {}
304+
305+
for old_name, old_info in unmatched_old.items():
306+
# Normalize this struct's field types using mappings found so far
307+
normalized_fields = [[_normalize_type(f[0], rename_map), f[1]] for f in old_info.get("fields", [])]
308+
for new_name, new_info in new_pods.items():
309+
if new_name in matched_pods:
310+
continue
311+
if normalized_fields == new_info.get("fields", []):
312+
matched_this_round[old_name] = new_name
313+
matched_pods.add(new_name)
314+
break
315+
316+
if not matched_this_round:
317+
break # No progress; remaining old anons have no content-matching pod
318+
319+
rename_map.update(matched_this_round)
320+
for name in matched_this_round:
321+
del unmatched_old[name]
322+
323+
return rename_map
324+
325+
266326
def _report_field_changes(name: str, expected_fields: list, found_fields: list) -> None:
267327
"""Print detailed field-level differences for a struct."""
268328
expected_dict = {f[1]: f[0] for f in expected_fields}
@@ -289,12 +349,22 @@ def check_structs(expected: dict, found: dict) -> tuple[bool, bool]:
289349
has_errors = False
290350
has_allowed_changes = False
291351

352+
rename_map = _build_anon_rename_map(expected, found)
353+
renamed_new = set(rename_map.values())
354+
292355
for name, expected_info in expected.items():
293-
if name not in found:
356+
effective_name = rename_map.get(name, name)
357+
358+
if effective_name not in found:
294359
print(f" Missing struct/class: {name}")
295360
has_errors = True
296361
continue
297-
found_info = found[name]
362+
363+
if effective_name != name:
364+
# Anon struct/union renamed to new-style anon_pod — allowed change
365+
has_allowed_changes = True
366+
367+
found_info = found[effective_name]
298368

299369
if "basicsize" in expected_info:
300370
if "basicsize" not in found_info:
@@ -310,12 +380,15 @@ def check_structs(expected: dict, found: dict) -> tuple[bool, bool]:
310380
if "fields" not in found_info:
311381
print(f" Struct {name}: field information no longer available")
312382
has_errors = True
313-
elif found_info["fields"] != expected_info["fields"]:
314-
_report_field_changes(name, expected_info["fields"], found_info["fields"])
315-
has_errors = True
383+
else:
384+
# Normalize old anon type names in expected field types before comparing
385+
normalized_fields = [[_normalize_type(f[0], rename_map), f[1]] for f in expected_info["fields"]]
386+
if found_info["fields"] != normalized_fields:
387+
_report_field_changes(name, normalized_fields, found_info["fields"])
388+
has_errors = True
316389

317390
for name in found:
318-
if name not in expected:
391+
if name not in expected and name not in renamed_new:
319392
print(f" Added struct/class: {name}")
320393
has_allowed_changes = True
321394

0 commit comments

Comments
 (0)