3030import ast
3131import json
3232import os
33+ import re
3334import sys
3435import tomllib
3536import urllib .request
@@ -147,6 +148,38 @@ def ensure_griffe() -> None:
147148 raise SystemExit (1 )
148149
149150
151+ def _is_field_metadata_only_change (old_val : object , new_val : object ) -> bool :
152+ """Check if the change is only in Field metadata (description, title, etc.).
153+
154+ Field metadata parameters like ``description``, ``title``, and ``examples``
155+ don't affect runtime behavior - they're documentation-only. Changes to these
156+ should not be considered breaking API changes.
157+
158+ Returns:
159+ True if both values are Field() calls and only metadata parameters differ.
160+ """
161+ old_str = str (old_val )
162+ new_str = str (new_val )
163+
164+ if not (old_str .startswith ("Field(" ) and new_str .startswith ("Field(" )):
165+ return False
166+
167+ # Metadata parameters that don't affect runtime behavior
168+ # See https://docs.pydantic.dev/latest/api/fields/#pydantic.fields.Field
169+ metadata_params = ["description" , "title" , "examples" , "json_schema_extra" ]
170+
171+ old_normalized = old_str
172+ new_normalized = new_str
173+
174+ for param in metadata_params :
175+ # Pattern to match param='...' or param="..." with simple string values
176+ pattern = rf'{ param } \s*=\s*([\'"])([^\'"]*?)\1'
177+ old_normalized = re .sub (pattern , f"{ param } =PLACEHOLDER" , old_normalized )
178+ new_normalized = re .sub (pattern , f"{ param } =PLACEHOLDER" , new_normalized )
179+
180+ return old_normalized == new_normalized
181+
182+
150183def _collect_breakages_pairs (
151184 objs : Iterable [tuple [object , object ]],
152185 * ,
@@ -160,40 +193,81 @@ def _collect_breakages_pairs(
160193 Returns:
161194 (breakages, undeprecated_removals)
162195 """
196+
163197 import griffe
164- from griffe import BreakageKind , ExplanationStyle , Kind
198+ from griffe import Alias , AliasResolutionError , BreakageKind , ExplanationStyle , Kind
165199
166- breakages = []
200+ breakages : list [ object ] = []
167201 undeprecated_removals = 0
168202
169203 for old , new in objs :
170- for br in griffe .find_breaking_changes (old , new ):
171- obj = getattr (br , "obj" , None )
172- if not getattr (obj , "is_public" , True ):
173- continue
174-
175- print (br .explain (style = ExplanationStyle .GITHUB ))
176- breakages .append (br )
177-
178- if br .kind != BreakageKind .OBJECT_REMOVED :
179- continue
180-
181- parent = getattr (obj , "parent" , None )
182- if getattr (parent , "kind" , None ) != Kind .CLASS :
183- continue
184-
185- feature = f"{ parent .name } .{ obj .name } "
186- if (
187- feature not in deprecated .qualified
188- and parent .name not in deprecated .top_level
189- ):
204+ try :
205+ for br in griffe .find_breaking_changes (old , new ):
206+ obj = getattr (br , "obj" , None )
207+ if not getattr (obj , "is_public" , True ):
208+ continue
209+
210+ # Skip ATTRIBUTE_CHANGED_VALUE when it's just Field metadata changes
211+ # (description, title, examples, etc.) - these don't affect runtime
212+ if br .kind == BreakageKind .ATTRIBUTE_CHANGED_VALUE :
213+ old_value = getattr (br , "old_value" , None )
214+ new_value = getattr (br , "new_value" , None )
215+ if _is_field_metadata_only_change (old_value , new_value ):
216+ print (
217+ f"::notice title={ title } ::Ignoring Field metadata-only "
218+ f"change (non-breaking): { obj .name if obj else 'unknown' } "
219+ )
220+ continue
221+
222+ print (br .explain (style = ExplanationStyle .GITHUB ))
223+ breakages .append (br )
224+
225+ if br .kind != BreakageKind .OBJECT_REMOVED :
226+ continue
227+
228+ parent = getattr (obj , "parent" , None )
229+ if getattr (parent , "kind" , None ) != Kind .CLASS :
230+ continue
231+
232+ feature = f"{ parent .name } .{ obj .name } "
233+ if (
234+ feature not in deprecated .qualified
235+ and parent .name not in deprecated .top_level
236+ ):
237+ print (
238+ f"::error title={ title } ::Removed '{ feature } ' without prior "
239+ "deprecation. Mark it with @deprecated(...) or "
240+ f"warn_deprecated('{ feature } ', ...) for at least one release "
241+ "before removing."
242+ )
243+ undeprecated_removals += 1
244+ except AliasResolutionError as e :
245+ if isinstance (old , Alias ) or isinstance (new , Alias ):
246+ old_target = old .target_path if isinstance (old , Alias ) else None
247+ new_target = new .target_path if isinstance (new , Alias ) else None
248+ if old_target != new_target :
249+ name = getattr (old , "name" , None ) or getattr (
250+ new , "name" , "<unknown>"
251+ )
252+ print (
253+ f"::warning title={ title } ::Alias target changed for '{ name } ': "
254+ f"{ old_target !r} -> { new_target !r} "
255+ )
256+ breakages .append (
257+ {
258+ "kind" : "ALIAS_TARGET_CHANGED" ,
259+ "name" : name ,
260+ "old" : old_target ,
261+ "new" : new_target ,
262+ }
263+ )
264+ else :
190265 print (
191- f"::error title={ title } ::Removed '{ feature } ' without prior "
192- "deprecation. Mark it with @deprecated(...) or "
193- f"warn_deprecated('{ feature } ', ...) for at least one release "
194- "before removing."
266+ f"::notice title={ title } ::Skipping symbol comparison due to "
267+ f"unresolved alias: { e } "
195268 )
196- undeprecated_removals += 1
269+ except Exception as e :
270+ print (f"::warning title={ title } ::Failed to compute breakages: { e } " )
197271
198272 return breakages , undeprecated_removals
199273
@@ -247,7 +321,7 @@ def _check_version_bump(prev: str, new_version: str, total_breaks: int) -> int:
247321 0 if policy satisfied, 1 if not
248322 """
249323 if total_breaks == 0 :
250- print ("No SDK breaking changes detected" )
324+ print ("No breaking changes detected" )
251325 return 0
252326
253327 parsed_prev = _parse_version (prev )
@@ -260,14 +334,14 @@ def _check_version_bump(prev: str, new_version: str, total_breaks: int) -> int:
260334
261335 if not ok :
262336 print (
263- f"::error title=SDK SemVer::Breaking changes detected ({ total_breaks } ); "
337+ f"::error title=SemVer::Breaking changes detected ({ total_breaks } ); "
264338 f"require at least minor version bump from "
265339 f"{ parsed_prev .major } .{ parsed_prev .minor } .x, but new is { new_version } "
266340 )
267341 return 1
268342
269343 print (
270- f"SDK breaking changes detected ({ total_breaks } ) and version bump policy "
344+ f"Breaking changes detected ({ total_breaks } ) and version bump policy "
271345 f"satisfied ({ prev } -> { new_version } )"
272346 )
273347 return 0
@@ -452,9 +526,7 @@ def _get_source_root(griffe_root: object) -> Path | None:
452526 return None
453527
454528
455- def _compute_breakages (
456- old_root , new_root , cfg : PackageConfig , include : list [str ]
457- ) -> tuple [int , int ]:
529+ def _compute_breakages (old_root , new_root , cfg : PackageConfig ) -> tuple [int , int ]:
458530 """Detect breaking changes between old and new package versions.
459531
460532 Returns:
@@ -468,79 +540,66 @@ def _compute_breakages(
468540 total_breaks = 0
469541 undeprecated_removals = 0
470542
471- deprecated = DeprecatedSymbols ()
543+ source_root = _get_source_root (old_root )
544+ deprecated = (
545+ _find_deprecated_symbols (source_root ) if source_root else DeprecatedSymbols ()
546+ )
472547
473548 try :
474549 old_mod = _resolve_griffe_object (old_root , pkg , root_package = pkg )
475550 new_mod = _resolve_griffe_object (new_root , pkg , root_package = pkg )
476- old_exports = _extract_exported_names (old_mod )
477- new_exports = _extract_exported_names (new_mod )
478-
479- removed = sorted (old_exports - new_exports )
551+ except Exception as e :
552+ raise RuntimeError (f"Failed to resolve root module '{ pkg } '" ) from e
480553
481- source_root = _get_source_root (old_root )
482- deprecated = (
483- _find_deprecated_symbols (source_root )
484- if source_root
485- else DeprecatedSymbols ()
554+ new_exports = _extract_exported_names (new_mod )
555+ try :
556+ old_exports = _extract_exported_names (old_mod )
557+ except ValueError as e :
558+ # The API breakage check relies on a curated public surface defined via
559+ # __all__. If the previous release didn't define (or couldn't statically
560+ # evaluate) __all__, we can't compute meaningful breakages.
561+ #
562+ # In this situation, skip rather than failing the entire workflow.
563+ print (
564+ f"::notice title={ title } ::Skipping breakage check; previous release "
565+ f"has no statically-evaluable { pkg } .__all__: { e } "
486566 )
567+ return 0 , 0
487568
488- # Check deprecation-before-removal policy (exports)
489- if removed :
490- for name in removed :
491- total_breaks += 1 # every removal is a structural break
492- if name not in deprecated .top_level :
493- print (
494- f"::error title={ title } ::Removed '{ name } ' from "
495- f"{ pkg } .__all__ without prior deprecation. "
496- "Mark it with @deprecated or warn_deprecated() "
497- "for at least one release before removing."
498- )
499- undeprecated_removals += 1
500- else :
501- print (
502- f"::notice title={ title } ::Removed previously-"
503- f"deprecated symbol '{ name } ' from "
504- f"{ pkg } .__all__"
505- )
569+ removed = sorted (old_exports - new_exports )
506570
507- common = sorted (old_exports & new_exports )
508- pairs : list [tuple [object , object ]] = []
509- for name in common :
510- try :
511- pairs .append ((old_mod [name ], new_mod [name ]))
512- except Exception as e :
513- print (f"::warning title={ title } ::Unable to resolve symbol { name } : { e } " )
514-
515- breakages , undeprecated_members = _collect_breakages_pairs (
516- pairs ,
517- deprecated = deprecated ,
518- title = title ,
519- )
520- total_breaks += len (breakages )
521- undeprecated_removals += undeprecated_members
522- except Exception as e :
523- print (f"::warning title={ title } ::Failed to process top-level exports: { e } " )
571+ # Check deprecation-before-removal policy (exports)
572+ for name in removed :
573+ total_breaks += 1 # every removal is a structural break
574+ if name not in deprecated .top_level :
575+ print (
576+ f"::error title={ title } ::Removed '{ name } ' from "
577+ f"{ pkg } .__all__ without prior deprecation. "
578+ "Mark it with @deprecated or warn_deprecated() "
579+ "for at least one release before removing."
580+ )
581+ undeprecated_removals += 1
582+ else :
583+ print (
584+ f"::notice title={ title } ::Removed previously-deprecated symbol "
585+ f"'{ name } ' from { pkg } .__all__"
586+ )
524587
525- extra_pairs : list [tuple [object , object ]] = []
526- for path in include :
527- if path == pkg :
528- continue
588+ common = sorted (old_exports & new_exports )
589+ pairs : list [tuple [object , object ]] = []
590+ for name in common :
529591 try :
530- old_obj = _resolve_griffe_object (old_root , path , root_package = pkg )
531- new_obj = _resolve_griffe_object (new_root , path , root_package = pkg )
532- extra_pairs .append ((old_obj , new_obj ))
592+ pairs .append ((old_mod [name ], new_mod [name ]))
533593 except Exception as e :
534- print (f"::warning title={ title } ::Path { path } not found : { e } " )
594+ print (f"::warning title={ title } ::Unable to resolve symbol { name } : { e } " )
535595
536- if extra_pairs :
537- breakages , undeprecated_members = _collect_breakages_pairs (
538- extra_pairs ,
539- deprecated = deprecated ,
540- title = title ,
541- )
542- total_breaks += len (breakages )
543- undeprecated_removals += undeprecated_members
596+ breakages , undeprecated_members = _collect_breakages_pairs (
597+ pairs ,
598+ deprecated = deprecated ,
599+ title = title ,
600+ )
601+ total_breaks += len (breakages )
602+ undeprecated_removals += undeprecated_members
544603
545604 return total_breaks , undeprecated_removals
546605
@@ -550,10 +609,6 @@ def _check_package(griffe_module, repo_root: str, cfg: PackageConfig) -> int:
550609 pyproj = os .path .join (repo_root , cfg .source_dir , "pyproject.toml" )
551610 new_version = read_version_from_pyproject (pyproj )
552611
553- include_env = f"{ cfg .package .upper ().replace ('.' , '_' )} _INCLUDE_PATHS"
554- include = os .environ .get (include_env , cfg .package ).split ("," )
555- include = [p .strip () for p in include if p .strip ()]
556-
557612 title = f"{ cfg .distribution } API"
558613 prev = get_prev_pypi_version (cfg .distribution , new_version )
559614 if not prev :
@@ -573,7 +628,11 @@ def _check_package(griffe_module, repo_root: str, cfg: PackageConfig) -> int:
573628 if not old_root :
574629 return 1
575630
576- total_breaks , undeprecated = _compute_breakages (old_root , new_root , cfg , include )
631+ try :
632+ total_breaks , undeprecated = _compute_breakages (old_root , new_root , cfg )
633+ except Exception as e :
634+ print (f"::error title={ title } ::Failed to compute breakages: { e } " )
635+ return 1
577636
578637 if undeprecated :
579638 print (
0 commit comments