Skip to content

Commit 89b1495

Browse files
chore(scripts): follow alias and attribute redirections in breaking-change detection
The script previously treated griffe.Alias as opaque (returning {} members). This produced false positives for back-compat re-exports — both: * `from .new_path import NewClass` (Alias) * `OldName = NewClass` (Attribute with ExprName value) * `OldName = module.NewClass` (Attribute with ExprAttribute value) Resolve each to the underlying class and report members from there. Real breaking changes (class definition removed, attribute deleted from the target class) are still flagged; false positives from intentional back-compat aliases are not.
1 parent b1b9858 commit 89b1495

1 file changed

Lines changed: 62 additions & 3 deletions

File tree

scripts/detect-breaking-changes.py

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,70 @@
1010
from rich.style import Style
1111

1212

13+
def _resolve_redirect(obj: griffe.Object | griffe.Alias) -> griffe.Object | griffe.Alias | None:
14+
"""Follow back-compat redirections to a real class definition.
15+
16+
We use two patterns to preserve import paths after a schema rename:
17+
* `from .new_path import NewClass` → `griffe.Alias`
18+
* `OldName = NewClass` (module-level assignment) → `griffe.Attribute`
19+
Both are runtime-equivalent re-exports and should expose the target
20+
class's attributes for breaking-change detection. Without resolving them,
21+
griffe reports every old attribute as removed.
22+
"""
23+
visited: set[int] = set()
24+
current: griffe.Object | griffe.Alias | None = obj
25+
while current is not None and id(current) not in visited:
26+
visited.add(id(current))
27+
if isinstance(current, griffe.Alias):
28+
try:
29+
final = current.final_target
30+
except Exception:
31+
return None
32+
if isinstance(final, griffe.Alias) or final is current:
33+
return None
34+
return final
35+
if isinstance(current, griffe.Attribute):
36+
value = current.value
37+
parent = current.parent
38+
if parent is None or value is None:
39+
return None
40+
if isinstance(value, griffe.ExprName):
41+
next_obj = parent.members.get(str(value))
42+
if next_obj is None:
43+
return None
44+
current = next_obj
45+
continue
46+
if isinstance(value, griffe.ExprAttribute):
47+
# Qualified path like `task_group_status.TaskGroupStatus`.
48+
# Walk segment by segment, resolving any module aliases as we go.
49+
next_obj: griffe.Object | griffe.Alias | None = parent
50+
for segment in value.values:
51+
if not isinstance(segment, griffe.ExprName):
52+
continue
53+
if isinstance(next_obj, griffe.Alias):
54+
try:
55+
next_obj = next_obj.final_target
56+
except Exception:
57+
return None
58+
if next_obj is None or not hasattr(next_obj, "members"):
59+
return None
60+
next_obj = next_obj.members.get(str(segment))
61+
if next_obj is None:
62+
return None
63+
current = next_obj
64+
continue
65+
return None
66+
return current
67+
return None
68+
69+
1370
def public_members(obj: griffe.Object | griffe.Alias) -> dict[str, griffe.Object | griffe.Alias]:
71+
target = _resolve_redirect(obj)
72+
if target is not None and target is not obj:
73+
obj = target
74+
1475
if isinstance(obj, griffe.Alias):
15-
# ignore imports for now, they're technically part of the public API
16-
# but we don't have good preventative measures in place to prevent
17-
# changing them
76+
# Truly opaque alias we couldn't resolve.
1877
return {}
1978

2079
return {name: value for name, value in obj.all_members.items() if not name.startswith("_")}

0 commit comments

Comments
 (0)