@@ -33,7 +33,6 @@ class member must have been marked deprecated in the *previous* release using
3333import ast
3434import json
3535import os
36- import re
3736import subprocess
3837import sys
3938import tomllib
@@ -376,71 +375,88 @@ def ensure_griffe() -> None:
376375 raise SystemExit (1 )
377376
378377
379- def _strip_balanced_param (text : str , param : str ) -> str :
380- """Remove a keyword parameter whose value may contain nested delimiters.
378+ FIELD_METADATA_KWARGS = frozenset (
379+ {
380+ "deprecated" ,
381+ "description" ,
382+ "examples" ,
383+ "json_schema_extra" ,
384+ "title" ,
385+ }
386+ )
381387
382- Handles values like ``json_schema_extra={'key': {'nested': True}}`` where a
383- simple regex cannot reliably match the balanced braces/parens/brackets.
384388
385- Returns *text* with the ``param=<value>`` fragment (and any surrounding
386- comma) removed.
387- """
388- pattern = re .compile (rf",?\s*{ re .escape (param )} \s*=\s*" )
389- match = pattern .search (text )
390- if not match :
391- return text
392-
393- start = match .start ()
394- pos = match .end ()
395- if pos >= len (text ):
396- return text
397-
398- # Track balanced delimiters to find where the value ends.
399- openers = {"(" : ")" , "[" : "]" , "{" : "}" }
400- closers = {")" , "]" , "}" }
401- stack : list [str ] = []
389+ def _escape_newlines_in_string_literals (text : str ) -> str :
390+ """Escape literal newlines that appear inside quoted string literals."""
391+ chars : list [str ] = []
402392 in_string : str | None = None
393+ escaped = False
394+
395+ for ch in text :
396+ if in_string is None :
397+ chars .append (ch )
398+ if ch in {"'" , '"' }:
399+ in_string = ch
400+ continue
401+
402+ if escaped :
403+ chars .append (ch )
404+ escaped = False
405+ continue
403406
404- while pos < len (text ):
405- ch = text [pos ]
406-
407- # Handle string literals (skip their contents).
408- if in_string :
409- if ch == "\\ " and pos + 1 < len (text ):
410- pos += 2
411- continue
412- if ch == in_string :
413- in_string = None
414- pos += 1
407+ if ch == "\\ " :
408+ chars .append (ch )
409+ escaped = True
415410 continue
416411
417- if ch in ( "'" , '"' ) :
418- in_string = ch
419- pos += 1
412+ if ch == in_string :
413+ chars . append ( ch )
414+ in_string = None
420415 continue
421416
422- if ch in openers :
423- stack .append (openers [ch ])
424- pos += 1
417+ if ch == "\n " :
418+ chars .append ("\\ n" )
425419 continue
426420
427- if ch in closers :
428- if stack :
429- stack .pop ()
430- pos += 1
431- if not stack :
432- break
433- continue
434- # Unmatched closer — end of value.
435- break
421+ chars .append (ch )
422+
423+ return "" .join (chars )
424+
425+
426+ def _parse_field_call (value : object ) -> ast .Call | None :
427+ """Parse a stringified Pydantic ``Field(...)`` value into an AST call."""
428+ try :
429+ expr = ast .parse (
430+ _escape_newlines_in_string_literals (str (value )),
431+ mode = "eval" ,
432+ ).body
433+ except SyntaxError :
434+ return None
435+
436+ if not isinstance (expr , ast .Call ):
437+ return None
438+
439+ func = expr .func
440+ if isinstance (func , ast .Name ):
441+ func_name = func .id
442+ elif isinstance (func , ast .Attribute ):
443+ func_name = func .attr
444+ else :
445+ return None
436446
437- # At depth 0, a comma or closing paren ends the value.
438- if not stack and ch in ("," , ")" ):
439- break
447+ if func_name != "Field" :
448+ return None
440449
441- pos += 1
450+ return expr
442451
443- return text [:start ] + text [pos :]
452+
453+ def _filter_field_metadata_kwargs (call : ast .Call ) -> ast .Call :
454+ """Return a copy of a ``Field(...)`` call without metadata-only kwargs."""
455+ return ast .Call (
456+ func = call .func ,
457+ args = call .args ,
458+ keywords = [kw for kw in call .keywords if kw .arg not in FIELD_METADATA_KWARGS ],
459+ )
444460
445461
446462def _is_field_metadata_only_change (old_val : object , new_val : object ) -> bool :
@@ -453,43 +469,18 @@ def _is_field_metadata_only_change(old_val: object, new_val: object) -> bool:
453469 Returns:
454470 True if both values are Field() calls and only metadata parameters differ.
455471 """
456- old_str = str (old_val )
457- new_str = str (new_val )
458-
459- if not (old_str .startswith ("Field(" ) and new_str .startswith ("Field(" )):
472+ old_call = _parse_field_call (old_val )
473+ new_call = _parse_field_call (new_val )
474+ if old_call is None or new_call is None :
460475 return False
461476
462- # Simple metadata parameters whose values are always plain quoted strings
463- # or simple literals.
464- # See https://docs.pydantic.dev/latest/api/fields/#pydantic.fields.Field
465- simple_metadata_patterns = {
466- "description" : r'([\'"])([^\'"]*?)\1' ,
467- "title" : r'([\'"])([^\'"]*?)\1' ,
468- "examples" : r'([\'"])([^\'"]*?)\1' ,
469- "deprecated" : r"(?:True|False|None|'[^']*'|\"[^\"]*\")" ,
470- }
471-
472- # Parameters whose values can be complex nested structures (dicts, function
473- # calls, etc.) and need balanced-delimiter parsing instead of a regex.
474- balanced_params = ("json_schema_extra" ,)
475-
476- def _normalize (value : str ) -> str :
477- normalized = value
478-
479- for param , value_pattern in simple_metadata_patterns .items ():
480- pattern = rf",?\s*{ param } \s*=\s*{ value_pattern } "
481- normalized = re .sub (pattern , "" , normalized )
482-
483- for param in balanced_params :
484- normalized = _strip_balanced_param (normalized , param )
485-
486- normalized = re .sub (r"\(\s*," , "(" , normalized )
487- normalized = re .sub (r",\s*\)" , ")" , normalized )
488- normalized = re .sub (r",\s*," , ", " , normalized )
489- normalized = re .sub (r"\s+" , " " , normalized )
490- return normalized .strip ()
491-
492- return _normalize (old_str ) == _normalize (new_str )
477+ return ast .dump (
478+ _filter_field_metadata_kwargs (old_call ),
479+ include_attributes = False ,
480+ ) == ast .dump (
481+ _filter_field_metadata_kwargs (new_call ),
482+ include_attributes = False ,
483+ )
493484
494485
495486def _member_deprecation_metadata (
0 commit comments