1212import decimal
1313import enum
1414import fractions
15+ import inspect
1516import json
1617import pathlib
1718import sys
@@ -149,7 +150,17 @@ def _alternatives(annotation: object) -> tuple[object, ...]:
149150
150151_DECLARED : dict [tuple [int , object ], tuple [_Declared , Any ]] = {}
151152"""What a declaration says a container built from it holds, per declaration and kind of container: worked out once,
152- since a list of 1000 orders asks it for each order. At most 1024 entries."""
153+ since a list of 1000 orders asks it for each order. At most 1024 entries (`_remember`)."""
154+
155+
156+ def _remember (
157+ cache : dict [Any , tuple [_Declared , Any ]], key : object , declared : _Declared , answer : object , bound : int
158+ ) -> None :
159+ """Keep *answer* for *declared* under *key*, beside the declaration so the id stays its own. A full *cache* starts
160+ over rather than refusing new entries: a long run fills it early, and what it asks now is what it asks again."""
161+ if len (cache ) >= bound :
162+ cache .clear ()
163+ cache [key ] = (declared , answer )
153164
154165
155166def _declared_container (declared : _Declared | None ) -> tuple [tuple [_Declared , ...] | None , _Declared | None ] | None :
@@ -174,8 +185,7 @@ def _declared_container(declared: _Declared | None) -> tuple[tuple[_Declared, ..
174185 items = (tuple ((argument , owner ) for argument in arguments ), None )
175186 else :
176187 items = (None , (arguments [0 ], owner ))
177- if len (_DECLARED ) < 1024 :
178- _DECLARED [key ] = (declared , items )
188+ _remember (_DECLARED , key , declared , items , 1024 )
179189 return items
180190
181191
@@ -194,8 +204,7 @@ def _declared_mapping_value(declared: _Declared | None) -> _Declared | None:
194204 if get_origin (mapping ) in _MAPPING_ORIGINS and len (get_args (mapping )) == 2
195205 else None
196206 )
197- if len (_DECLARED ) < 1024 :
198- _DECLARED [key ] = (declared , value )
207+ _remember (_DECLARED , key , declared , value , 1024 )
199208 return value
200209
201210
@@ -207,9 +216,17 @@ def _sole_member(annotation: object) -> object:
207216
208217def _chooses (annotation : object ) -> bool :
209218 """Whether validation chose anywhere in *annotation* above the models it names: between two or more members of a
210- union besides ``None``, or inside a named type alias, which is not opened."""
219+ union besides ``None``, or inside a named type alias (`typing` or `typing_extensions`, plain or parametrized),
220+ which is not opened."""
211221 members = [member for member in _alternatives (annotation ) if member is not type (None )]
212- if len (members ) > 1 or any (hasattr (member , "__value__" ) for member in members ):
222+ aliases = tuple (
223+ kind
224+ for module in ("typing" , "typing_extensions" )
225+ if isinstance (kind := getattr (sys .modules .get (module ), "TypeAliasType" , None ), type )
226+ )
227+ if len (members ) > 1 or any (
228+ isinstance (named , aliases ) for member in members for named in (member , get_origin (member ))
229+ ):
213230 return True
214231 return any (_chooses (argument ) for member in members for argument in get_args (member ) if argument is not Ellipsis )
215232
@@ -233,7 +250,7 @@ def _item_declarations(declared: _Declared | None, count: int) -> list[_Declared
233250
234251_ADAPTERS : dict [int , tuple [_Declared , Any ]] = {}
235252"""A pydantic `TypeAdapter` per declaration, or ``None`` where none can be built: building one costs a schema. At
236- most 256 entries, each holding its declaration so the id stays its own ."""
253+ most 256 entries (`_remember`) ."""
237254
238255
239256def _adapter (declared : _Declared ) -> Any :
@@ -251,23 +268,23 @@ def _adapter(declared: _Declared) -> Any:
251268 adapter = adapter_class (annotation , config = getattr (owner , "model_config" , None ) or None )
252269 except (TypeError , RuntimeError , NameError ):
253270 adapter = None
254- if len (_ADAPTERS ) < 256 :
255- _ADAPTERS [id (declared )] = (declared , adapter )
271+ _remember (_ADAPTERS , id (declared ), declared , adapter , 256 )
256272 return adapter
257273
258274
259275_FieldReads = tuple [tuple [str , tuple [tuple [object , ...], ...], _Declared ], ...]
260276
261- _READS : dict [type , tuple [object , frozenset [str ], _FieldReads ]] = {}
262- """Per model class, with the validator they were read beside: the keys it declares and where each field is read.
277+ _READS : dict [type , tuple [object , frozenset [str ], _FieldReads , _Declared | None ]] = {}
278+ """Per model class, with the validator they were read beside: the keys it declares, where each field is read, and
279+ what a typed `__pydantic_extra__` declares its extra values as.
263280
264281Worked out per level of every payload, it was most of the walk: a list of 1000 nested models asked for it 1000 times.
265282A field whose type cannot hold a model is left out: a 300 by 300 grid of floats cost 35 ms to walk and holds nothing.
266283A rebuild replaces the validator, and with it what the class reads, so an entry answers only for the validator it
267284was made with. At most 256 classes are kept, so models made on the fly cannot grow it without bound."""
268285
269286
270- def _reads_of (model : Any ) -> tuple [frozenset [str ], _FieldReads ]:
287+ def _reads_of (model : Any ) -> tuple [frozenset [str ], _FieldReads , _Declared | None ]:
271288 validator = getattr (model , "__pydantic_validator__" , None )
272289 known = _READS .get (model )
273290 if known is None or known [0 ] is not validator :
@@ -277,10 +294,29 @@ def _reads_of(model: Any) -> tuple[frozenset[str], _FieldReads]:
277294 for name , info in model .model_fields .items ()
278295 if _may_hold_model (getattr (info , "annotation" , None ))
279296 )
280- known = (validator , frozenset (_declared_keys (model )), fields )
297+ extras = _extra_values (model ) if config .get ("extra" ) == "allow" else None
298+ known = (validator , frozenset (_declared_keys (model )), fields , extras )
281299 if model in _READS or len (_READS ) < 256 :
282300 _READS [model ] = known
283- return known [1 ], known [2 ]
301+ return known [1 ], known [2 ], known [3 ]
302+
303+
304+ def _extra_values (model : Any ) -> _Declared | None :
305+ """The declaration a typed `__pydantic_extra__` gives the extra values of *model*, as written.
306+
307+ ``None`` where it has none, and where it cannot be read: text, which pydantic read in the namespace of the frame
308+ that defined the model, where a name can mean another type than in its module, and, from 3.14, annotations that
309+ name what is defined only later, which reading them evaluates.
310+ """
311+ try :
312+ for klass in getattr (model , "__mro__" , ()):
313+ own = inspect .get_annotations (klass )
314+ if "__pydantic_extra__" in own :
315+ annotation = own ["__pydantic_extra__" ]
316+ return None if isinstance (annotation , str ) else _declared_mapping_value ((annotation , model ))
317+ except NameError :
318+ return None
319+ return None
284320
285321
286322def _followed (payload : object , path : tuple [object , ...]) -> tuple [bool , object ]:
@@ -318,8 +354,8 @@ def _is_model(value: object) -> bool:
318354_CONTAINERS = (dict , list , tuple , set , frozenset )
319355
320356_REPLAYED = (0 , - 1 )
321- """In the pairs a walk has seen, where no pair of ids can be: the walk is under a replay (`_replayed`), which reads no
322- field where validation chose, since the validators it skipped could have steered the choice ."""
357+ """In the pairs a walk has seen, where no pair of ids can be: the walk is under a replay (`_replayed`), where a field
358+ whose type offers a choice cannot be read (`_steered`) ."""
323359
324360
325361class UncheckableDriftError (Exception ):
@@ -346,16 +382,16 @@ def contract_drift(
346382 inside the JSON text it is) cannot be paired with a model built from it: a set, a resized list or an
347383 object wrapped into either whose built items are not all one model class, or whose raw item that class
348384 refuses on its own, dict keys coercion merged, a part of another shape than what was built, an iterable
349- validation read up or left lazy. The payload is read as validation left it: a validator renaming keys,
350- changing the payload in place, or reordering or rewriting a container's items without changing its size
351- is not seen, and the walk reads those items by position.
385+ validation read up or left lazy, raw items no built item is left for that their declared type does not
386+ build again or that it declares through a choice. The payload is read as validation left it: a validator
387+ renaming keys, changing the payload in place, or reordering or rewriting a container's items without
388+ changing its size is not seen, and the walk reads those items by position.
352389 """
353390 model = type (instance )
354- declared , fields = _reads_of (model )
355- if _REPLAYED in _seen :
356- fields = tuple (field for field in fields if not _chooses (field [2 ][0 ]))
391+ declared , fields , extras = _reads_of (model )
392+ replayed = _REPLAYED in _seen
357393 if getattr (model , "__pydantic_root_model__" , False ):
358- return _value_drift (payload , instance .root , path , _seen , fields [0 ][2 ]) if fields else []
394+ return _declared_drift (payload , instance .root , path , _seen , fields [0 ][2 ], replayed ) if fields else []
359395 if not isinstance (payload , (dict , collections .abc .Mapping )):
360396 _refuse_if_key_hides (
361397 payload , instance , path , f"the payload holds a { type (payload ).__name__ } where a model was built"
@@ -367,14 +403,14 @@ def contract_drift(
367403 if getattr (model , "model_config" , {}).get ("extra" ) != "allow" :
368404 drift += [f"{ prefix } { key } " for key in payload if key not in declared ]
369405 else :
370- # extras typed through `__pydantic_extra__` are built too; untyped ones are the payload's own objects
371- for key , built in (getattr (instance , "__pydantic_extra__" , None ) or {}).items ():
372- if key in payload :
373- drift += _value_drift (payload [key ], built , f"{ prefix } { key } " , seen )
406+ drift += _extras_drift (payload , instance , prefix , seen , extras , replayed )
374407 for name , sources , annotated in fields :
375408 found , raw = _raw_field (payload , sources )
376409 if not found :
377410 continue
411+ if replayed and _chooses (annotated [0 ]):
412+ drift += _steered (raw , f"{ prefix } { name } " )
413+ continue
378414 value = getattr (instance , name , None )
379415 # a scalar built from a scalar holds nothing to pair
380416 if (value is None or isinstance (value , (str , int , float , bytes ))) and not isinstance (raw , _CONTAINERS ):
@@ -383,6 +419,47 @@ def contract_drift(
383419 return drift
384420
385421
422+ def _extras_drift (
423+ payload : collections .abc .Mapping ,
424+ instance : Any ,
425+ prefix : str ,
426+ seen : frozenset [tuple [int , int ]],
427+ declared : _Declared | None ,
428+ replayed : bool ,
429+ ) -> list [str ]:
430+ """Drift under the extras of a model that allows them: built through a typed `__pydantic_extra__` and walked as it
431+ *declared* them; untyped ones are the payload's own objects."""
432+ return [
433+ entry
434+ for key , built in (getattr (instance , "__pydantic_extra__" , None ) or {}).items ()
435+ if key in payload
436+ for entry in _declared_drift (payload [key ], built , f"{ prefix } { key } " , seen , declared , replayed )
437+ ]
438+
439+
440+ def _declared_drift (
441+ raw : object ,
442+ value : object ,
443+ path : str ,
444+ seen : frozenset [tuple [int , int ]],
445+ declared : _Declared | None ,
446+ replayed : bool ,
447+ ) -> list [str ]:
448+ """Drift under a value its model declared, a root's or an extra's; `_steered` under a replay where validation built
449+ it through a declared type that offers a choice, or one that cannot be read."""
450+ if replayed and value is not raw and (declared is None or _chooses (declared [0 ])):
451+ return _steered (raw , path )
452+ return _value_drift (raw , value , path , seen , declared )
453+
454+
455+ def _steered (raw : object , path : str ) -> list [str ]:
456+ """Nothing to read under a part a replay built where its type offered a choice, which the validators the replay
457+ skipped could have steered; a refusal where a key could hide in it."""
458+ if _holds (raw , _is_mapping , read_text = True ):
459+ raise UncheckableDriftError (path , "which of its declared types it became depends on validators not run again" )
460+ return []
461+
462+
386463def exactness_failure (pairs : Any , * , carrier : str ) -> str | None :
387464 """What an exact check of ``(payload, instance, path)`` pairs found, in the words a failure continues with.
388465
@@ -555,8 +632,8 @@ def _unpaired(
555632 could hide a key is validated again on its own by that class and walked beside what it becomes. Where validation
556633 kept no model at all, the raw items are validated again whole by the type the field *declared*, in its model's
557634 config, and what that builds is walked (`_replayed`). Anything else refuses with *reason*: items of mixed classes,
558- an item that does not validate on its own, which needed its parent, and, where equal items *merged*, a class whose
559- own `__eq__` could have merged an item of another class into it.
635+ an item that does not validate on its own, which needed its parent, where equal items *merged*, a class whose own
636+ `__eq__` could have merged an item of another class into it, and raw items no replay can tell the type of .
560637 """
561638 if not _holds (raw , _is_mapping , read_text = True ):
562639 return []
@@ -565,41 +642,43 @@ def _unpaired(
565642 if model is None or (merged and not _equal_only_within_its_class (model )):
566643 raise UncheckableDriftError (path , reason )
567644 return _revalidated (parts , model , path , seen , reason )
568- return [] if declared is None else _replayed (parts , path , seen , reason , declared )
645+ return _replayed (parts , path , seen , reason , declared )
569646
570647
571648def _replayed (
572649 parts : list [tuple [str , object ]],
573650 path : str ,
574651 seen : frozenset [tuple [int , int ]],
575652 reason : str ,
576- declared : _Declared ,
653+ declared : _Declared | None ,
577654) -> list [str ]:
578655 """Drift under raw items validation left without a model: validated again whole as the *declared* type, in Python
579656 mode as validation read them (`_replay_target`). All of them are replayed first, so a fixed tuple's positions
580657 see what validation saw; where pydantic refuses that, only the items that could hide a key. What that builds is
581658 paired item by item; a set it builds tells the one class they became.
582659
583- The replay skips the field's own validators , so whatever they did to the payload is missing from it. Where that
584- could have steered a choice (`_chooses`), nothing is read below it; where pydantic builds nothing from the items,
585- or no sequence of as many, nothing is read at all, since its refusal says nothing about the payload .
660+ The replay skips the validators of the field itself , so whatever they did to the payload is missing from it, and
661+ it refuses with *reason* wherever that leaves the type of the items open: a declaration that offers a choice
662+ (`_chooses`) or none at all, items pydantic builds nothing from, or no sequence of as many .
586663 """
587- if _chooses (declared [0 ]):
588- return []
664+ if declared is None or _chooses (declared [0 ]):
665+ raise UncheckableDriftError ( path , reason )
589666 replayed = _replay_target (declared )
590667 adapter = _adapter (replayed )
591668 if adapter is None :
592- return []
593- rebuilt = _replay (adapter , parts )
669+ raise UncheckableDriftError (path , reason )
670+ origin = get_origin (replayed [0 ])
671+ kind = origin if origin in (tuple , collections .deque ) else list
672+ rebuilt = _replay (adapter , parts , kind )
594673 if rebuilt is None :
595674 parts = [(label , part ) for label , part in parts if _holds (part , _is_mapping , read_text = True )]
596- rebuilt = _replay (adapter , parts )
675+ rebuilt = _replay (adapter , parts , kind )
597676 seen = seen | {_REPLAYED }
598677 if isinstance (rebuilt , collections .abc .Set ):
599678 return _unpaired ([part for _ , part in parts ], parts , rebuilt , path , seen , reason , merged = True )
600679 elements = _items_of (rebuilt )
601680 if elements is None or len (elements ) != len (parts ):
602- return []
681+ raise UncheckableDriftError ( path , reason )
603682 declarations = _item_declarations (replayed , len (elements ))
604683 return [
605684 entry
@@ -608,10 +687,11 @@ def _replayed(
608687 ]
609688
610689
611- def _replay (adapter : Any , parts : list [tuple [str , object ]]) -> object :
612- """What *adapter* builds from the raw items of *parts*, or ``None`` where pydantic refuses them."""
690+ def _replay (adapter : Any , parts : list [tuple [str , object ]], kind : Callable [[list [object ]], object ]) -> object :
691+ """What *adapter* builds from the raw items of *parts*, handed over as the *kind* of container a strict config
692+ takes for its type, or ``None`` where pydantic refuses them."""
613693 try :
614- return adapter .validate_python ([part for _ , part in parts ])
694+ return adapter .validate_python (kind ( [part for _ , part in parts ]) )
615695 # a validation error, or a schema pydantic builds only on first use and then refuses
616696 except (ValueError , TypeError , RuntimeError , NameError ):
617697 return None
@@ -635,8 +715,7 @@ def _replay_target(declared: _Declared) -> _Declared:
635715 ):
636716 member = types .GenericAlias (list , get_args (member ) or (Any ,))
637717 target = (member , owner )
638- if len (_DECLARED ) < 1024 :
639- _DECLARED [key ] = (declared , target )
718+ _remember (_DECLARED , key , declared , target , 1024 )
640719 return target
641720
642721
0 commit comments