assertpy 2.35.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. assertpy/__init__.py +87 -0
  2. assertpy/_clustering.py +511 -0
  3. assertpy/_dangling.py +505 -0
  4. assertpy/_engine/__init__.py +1 -0
  5. assertpy/_engine/_builder_check_typing.py +1365 -0
  6. assertpy/_engine/_capable_typing.py +772 -0
  7. assertpy/_engine/_check_typing.py +753 -0
  8. assertpy/_engine/_compare.py +983 -0
  9. assertpy/_engine/_compat.py +38 -0
  10. assertpy/_engine/_contract.py +1926 -0
  11. assertpy/_engine/_diff.py +1058 -0
  12. assertpy/_engine/_equality.py +843 -0
  13. assertpy/_engine/_introspection.py +729 -0
  14. assertpy/_engine/_membership.py +522 -0
  15. assertpy/_engine/_mixin_base.py +258 -0
  16. assertpy/_engine/_negated_typing.py +2176 -0
  17. assertpy/_engine/_operations.py +75 -0
  18. assertpy/_engine/_ordering.py +884 -0
  19. assertpy/_engine/_pairing.py +70 -0
  20. assertpy/_engine/_path.py +158 -0
  21. assertpy/_engine/_poll_typing.py +6039 -0
  22. assertpy/_engine/_require.py +312 -0
  23. assertpy/_engine/_size.py +31 -0
  24. assertpy/_engine/_text.py +39 -0
  25. assertpy/_engine/_typing.py +1226 -0
  26. assertpy/_hints.py +1213 -0
  27. assertpy/_inline.py +484 -0
  28. assertpy/_matcher_impls.py +1876 -0
  29. assertpy/_passes.py +249 -0
  30. assertpy/_renamed.py +64 -0
  31. assertpy/_satisfies.py +835 -0
  32. assertpy/_snapshot_codec.py +290 -0
  33. assertpy/assertpy.py +2640 -0
  34. assertpy/async_assertions.py +864 -0
  35. assertpy/base.py +862 -0
  36. assertpy/behave_matchers.py +96 -0
  37. assertpy/bytes_mixin.py +158 -0
  38. assertpy/collection.py +528 -0
  39. assertpy/contains.py +1068 -0
  40. assertpy/dataframe.py +200 -0
  41. assertpy/date.py +327 -0
  42. assertpy/dict.py +249 -0
  43. assertpy/dynamic.py +222 -0
  44. assertpy/errors.py +782 -0
  45. assertpy/exception.py +838 -0
  46. assertpy/extracting.py +266 -0
  47. assertpy/file.py +324 -0
  48. assertpy/helpers.py +747 -0
  49. assertpy/http_mixin.py +218 -0
  50. assertpy/json_mixin.py +1265 -0
  51. assertpy/matchers.py +639 -0
  52. assertpy/numeric.py +696 -0
  53. assertpy/outcome.py +163 -0
  54. assertpy/py.typed +0 -0
  55. assertpy/pytest_plugin.py +1650 -0
  56. assertpy/snapshot.py +1031 -0
  57. assertpy/string.py +803 -0
  58. assertpy/warning.py +137 -0
  59. assertpy-2.35.0.dist-info/METADATA +324 -0
  60. assertpy-2.35.0.dist-info/RECORD +63 -0
  61. assertpy-2.35.0.dist-info/WHEEL +4 -0
  62. assertpy-2.35.0.dist-info/entry_points.txt +2 -0
  63. assertpy-2.35.0.dist-info/licenses/LICENSE +28 -0
@@ -0,0 +1,1926 @@
1
+ """Contract-drift detection for `assert_conforms(..., exact=True)`.
2
+
3
+ Reports fields a raw payload carries that its pydantic v2 model does **not** declare - the silent API
4
+ growth that `model_validate` drops by default. Duck-typed on ``model_fields`` (no pydantic import),
5
+ alias-aware, and walked beside the validated instance, so every nested model is checked where pydantic put one.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import collections.abc
11
+ import dataclasses
12
+ import datetime
13
+ import decimal
14
+ import enum
15
+ import fractions
16
+ import inspect
17
+ import json
18
+ import marshal
19
+ import operator
20
+ import pathlib
21
+ import sys
22
+ import types
23
+ import uuid
24
+ from functools import reduce
25
+ from typing import (
26
+ TYPE_CHECKING,
27
+ Annotated,
28
+ Any,
29
+ ForwardRef,
30
+ Literal,
31
+ NamedTuple,
32
+ TypeVar,
33
+ Union,
34
+ get_args,
35
+ get_origin,
36
+ )
37
+
38
+ from ..errors import DiffEntry, Step
39
+ from ._introspection import definition_of
40
+
41
+ if TYPE_CHECKING:
42
+ from collections.abc import Callable
43
+
44
+
45
+ def _alias_sources(alias: object) -> list[tuple[object, ...]]:
46
+ """The payload paths an alias reads, in the order pydantic tries them (str, ``AliasChoices``, ``AliasPath``)."""
47
+ if isinstance(alias, str):
48
+ return [(alias,)]
49
+ choices = getattr(alias, "choices", None)
50
+ if choices is not None:
51
+ return [source for choice in choices for source in _alias_sources(choice)]
52
+ path = getattr(alias, "path", None)
53
+ return [tuple(path)] if path else []
54
+
55
+
56
+ def _spellings(name: str, info: Any) -> list[object]:
57
+ """Every key that spells a field: its name, the first key of each alias validation may read it by, and the alias
58
+ it is dumped under. Which of them validation reads is the config's to say (`_field_sources`)."""
59
+ keys: list[object] = [name]
60
+ for alias in (getattr(info, "alias", None), getattr(info, "validation_alias", None)):
61
+ if alias is not None:
62
+ keys += [source[0] for source in _alias_sources(alias)]
63
+ shown = getattr(info, "serialization_alias", None)
64
+ return [*keys, shown] if isinstance(shown, str) else keys
65
+
66
+
67
+ def _field_sources(name: str, info: Any, config: Any) -> tuple[tuple[object, ...], ...]:
68
+ """Where pydantic reads field *name*, in the order it looks: its alias sources, then the name if allowed."""
69
+ validation_alias = getattr(info, "validation_alias", None)
70
+ alias = validation_alias if validation_alias is not None else getattr(info, "alias", None)
71
+ if alias is None:
72
+ return ((name,),)
73
+ sources = _alias_sources(alias) if config.get("validate_by_alias") is not False else []
74
+ by_name = config.get("validate_by_name")
75
+ if by_name if by_name is not None else config.get("populate_by_name"):
76
+ sources.append((name,))
77
+ return tuple(sources)
78
+
79
+
80
+ _KEYLESS = (
81
+ int,
82
+ float,
83
+ complex,
84
+ str,
85
+ bytes,
86
+ bytearray,
87
+ type(None),
88
+ decimal.Decimal,
89
+ fractions.Fraction,
90
+ datetime.date,
91
+ datetime.time,
92
+ datetime.timedelta,
93
+ uuid.UUID,
94
+ enum.Enum,
95
+ pathlib.PurePath,
96
+ )
97
+ _PLAIN_ORIGINS = {
98
+ list,
99
+ tuple,
100
+ set,
101
+ frozenset,
102
+ dict,
103
+ collections.abc.Sequence,
104
+ collections.abc.MutableSequence,
105
+ collections.abc.Set,
106
+ collections.abc.MutableSet,
107
+ collections.abc.Mapping,
108
+ collections.abc.MutableMapping,
109
+ collections.abc.Iterable,
110
+ collections.abc.Iterator,
111
+ collections.abc.Collection,
112
+ }
113
+
114
+
115
+ def _may_hold_model(annotation: object, outer: bool = True) -> bool:
116
+ """Whether a model can be built anywhere under a field's *annotation*; only a type made of plain parts says no.
117
+
118
+ ``Any`` says yes at the top of the field, where only a validator puts a model and the payload's own object kept
119
+ there costs one identity check, and no inside a container, where passing each kept item costs what skipping saves.
120
+ """
121
+ if annotation is Any or annotation is object:
122
+ return outer
123
+ if annotation in (list, tuple, set, frozenset, dict):
124
+ return False
125
+ origin = get_origin(annotation)
126
+ if origin is Literal:
127
+ return False
128
+ if origin is Annotated:
129
+ return _may_hold_model(get_args(annotation)[0], outer)
130
+ if origin is Union or origin is types.UnionType:
131
+ return any(_may_hold_model(arg, outer) for arg in get_args(annotation))
132
+ if origin in _PLAIN_ORIGINS:
133
+ return any(_may_hold_model(arg, outer=False) for arg in get_args(annotation) if arg is not Ellipsis)
134
+ return origin is not None or not isinstance(annotation, type) or not issubclass(annotation, _KEYLESS)
135
+
136
+
137
+ def _alternatives(annotation: object) -> tuple[object, ...]:
138
+ """The members of a union, `Annotated` and nested unions opened, or the annotation alone."""
139
+ while get_origin(annotation) is Annotated:
140
+ annotation = get_args(annotation)[0]
141
+ if get_origin(annotation) in (Union, types.UnionType):
142
+ return tuple(member for argument in get_args(annotation) for member in _alternatives(argument))
143
+ return (annotation,)
144
+
145
+
146
+ _ITEM_ORIGINS = frozenset(
147
+ {
148
+ list,
149
+ tuple,
150
+ set,
151
+ frozenset,
152
+ collections.deque,
153
+ collections.abc.Sequence,
154
+ collections.abc.MutableSequence,
155
+ collections.abc.Set,
156
+ collections.abc.MutableSet,
157
+ collections.abc.Collection,
158
+ collections.abc.Iterable,
159
+ }
160
+ )
161
+
162
+
163
+ _MAPPING_ORIGINS = frozenset({dict, collections.abc.Mapping, collections.abc.MutableMapping})
164
+
165
+ _Declared = tuple[object, object, Any]
166
+ """A declared type, the model class whose field declared it, whose config its validation ran in, and the schema
167
+ pydantic built for it in that class, where the walk came by one. The type is ``None`` where it cannot be read (text,
168
+ the place of a type variable), and the schema alone declares then."""
169
+
170
+
171
+ class _Record(NamedTuple):
172
+ """A `TypedDict` or a dataclass as the walk came to it: by the schema that validates it, by its class, or both."""
173
+
174
+ node: Any
175
+ kind: Any
176
+
177
+
178
+ class _Unsure(NamedTuple):
179
+ """The `TypedDict`s one of which may have built a dict, where its declaration names several, and the schemas
180
+ declared *beside* them that may build a dict as well: a ``dict``, or one that does not say what it builds."""
181
+
182
+ records: tuple[Any, ...]
183
+ beside: tuple[Any, ...]
184
+
185
+
186
+ _ITEM_SCHEMAS = frozenset({"list", "set", "frozenset", "generator", "tuple", "tuple-positional", "tuple-variable"})
187
+ _NO_DICT = _ITEM_SCHEMAS | {
188
+ "none",
189
+ "bool",
190
+ "int",
191
+ "float",
192
+ "decimal",
193
+ "complex",
194
+ "str",
195
+ "bytes",
196
+ "date",
197
+ "time",
198
+ "datetime",
199
+ "timedelta",
200
+ "literal",
201
+ "enum",
202
+ "url",
203
+ "multi-host-url",
204
+ "uuid",
205
+ "model",
206
+ "dataclass",
207
+ }
208
+ """The schemas that never build a plain dict; any other beside a `TypedDict` leaves open which of them built one."""
209
+
210
+ _ANY: dict[str, Any] = {"type": "any"}
211
+ """Stands for a schema that does not say what it builds."""
212
+
213
+ _Hop = tuple[Any, object, object] | None
214
+ """Where the walk stands in the payload, ``None`` at its root: the hop this one was taken from, what it is named by,
215
+ and how it was taken (the keys followed for a field or a key, ``None`` for a position, a `Step` for a member of a
216
+ collection that keeps no positions). A hop into a mapping's key, and one into what JSON text decodes to, is named
217
+ by nothing. Read out only where something is found (`_placed`)."""
218
+
219
+ _Found = tuple[_Hop, object] | tuple[_Hop, object, str]
220
+ """An undeclared field: where it stands and the value sent there, and, where the key spells a field of the model, why
221
+ validation did not read it."""
222
+
223
+ _DECLARED: dict[tuple[object, object], tuple[Any, Any]] = {}
224
+ """What a declaration says a container built from it holds, per declaration and kind of container: worked out once,
225
+ since a list of 1000 orders asks it for each order. At most 1024 entries (`_remember`)."""
226
+
227
+
228
+ def _remember(cache: dict[Any, tuple[Any, Any]], key: object, declared: object, answer: object, bound: int) -> None:
229
+ """Keep *answer* for *declared* under *key*, beside the declaration so the id stays its own. A full *cache* starts
230
+ over rather than refusing new entries: a long run fills it early, and what it asks now is what it asks again."""
231
+ if len(cache) >= bound:
232
+ cache.clear()
233
+ cache[key] = (declared, answer)
234
+
235
+
236
+ _REFS: dict[Any, tuple[object, dict[str, Any]]] = {}
237
+ """Per class, beside the validator it was read with: each schema its core schema defines under a name. At most 256
238
+ classes (`_remember`)."""
239
+
240
+
241
+ def _refs(holder: Any) -> dict[str, Any]:
242
+ """The schemas the core schema of *holder* names, by the name a reference to them carries."""
243
+ validator = getattr(holder, "__pydantic_validator__", None)
244
+ known = _REFS.get(holder)
245
+ if known is not None and known[0] is validator:
246
+ return known[1]
247
+ refs: dict[str, Any] = {}
248
+ pending: list[Any] = [getattr(holder, "__pydantic_core_schema__", None)]
249
+ while pending:
250
+ node = pending.pop()
251
+ if isinstance(node, dict):
252
+ if isinstance(node.get("ref"), str):
253
+ refs[node["ref"]] = node
254
+ pending.extend(value for key, value in node.items() if key not in ("serialization", "metadata", "default"))
255
+ elif isinstance(node, (list, tuple)):
256
+ pending.extend(node)
257
+ _remember(_REFS, holder, validator, refs, 256)
258
+ return refs
259
+
260
+
261
+ def _schema_kind(node: object) -> object:
262
+ return node.get("type") if isinstance(node, dict) else None
263
+
264
+
265
+ def _choices(node: object, owner: object) -> list[Any]:
266
+ """The schemas validation chooses between at *node*, in the order it lists them, with what only wraps a schema
267
+ opened: a default, ``None`` beside it, a reference to a definition, a union and the unions inside it, what JSON
268
+ text is decoded into, and a validator around a schema, which is read as keeping the kind of what it validates, as
269
+ one in `Annotated` is."""
270
+ refs = _refs(owner)
271
+ pending, leaves, met = [node], [], set()
272
+ while pending:
273
+ current: Any = pending.pop()
274
+ kind = _schema_kind(current)
275
+ if id(current) in met:
276
+ continue
277
+ met.add(id(current))
278
+ if kind in ("default", "nullable", "definitions", "json", "function-before", "function-after", "function-wrap"):
279
+ pending.append(current.get("schema"))
280
+ elif kind == "definition-ref":
281
+ pending.append(refs.get(current.get("schema_ref")))
282
+ elif kind == "union":
283
+ members = [choice[0] if isinstance(choice, tuple) else choice for choice in current.get("choices", ())]
284
+ pending.extend(reversed(members))
285
+ elif kind == "tagged-union":
286
+ pending.extend(
287
+ reversed([choice for choice in current.get("choices", {}).values() if isinstance(choice, dict)])
288
+ )
289
+ else:
290
+ leaves.append(current)
291
+ return leaves
292
+
293
+
294
+ def _either(nodes: list[Any]) -> Any:
295
+ """One schema standing for a choice between *nodes*, or the only one of them."""
296
+ return nodes[0] if len(nodes) == 1 else {"type": "union", "choices": nodes}
297
+
298
+
299
+ def _keyed_under(node: object, owner: object) -> list[Any]:
300
+ """The schemas anywhere under *node* that take named keys and drop the rest: a `TypedDict`, a model, a dataclass,
301
+ short of what each of them declares in turn."""
302
+ refs = _refs(owner)
303
+ pending, found, met = [node], [], set()
304
+ while pending:
305
+ current: Any = pending.pop()
306
+ kind = _schema_kind(current)
307
+ if id(current) in met:
308
+ continue
309
+ met.add(id(current))
310
+ if kind in ("typed-dict", "model", "dataclass"):
311
+ found.append(current)
312
+ elif kind == "definition-ref":
313
+ pending.append(refs.get(current.get("schema_ref")))
314
+ elif isinstance(current, dict):
315
+ pending.extend(
316
+ value for key, value in current.items() if key not in ("serialization", "metadata", "default")
317
+ )
318
+ elif isinstance(current, (list, tuple)):
319
+ pending.extend(current)
320
+ return found
321
+
322
+
323
+ def _records_under(node: object, owner: object) -> list[Any]:
324
+ """The `TypedDict` schemas anywhere under a schema, short of the models and dataclasses in it, which build no
325
+ dict: what a schema that does not say what it builds may build a dict by."""
326
+ return [found for found in _keyed_under(node, owner) if found.get("type") == "typed-dict"]
327
+
328
+
329
+ def _item_nodes(node: object, owner: object) -> tuple[tuple[Any, ...] | None, Any] | None:
330
+ """What the schema *node* says the items of a sequence or a set built from it are: a schema per position of a fixed
331
+ tuple, or one for every item, a choice between them where *node* offers several containers or a tuple with a
332
+ variable part; ``None`` where it declares no such container and hides no `TypedDict`.
333
+
334
+ A schema beside the containers that does not say what it builds is one of the choices as it is: a `TypedDict`
335
+ inside it is looked for at whatever depth the walk meets a dict (`_records_under`).
336
+ """
337
+ leaves = [] if node is None else _choices(node, owner)
338
+ holding = [leaf for leaf in leaves if _schema_kind(leaf) in _ITEM_SCHEMAS]
339
+ unknown = [leaf for leaf in leaves if _schema_kind(leaf) not in _NO_DICT | {"typed-dict", "dict"}]
340
+ if not holding and not any(_records_under(leaf, owner) for leaf in unknown):
341
+ return None
342
+ every: list[Any] = []
343
+ for leaf in holding:
344
+ items = leaf.get("items_schema", _ANY)
345
+ every += items if isinstance(items, list) else [items]
346
+ kind = _schema_kind(leaves[0])
347
+ fixed = kind == "tuple-positional" or (kind == "tuple" and leaves[0].get("variadic_item_index") is None)
348
+ if len(leaves) == 1 and fixed:
349
+ return tuple(every), None
350
+ return None, _either([*every, *unknown])
351
+
352
+
353
+ def _missing_is_its_own(cls: Any) -> bool:
354
+ """Whether the enum *cls* answers a value it does not name by a `_missing_` other than `Enum`'s.
355
+
356
+ Its constructor calls that method, and a schema names it only where pydantic calls it in the constructor's
357
+ place. A class that cannot be read says yes.
358
+ """
359
+ try:
360
+ found = definition_of(cls, "_missing_")
361
+ # no class: its tree is read off the slots of `type`, so nothing the class or its metaclass says is asked
362
+ except TypeError:
363
+ return True
364
+ return found is None or found[0] is not enum.Enum
365
+
366
+
367
+ _OWN_CODE: dict[type, tuple[object, bool]] = {}
368
+ """Per model class, beside the validator it was read with: whether validating by it runs code that is not pydantic's.
369
+ At most 256 classes (`_remember`)."""
370
+
371
+
372
+ def runs_own_code(model: Any) -> bool:
373
+ """Whether validation by *model* runs code of its own anywhere in its schema: a validator, a `model_post_init`
374
+ (private attributes bring one), a custom `__init__`, a dataclass `__post_init__`, a called class, a named tuple,
375
+ whose class is called as well, an enum's own `_missing_`.
376
+
377
+ Pydantic's own validation never changes the payload it is given, so only such a model can leave it other than
378
+ it was sent. A default factory that takes no data is not counted: validation hands it nothing of the payload.
379
+ A schema that cannot be read says yes.
380
+ """
381
+ validator = getattr(model, "__pydantic_validator__", None)
382
+ known = _OWN_CODE.get(model)
383
+ if known is not None and known[0] is validator:
384
+ return known[1]
385
+ schema = getattr(model, "__pydantic_core_schema__", None)
386
+ answer = True
387
+ if isinstance(schema, dict):
388
+ answer, pending = False, [schema]
389
+ while pending and not answer:
390
+ node = pending.pop()
391
+ if isinstance(node, dict):
392
+ kind = node.get("type")
393
+ answer = isinstance(kind, str) and bool(
394
+ kind.startswith("function-")
395
+ or kind in ("call", "named-tuple")
396
+ or node.get("post_init")
397
+ or node.get("custom_init")
398
+ or node.get("default_factory_takes_data")
399
+ or node.get("missing")
400
+ or (kind == "enum" and _missing_is_its_own(node.get("cls")))
401
+ or callable(node.get("discriminator"))
402
+ )
403
+ # a serialiser runs at dump time, metadata is never validated, a default may hold itself
404
+ pending.extend(
405
+ value for key, value in node.items() if key not in ("serialization", "metadata", "default")
406
+ )
407
+ elif isinstance(node, (list, tuple)):
408
+ pending.extend(node)
409
+ _remember(_OWN_CODE, model, validator, answer, 256)
410
+ return answer
411
+
412
+
413
+ _Sent = tuple[Any, list[tuple[Any, Any]] | None]
414
+ """A payload as it was before validation: an independent copy of it, or, where no such copy can be made, ``None``
415
+ and every plain `dict` and `list` inside it beside a copy of what it held."""
416
+
417
+
418
+ def sent_record(payload: Any) -> _Sent:
419
+ """*payload* as it is now, before validation, to put it back by (`put_as_sent`).
420
+
421
+ Plain data, which a decoded JSON document is, is copied whole by `marshal`: 1.2 us for an order and 125 us for a
422
+ thousand lines, where recording each container took 2.2 us and 330 us. A payload `marshal` refuses (a `datetime`,
423
+ a `Decimal`, a subclass, one nested too deep) is recorded container by container (`_held_now`).
424
+ """
425
+ try:
426
+ return marshal.loads(marshal.dumps(payload)), None
427
+ except ValueError:
428
+ return None, _held_now(payload)
429
+
430
+
431
+ def _held_now(payload: Any) -> list[tuple[Any, Any]]:
432
+ """Every plain `dict` and `list` inside *payload*, each beside a copy of what it holds now.
433
+
434
+ Reached through dicts, lists and tuples and their subclasses; only the plain ones are recorded, since putting
435
+ a subclass back would go through methods of its own.
436
+ """
437
+ record: list[tuple[Any, Any]] = []
438
+ pending: list[Any] = [payload]
439
+ met: set[int] = set()
440
+ while pending:
441
+ node = pending.pop()
442
+ plain = type(node) is dict or type(node) is list
443
+ if not (plain or isinstance(node, (dict, list, tuple))) or id(node) in met:
444
+ continue
445
+ met.add(id(node))
446
+ if plain:
447
+ record.append((node, dict(node) if type(node) is dict else list(node)))
448
+ # read as the builtin holds it: a subclass's own `values` or `__iter__` is not the check's to call
449
+ if isinstance(node, dict):
450
+ children: Any = dict.values(node)
451
+ else:
452
+ children = list.__iter__(node) if isinstance(node, list) else tuple.__iter__(node)
453
+ pending += [child for child in children if type(child) not in (str, int, float, bool, types.NoneType)]
454
+ return record
455
+
456
+
457
+ def put_as_sent(payload: Any, sent: _Sent) -> list[tuple[Any, Any]]:
458
+ """Put what validation changed inside *payload* back as it was *sent*, and return each container this changed
459
+ beside what validation left in it, for `put_back`.
460
+
461
+ So the walk reads the payload as it was sent: a validator that changed its input in place is read as one that
462
+ returned a changed copy. Against a whole copy, changed means no longer equal, and the payload's own dicts and
463
+ lists, reached through its tuples, are refilled from the copy. Container by container, changed means another
464
+ object under a key or at an index, or other keys, and each container is refilled with the objects it held, so
465
+ what validation retained is still met as the payload's own.
466
+ """
467
+ whole, parts = sent
468
+ left: list[tuple[Any, Any]] = []
469
+ if parts is None:
470
+ try:
471
+ same = payload == whole
472
+ # a payload that holds itself, or a value a validator put in whose own `==` raises: not what was sent
473
+ except Exception:
474
+ same = False
475
+ pending, met = ([] if same else [(payload, whole)]), set()
476
+ while pending:
477
+ node, was = pending.pop()
478
+ # a container the payload holds twice is refilled once, or what validation left in it is lost
479
+ if id(node) in met:
480
+ continue
481
+ met.add(id(node))
482
+ if type(node) is dict or type(node) is list:
483
+ left.append((node, node.copy()))
484
+ _refill(node, was)
485
+ elif type(node) is tuple:
486
+ pending += zip(node, was, strict=True)
487
+ return left
488
+ for node, was in parts:
489
+ if type(node) is dict:
490
+ same = node.keys() == was.keys() and all(map(operator.is_, node.values(), was.values()))
491
+ else:
492
+ same = len(node) == len(was) and all(map(operator.is_, node, was))
493
+ if not same:
494
+ left.append((node, node.copy()))
495
+ _refill(node, was)
496
+ return left
497
+
498
+
499
+ def kept_apart(entries: list[DiffEntry]) -> list[DiffEntry]:
500
+ """*entries* holding copies of the plain dicts, lists and tuples they hold, which `put_back` is about to refill.
501
+
502
+ One copy of each container for all of them, so what the entries share they still share, cycles are kept, and
503
+ anything else stays the payload's own object. Made without recursion: a payload nests deeper than the stack goes.
504
+ What lies in JSON text is the walk's own reading of it, which nothing refills, and stays the object the steps
505
+ after its ``json`` step lead to.
506
+ """
507
+ known: dict[int, Any] = {}
508
+ pending: list[tuple[Any, Any]] = []
509
+ read = [any(step.kind == "json" for step in entry.steps) for entry in entries]
510
+ copies = [
511
+ entry.actual if decoded else _copied(entry.actual, known, pending)
512
+ for entry, decoded in zip(entries, read, strict=True)
513
+ ]
514
+ while pending:
515
+ copy, source = pending.pop()
516
+ if type(source) is dict:
517
+ copy.update((key, _copied(part, known, pending)) for key, part in source.items())
518
+ else:
519
+ copy.extend(_copied(part, known, pending) for part in source)
520
+ return [dataclasses.replace(entry, actual=copy) for entry, copy in zip(entries, copies, strict=True)]
521
+
522
+
523
+ def _copied(value: Any, known: dict[int, Any], pending: list[tuple[Any, Any]]) -> Any:
524
+ """The copy *value* is known by: a dict or a list is made empty and left *pending* to be filled, a tuple is built
525
+ at once from the copies of its items (`_tuple_copied`), anything else is itself."""
526
+ kind = type(value)
527
+ if kind is not dict and kind is not list and kind is not tuple:
528
+ return value
529
+ copy = known.get(id(value))
530
+ if copy is None:
531
+ if kind is tuple:
532
+ return _tuple_copied(value, known, pending)
533
+ copy = known[id(value)] = kind()
534
+ pending.append((copy, value))
535
+ return copy
536
+
537
+
538
+ def _tuple_copied(value: tuple, known: dict[int, Any], pending: list[tuple[Any, Any]]) -> tuple:
539
+ """A tuple of the copies of what *value* holds, the tuples directly inside it built first; the tuple itself where
540
+ nothing it holds was copied, which is every tuple a dict has for a key."""
541
+ building: list[tuple[tuple, list[Any]]] = [(value, [])]
542
+ while True:
543
+ source, items = building[-1]
544
+ if len(items) < len(source):
545
+ item = source[len(items)]
546
+ if type(item) is tuple and id(item) not in known:
547
+ building.append((item, []))
548
+ else:
549
+ items.append(_copied(item, known, pending))
550
+ continue
551
+ unchanged = all(map(operator.is_, items, source))
552
+ built = known[id(source)] = source if unchanged else tuple(items)
553
+ building.pop()
554
+ if not building:
555
+ return built
556
+ building[-1][1].append(built)
557
+
558
+
559
+ def put_back(left: list[tuple[Any, Any]]) -> None:
560
+ """Return each container `put_as_sent` changed to what validation left in it."""
561
+ for node, held in left:
562
+ _refill(node, held)
563
+
564
+
565
+ def _refill(node: Any, content: Any) -> None:
566
+ if type(node) is dict:
567
+ node.clear()
568
+ node.update(content)
569
+ else:
570
+ node[:] = content
571
+
572
+
573
+ def _validated_again(validate: Callable[[Any], Any], given: Any, held: collections.abc.Iterable[Any]) -> Any:
574
+ """What *validate* builds from *given*, with each part in *held* put back as it was once it has run.
575
+
576
+ *held* names the parts the walk already holds where the validator has code of its own, and nothing where it has
577
+ none: run again on the payload, such a validator can change its input in place, which is what the walk is about
578
+ to read. The payload itself is given, not a copy, so what validation retains is still the payload's own object,
579
+ and each part is recorded container by container, so a container it shares with the rest of the payload is put
580
+ back as the very object it is.
581
+ """
582
+ records = [(part, (None, _held_now(part))) for part in held]
583
+ try:
584
+ return validate(given)
585
+ finally:
586
+ for part, record in records:
587
+ put_as_sent(part, record)
588
+
589
+
590
+ def _declared_container(declared: _Declared | None) -> tuple[tuple[_Declared, ...] | None, _Declared | None] | None:
591
+ """What *declared* says the items of a container built from it are declared as: one declaration per position of a
592
+ fixed tuple, or one for every item; ``None`` where it does not say.
593
+
594
+ Only a declaration that leaves validation no choice says (`_sole_member`): which member of a union validation took
595
+ depends on what the field's own validators made of the payload, and the kind of container they left does not tell.
596
+ """
597
+ if declared is None:
598
+ return None
599
+ key = (id(declared), "items")
600
+ known = _DECLARED.get(key)
601
+ if known is not None and known[0] is declared:
602
+ return known[1]
603
+ annotation, owner, node = declared
604
+ container = _sole_member(annotation)
605
+ under = _item_nodes(node, owner)
606
+ items = None
607
+ if get_origin(container) in _ITEM_ORIGINS and get_args(container):
608
+ arguments = get_args(container)
609
+ if _is_fixed_tuple(container):
610
+ told = None if under is None else under[0]
611
+ placed: Any = told if told is not None and len(told) == len(arguments) else [None] * len(arguments)
612
+ items = (tuple((argument, owner, schema) for argument, schema in zip(arguments, placed, strict=True)), None)
613
+ else:
614
+ items = (None, (arguments[0], owner, None if under is None else under[1]))
615
+ elif under is not None:
616
+ # the type is not read here, so the schema alone says what the items are
617
+ positional, common = under
618
+ items = (
619
+ None if positional is None else tuple((None, owner, schema) for schema in positional),
620
+ None if common is None else (None, owner, common),
621
+ )
622
+ _remember(_DECLARED, key, declared, items, 1024)
623
+ return items
624
+
625
+
626
+ def _declared_mapping(declared: _Declared) -> tuple[_Declared | None, _Record | _Unsure | None]:
627
+ """What *declared* says of a mapping built from it: the declaration of its values, or the `TypedDict` it is, which
628
+ declares each key on its own; ``None`` for what it does not say.
629
+
630
+ The schema says first. One `TypedDict` among its choices, beside types that never build a dict, is that record.
631
+ Several, or one beside a schema that may build a dict as well, are all that is known (`_Unsure`): the walk does not
632
+ choose between them as validation did. A `TypedDict` inside a schema that does not say what it builds is one of
633
+ them. Without a schema the type says, where it leaves validation no choice.
634
+ """
635
+ key = (id(declared), "values")
636
+ known = _DECLARED.get(key)
637
+ if known is not None and known[0] is declared:
638
+ return known[1]
639
+ annotation, owner, node = declared
640
+ leaves = [] if node is None else _choices(node, owner)
641
+ records = [leaf for leaf in leaves if _schema_kind(leaf) == "typed-dict"]
642
+ beside = [leaf for leaf in leaves if _schema_kind(leaf) not in _NO_DICT | {"typed-dict"}]
643
+ unknown = [leaf for leaf in beside if _schema_kind(leaf) != "dict"]
644
+ hidden = [record for leaf in unknown for record in _records_under(leaf, owner)]
645
+ mapping = _sole_member(annotation)
646
+ kind = get_origin(mapping) or mapping
647
+ written = kind if isinstance(kind, type) and issubclass(kind, dict) and hasattr(kind, "__required_keys__") else None
648
+ answer: tuple[_Declared | None, _Record | _Unsure | None] = (None, None)
649
+ if len(records) == 1 and not beside:
650
+ answer = (None, _Record(records[0], written))
651
+ elif records or hidden:
652
+ answer = (None, _Unsure((*records, *hidden), tuple(beside)))
653
+ elif written is not None:
654
+ answer = (None, _Record(None, written))
655
+ else:
656
+ values = _values_beside(beside)
657
+ under = _either(values) if len(unknown) < len(beside) else None
658
+ if get_origin(mapping) in _MAPPING_ORIGINS and len(get_args(mapping)) == 2:
659
+ answer = ((get_args(mapping)[1], owner, under), None)
660
+ elif under is not None:
661
+ answer = ((None, owner, under), None)
662
+ _remember(_DECLARED, key, declared, answer, 1024)
663
+ return answer
664
+
665
+
666
+ def _values_beside(beside: collections.abc.Iterable[Any]) -> list[Any]:
667
+ """What the schemas *beside* a `TypedDict` say of the values of a dict they build: the values a ``dict`` declares,
668
+ and a schema that does not say what it builds as it is, for what may lie inside it."""
669
+ return [leaf.get("values_schema", _ANY) if _schema_kind(leaf) == "dict" else leaf for leaf in beside]
670
+
671
+
672
+ def _sole_member(annotation: object) -> object:
673
+ """*annotation* without `Annotated` and without ``None`` beside it; ``None`` where a union leaves a choice."""
674
+ members = [member for member in _alternatives(annotation) if member is not type(None)]
675
+ return members[0] if len(members) == 1 else None
676
+
677
+
678
+ def _chooses(annotation: object) -> bool:
679
+ """Whether validation chose anywhere in *annotation* above the models it names: between two or more members of a
680
+ union besides ``None``, or inside a named type alias (`typing` or `typing_extensions`, plain or parametrized),
681
+ which is not opened. A type that is not read (``None``), and a type variable, count as a choice."""
682
+ members = [member for member in _alternatives(annotation) if member is not type(None)]
683
+ if any(member is None or isinstance(member, TypeVar) for member in members):
684
+ return True
685
+ aliases = tuple(
686
+ kind
687
+ for module in ("typing", "typing_extensions")
688
+ if isinstance(kind := getattr(sys.modules.get(module), "TypeAliasType", None), type)
689
+ )
690
+ if len(members) > 1 or any(
691
+ isinstance(named, aliases) for member in members for named in (member, get_origin(member))
692
+ ):
693
+ return True
694
+ return any(_chooses(argument) for member in members for argument in get_args(member) if argument is not Ellipsis)
695
+
696
+
697
+ def _is_fixed_tuple(container: object) -> bool:
698
+ arguments = get_args(container)
699
+ return get_origin(container) is tuple and not (len(arguments) == 2 and arguments[1] is Ellipsis)
700
+
701
+
702
+ def _item_declarations(declared: _Declared | None, count: int) -> list[_Declared | None]:
703
+ """The declaration of each of *count* items of a container built from *declared*, by position where a fixed tuple
704
+ declares one."""
705
+ items = _declared_container(declared)
706
+ if items is None:
707
+ return [None] * count
708
+ positional, common = items
709
+ if positional is None:
710
+ return [common] * count
711
+ return [positional[index] if index < len(positional) else None for index in range(count)]
712
+
713
+
714
+ _ADAPTERS: dict[int, tuple[_Declared, Any]] = {}
715
+ """A pydantic `TypeAdapter` per declaration, or ``None`` where none can be built: building one costs a schema. At
716
+ most 256 entries (`_remember`)."""
717
+
718
+
719
+ def _adapter(declared: _Declared) -> Any:
720
+ """A `TypeAdapter` for a declared type, in the config of the model that declared it, from the pydantic the models
721
+ came from; ``None`` where there is none."""
722
+ known = _ADAPTERS.get(id(declared))
723
+ if known is not None and known[0] is declared:
724
+ return known[1]
725
+ annotation, owner, _ = declared
726
+ adapter_class = getattr(sys.modules.get("pydantic"), "TypeAdapter", None)
727
+ adapter = None
728
+ if adapter_class is not None:
729
+ # a schema pydantic refuses: TypeError in 2.0, RuntimeError in 2.13, NameError for a forward reference
730
+ try:
731
+ config = getattr(owner, "model_config", None) or getattr(owner, "__pydantic_config__", None)
732
+ adapter = adapter_class(annotation, config=config or None)
733
+ except (TypeError, RuntimeError, NameError):
734
+ adapter = None
735
+ _remember(_ADAPTERS, id(declared), declared, adapter, 256)
736
+ return adapter
737
+
738
+
739
+ _FieldReads = tuple[tuple[str, tuple[tuple[object, ...], ...], _Declared], ...]
740
+
741
+ _Sources = tuple[tuple[object, ...], ...]
742
+ _KeyReads = tuple[frozenset[object], tuple[_Sources, ...], dict[object, _Sources]]
743
+ """Which keys of a payload a model or a record reads: the keys it reads wherever they are sent, the sources of each
744
+ field that may be read from another place, and every key that spells a field beside where validation reads that
745
+ field."""
746
+
747
+
748
+ def _key_reads(fields: collections.abc.Iterable[tuple[_Sources, collections.abc.Iterable[object]]]) -> _KeyReads:
749
+ """The keys the *fields* are read from, each given as its sources in the order validation tries them and the
750
+ keys that spell it.
751
+
752
+ A field is read from the first of its sources a payload holds whole. The first of them, where it is a key and
753
+ no path, is read wherever it is sent. A later one is read only where those before it are absent, and a path only
754
+ where it leads somewhere, so those are told per payload (`_unread`): of two spellings sent validation drops the
755
+ later, and the key a path starts at is dropped where the path leads nowhere.
756
+ """
757
+ sure: set[object] = set()
758
+ chosen: list[_Sources] = []
759
+ spelled: dict[object, _Sources] = {}
760
+ for sources, spellings in fields:
761
+ if sources and len(sources[0]) == 1:
762
+ sure.add(sources[0][0])
763
+ if len(sources) != 1 or len(sources[0]) != 1:
764
+ chosen.append(sources)
765
+ for key in (*(source[0] for source in sources), *spellings):
766
+ spelled.setdefault(key, sources)
767
+ return frozenset(sure), tuple(chosen), spelled
768
+
769
+
770
+ _READS: dict[type, tuple[object, _KeyReads, _FieldReads, _Declared | None]] = {}
771
+ """Per model class, with the validator they were read beside: the keys it reads, where each field is read, and
772
+ what a typed `__pydantic_extra__` declares its extra values as.
773
+
774
+ Worked out per level of every payload, it was most of the walk: a list of 1000 nested models asked for it 1000 times.
775
+ A 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.
776
+ A rebuild replaces the validator, and with it what the class reads, so an entry answers only for the validator it
777
+ was made with. At most 256 classes are kept, so models made on the fly cannot grow it without bound."""
778
+
779
+
780
+ def _reads_of(model: Any) -> tuple[_KeyReads, _FieldReads, _Declared | None]:
781
+ validator = getattr(model, "__pydantic_validator__", None)
782
+ known = _READS.get(model)
783
+ if known is None or known[0] is not validator:
784
+ config = getattr(model, "model_config", {})
785
+ nodes = _field_nodes(model)
786
+ fields = tuple(
787
+ (name, _field_sources(name, info, config), (getattr(info, "annotation", None), model, nodes.get(name)))
788
+ for name, info in model.model_fields.items()
789
+ # a schema of its own can validate a field as something its annotation does not name
790
+ if _may_hold_model(getattr(info, "annotation", None)) or _keyed_under(nodes.get(name), model)
791
+ )
792
+ extras = _extra_values(model, nodes.get("__pydantic_extra__")) if config.get("extra") == "allow" else None
793
+ keyed = _key_reads(
794
+ (_field_sources(name, info, config), _spellings(name, info)) for name, info in model.model_fields.items()
795
+ )
796
+ known = (validator, keyed, fields, extras)
797
+ if model in _READS or len(_READS) < 256:
798
+ _READS[model] = known
799
+ return known[1], known[2], known[3]
800
+
801
+
802
+ def _field_nodes(model: Any) -> dict[str, Any]:
803
+ """The schema each field of *model* is validated by, as its core schema holds it, the one for its extra values
804
+ under ``__pydantic_extra__`` and a root's under ``root``; empty where there is no such schema to read."""
805
+ node: Any = getattr(model, "__pydantic_core_schema__", None)
806
+ refs = _refs(model)
807
+ while isinstance(node, dict):
808
+ kind = node.get("type")
809
+ if kind == "model-fields":
810
+ nodes = {name: field.get("schema") for name, field in node.get("fields", {}).items()}
811
+ # older pydantic names it `extra_validator`
812
+ return {**nodes, "__pydantic_extra__": node.get("extras_schema", node.get("extra_validator"))}
813
+ if kind == "model" and node.get("root_model"):
814
+ return {"root": node.get("schema")}
815
+ node = refs.get(node.get("schema_ref", "")) if kind == "definition-ref" else node.get("schema")
816
+ return {}
817
+
818
+
819
+ def _extra_values(model: Any, node: object) -> _Declared | None:
820
+ """The declaration a typed `__pydantic_extra__` gives the extra values of *model*, as written, beside the schema
821
+ *node* pydantic built for them.
822
+
823
+ The type is not read where it is text, which pydantic read in the namespace of the frame that defined the model,
824
+ where a name can mean another type than in its module, and, from 3.14, where the annotations name what is defined
825
+ only later, which reading them evaluates. The schema declares alone then; ``None`` where there is neither.
826
+ """
827
+ unread = None if node is None else (None, model, node)
828
+ try:
829
+ for klass in getattr(model, "__mro__", ()):
830
+ own = inspect.get_annotations(klass)
831
+ if "__pydantic_extra__" in own:
832
+ annotation = own["__pydantic_extra__"]
833
+ if isinstance(annotation, str):
834
+ return unread
835
+ values = _declared_mapping((annotation, model, None))[0]
836
+ return values and (values[0], model, node)
837
+ except NameError:
838
+ return unread
839
+ return None
840
+
841
+
842
+ _RecordReads = tuple[
843
+ _KeyReads, frozenset[str], bool, tuple[tuple[str, tuple[tuple[object, ...], ...], _Declared | None], ...], _Declared
844
+ ]
845
+ """For a dataclass or a `TypedDict`: the payload keys it reads, the names of its fields, whether it keeps extras, the
846
+ fields worth walking with where each is read and what it declares, and the class as a declaration of its own."""
847
+
848
+ _RECORDS: dict[tuple[object, object], tuple[object, _RecordReads]] = {}
849
+ """Per dataclass or `TypedDict`, by its schema or its class, and the class whose schema holds it, beside the schema or
850
+ the validator it was read with. At most 256 entries (`_remember`)."""
851
+
852
+
853
+ def _record_reads(record: _Record, owner: object = None) -> _RecordReads:
854
+ """How a dataclass or a `TypedDict` reads a payload: as `_reads_of` says of a model.
855
+
856
+ The keys it takes and where each field is read come from the schema pydantic built, the one the walk came to it
857
+ by, the record's own, or the one of the class that declared it (*owner*). It holds what no annotation tells: an
858
+ alias written as text, the one of several `Field`s that wins, a field ``init=False`` leaves out, and the config
859
+ that says whether a name is read, which reaches a `TypedDict` from the model above. Where no schema holds the
860
+ record, the annotations are read as written (`_written_fields`), and a name counts as read after its aliases.
861
+
862
+ What each field declares below it is the schema of that field, beside its annotation as pydantic resolved it or
863
+ as written where that can be read.
864
+ """
865
+ node, kind = record
866
+ if node is None:
867
+ holder = kind if isinstance(getattr(kind, "__pydantic_core_schema__", None), dict) else owner
868
+ key, read_with = (kind, holder), getattr(holder, "__pydantic_validator__", None)
869
+ else:
870
+ holder, key, read_with = owner, (id(node), owner), node
871
+ kind = kind or node.get("cls")
872
+ known = _RECORDS.get(key)
873
+ if known is not None and known[0] is read_with:
874
+ return known[1]
875
+ # only later pydantic names the class in the schema of a `TypedDict`
876
+ told = _written_fields(kind) if isinstance(kind, type) else ()
877
+ written = {name: (sources, annotation, held) for name, sources, annotation, held in told}
878
+ found = _record_node(_Record(node, kind), holder)
879
+ said = found if isinstance(found, dict) else {}
880
+ # an older pydantic says it in the config alone, and a `TypedDict` that declares its extras in the schema alone
881
+ keeps = said.get("extra_behavior", (said.get("config") or {}).get("extra_fields_behavior")) == "allow"
882
+ reads = _schema_fields(_Record(found, kind), holder)
883
+ reads = reads or [(name, sources, held, None, ()) for name, (sources, _, held) in written.items()]
884
+ keyed, fields = [], []
885
+ for name, sources, held, schema, spellings in reads:
886
+ keyed.append((sources, (name, *spellings)))
887
+ annotation = written[name][1] if name in written else None
888
+ if held and (annotation is None or _may_hold_model(annotation) or _keyed_under(schema, holder)):
889
+ fields.append(
890
+ (name, sources, None if annotation is None and schema is None else (annotation, holder, schema))
891
+ )
892
+ names = frozenset(name for name, *_ in reads)
893
+ answer = (_key_reads(keyed), names, keeps, tuple(fields), (kind, None, None))
894
+ _remember(_RECORDS, key, read_with, answer, 256)
895
+ return answer
896
+
897
+
898
+ def _record_node(record: _Record, holder: object) -> Any:
899
+ """The schema of a record: the one the walk came to it by, else the one the schema of *holder* has for its class,
900
+ or ``None``. A `TypedDict` is told by the id its ``ref`` ends in, since only later pydantic names its class
901
+ there."""
902
+ found, kind = record
903
+ pending: list[Any] = [] if found is not None else [getattr(holder, "__pydantic_core_schema__", None)]
904
+ while pending and found is None:
905
+ node = pending.pop()
906
+ if isinstance(node, dict):
907
+ mine = node.get("cls") is kind or str(node.get("ref", "")).endswith(f":{id(kind)}")
908
+ if node.get("type") in ("typed-dict", "dataclass") and mine:
909
+ found = node
910
+ pending.extend(value for key, value in node.items() if key not in ("serialization", "metadata", "default"))
911
+ elif isinstance(node, (list, tuple)):
912
+ pending.extend(node)
913
+ return found
914
+
915
+
916
+ def _schema_fields(
917
+ record: _Record, holder: object
918
+ ) -> list[tuple[str, tuple[tuple[object, ...], ...], bool, Any, tuple[object, ...]]]:
919
+ """Each key the schema says a record takes: its name, where it is read (its alias paths, then its name, each
920
+ where the record's config reads by it), whether the record holds it, which an `InitVar` is not, the schema of its
921
+ value, and the other keys that spell it. Empty where no schema holds the record (`_record_node`).
922
+ """
923
+ found = _record_node(record, holder)
924
+ config = (found.get("config") or {}) if isinstance(found, dict) else {}
925
+ by_alias, by_name = config.get("validate_by_alias") is not False, config.get("validate_by_name")
926
+ by_name = by_name if by_name is not None else config.get("populate_by_name")
927
+ while isinstance(found, dict) and found.get("type") not in ("typed-dict", "dataclass-args"):
928
+ found = found.get("schema")
929
+ if not isinstance(found, dict):
930
+ return []
931
+ listed = found["fields"]
932
+ named = listed.items() if isinstance(listed, dict) else [(field["name"], field) for field in listed]
933
+ reads = []
934
+ for name, field in named:
935
+ if field.get("init") is not False:
936
+ alias = field.get("validation_alias")
937
+ paths = [alias] if isinstance(alias, str) or not alias or not isinstance(alias[0], list) else alias
938
+ aliases = [(path,) if isinstance(path, str) else tuple(path) for path in paths if path]
939
+ read_at = (*(aliases if by_alias else ()), *([(name,)] if by_name or not aliases else ()))
940
+ shown = field.get("serialization_alias")
941
+ spellings = (*(path[0] for path in aliases), *([shown] if isinstance(shown, str) else ()))
942
+ reads.append((name, read_at, not field.get("init_only"), field.get("schema"), spellings))
943
+ return reads
944
+
945
+
946
+ def _written_fields(kind: Any) -> collections.abc.Iterator[tuple[str, tuple[tuple[object, ...], ...], Any, bool]]:
947
+ """Each key a dataclass or a `TypedDict` declares, as its own annotations tell it: its name, where it is read,
948
+ its annotation where that is no text, and whether the record holds it.
949
+
950
+ A dataclass takes a key for each field its `__init__` takes, an `InitVar` among them and one declared
951
+ ``init=False`` not. A `TypedDict` keeps an annotation written as text as a forward reference.
952
+ """
953
+ config = getattr(kind, "__pydantic_config__", None) or {}
954
+ declared: list[tuple[str, Any, Any, bool]]
955
+ if issubclass(kind, dict):
956
+ hints: dict[str, Any] = {}
957
+ try:
958
+ for klass in reversed(kind.__mro__):
959
+ hints.update(inspect.get_annotations(klass))
960
+ except NameError:
961
+ hints = {}
962
+ keyed: Any = kind
963
+ names = (*keyed.__required_keys__, *keyed.__optional_keys__)
964
+ declared = [(name, hints.get(name), None, True) for name in names]
965
+ else:
966
+ infos = getattr(kind, "__pydantic_fields__", None) or {}
967
+ # a plain dataclass may hold pydantic's `Field` as a default, which is where its alias then is
968
+ declared = [
969
+ (name, getattr(info, "annotation", None) or field.type, info, role == "_FIELD")
970
+ for name, field in kind.__dataclass_fields__.items()
971
+ for role in [getattr(getattr(field, "_field_type", None), "name", "_FIELD")]
972
+ for info in [infos.get(name) or (field.default if hasattr(field.default, "validation_alias") else None)]
973
+ if field.init and role != "_FIELD_CLASSVAR"
974
+ ]
975
+ for name, annotation, info, held in declared:
976
+ while getattr(get_origin(annotation), "_name", None) in ("Required", "NotRequired", "ReadOnly"):
977
+ annotation = get_args(annotation)[0]
978
+ carried = get_args(annotation)[1:] if get_origin(annotation) is Annotated else ()
979
+ # of several `Field`s pydantic keeps what the later ones set, so the alias is the last one given
980
+ aliased = (
981
+ extra
982
+ for extra in reversed(carried)
983
+ if getattr(extra, "validation_alias", None) or getattr(extra, "alias", None)
984
+ )
985
+ info = info or next(aliased, None)
986
+ sources = ((name,),) if info is None else (*_field_sources(name, info, config), (name,))
987
+ yield name, sources, None if isinstance(annotation, (str, ForwardRef)) else annotation, held
988
+
989
+
990
+ def _followed(payload: object, path: tuple[object, ...]) -> tuple[bool, object]:
991
+ """What *path* reaches in *payload*, key by key and index by index, and whether it reached anything."""
992
+ current: Any = payload
993
+ for step in path:
994
+ if isinstance(current, dict):
995
+ if step not in current:
996
+ return False, None
997
+ current = current[step]
998
+ elif not isinstance(current, str):
999
+ # read as pydantic reads it, through whatever `__getitem__` the object has
1000
+ try:
1001
+ current = current[step]
1002
+ except (LookupError, TypeError):
1003
+ return False, None
1004
+ else:
1005
+ return False, None
1006
+ return True, current
1007
+
1008
+
1009
+ def _raw_field(
1010
+ payload: collections.abc.Mapping, sources: tuple[tuple[object, ...], ...]
1011
+ ) -> tuple[tuple[object, ...] | None, object]:
1012
+ """The part of *payload* pydantic validated a field from, after the first of its sources that is there."""
1013
+ for source in sources:
1014
+ found, value = _followed(payload, source)
1015
+ if found:
1016
+ return source, value
1017
+ return None, None
1018
+
1019
+
1020
+ def _unread(left: list[object], payload: collections.abc.Mapping, reads: _KeyReads) -> list[object]:
1021
+ """Those of the keys *left*, which no field is sure to read, that no field was read from in *payload*: the ones
1022
+ validation dropped, or kept as extras."""
1023
+ read = {source[0] for sources in reads[1] for source in [_raw_field(payload, sources)[0]] if source}
1024
+ return [key for key in left if key not in read]
1025
+
1026
+
1027
+ def _undeclared(payload: collections.abc.Mapping, key: object, path: _Hop, reads: _KeyReads) -> _Found:
1028
+ """The find for a *key* of *payload* validation dropped, with the reason where the key spells a field: the
1029
+ source its field was read from in its place, or the ones validation reads it from where the payload sent none."""
1030
+ sources = reads[2].get(key)
1031
+ if not sources:
1032
+ return (path, key, (key,)), payload[key]
1033
+ read, _ = _raw_field(payload, sources)
1034
+ reason = (
1035
+ f"its field was read from <{_spelt(read)}>"
1036
+ if read is not None
1037
+ else f"validation reads its field from {' or '.join(f'<{_spelt(source)}>' for source in sources)}"
1038
+ )
1039
+ return (path, key, (key,)), payload[key], reason
1040
+
1041
+
1042
+ def _spelt(source: tuple[object, ...]) -> str:
1043
+ """A source as a failure names it: a key, and the keys and positions a path goes on by."""
1044
+ return "".join(f"[{step}]" if isinstance(step, int) else f".{step}" for step in source).removeprefix(".")
1045
+
1046
+
1047
+ def _placed(hop: _Hop) -> tuple[str, tuple[Step, ...]]:
1048
+ """Where *hop* stands, as the text a failure names it by and as the steps that reach it in the payload."""
1049
+ hops = []
1050
+ while hop is not None:
1051
+ hops.append(hop)
1052
+ hop = hop[0]
1053
+ # joined once: adding each piece to the text so far copied it again at every hop
1054
+ pieces, steps, written = [], [], False
1055
+ for _, piece, taken in reversed(hops):
1056
+ if type(taken) is tuple:
1057
+ named = f"{piece}"
1058
+ pieces.append(f".{named}" if written else named)
1059
+ written = written or bool(named)
1060
+ steps += [Step("index" if type(key) is int else "key", key) for key in taken]
1061
+ elif piece is None:
1062
+ steps.append(taken)
1063
+ else:
1064
+ pieces.append(f"[{piece}]")
1065
+ written = True
1066
+ steps.append(Step("index", piece) if taken is None else taken)
1067
+ return "".join(pieces), tuple(steps)
1068
+
1069
+
1070
+ def _is_model(value: object) -> bool:
1071
+ return not isinstance(value, type) and hasattr(type(value), "model_fields")
1072
+
1073
+
1074
+ def _is_record(value: object) -> bool:
1075
+ """A model or a dataclass instance: what validation builds from a mapping and holds field by field."""
1076
+ return _is_model(value) or (not isinstance(value, type) and hasattr(type(value), "__dataclass_fields__"))
1077
+
1078
+
1079
+ _CONTAINERS = (dict, list, tuple, set, frozenset)
1080
+
1081
+ _REPLAYED = (0, -1)
1082
+ """In the pairs a walk has seen, where no pair of ids can be: the walk is under a replay (`_replayed`), where a field
1083
+ whose type offers a choice cannot be read (`_steered`)."""
1084
+
1085
+
1086
+ class UncheckableDriftError(Exception):
1087
+ """A part of the payload no reading pairs with the model it became, so whether it drifted cannot be told."""
1088
+
1089
+ def __init__(self, path: _Hop, reason: str, part: object) -> None:
1090
+ self.path, self.steps = _placed(path)
1091
+ super().__init__(f"{self.path or 'the payload'}: {reason}")
1092
+ self.reason = reason
1093
+ self.part = part
1094
+
1095
+
1096
+ def contract_drift(
1097
+ payload: object, instance: Any, path: _Hop = None, _seen: frozenset[tuple[int, int]] = frozenset()
1098
+ ) -> list[_Found]:
1099
+ """The fields ``payload`` carries that the model ``instance`` was validated into does not declare.
1100
+
1101
+ Walked beside the instance rather than read off the annotations: which member of a union, which element type
1102
+ of a tuple and which dict value a part of the payload became is what pydantic decided, and the instance holds
1103
+ the answer. A model whose config opts into extras (``extra="allow"``) keeps them intentionally, so its own
1104
+ level is skipped.
1105
+
1106
+ Raises:
1107
+ UncheckableDriftError: where a part of the payload that could hide an undeclared key (a dict inside it, or
1108
+ inside the JSON text it is) cannot be paired with a model built from it: a set, a resized list or an
1109
+ object wrapped into either whose built items are not all one model class, or whose raw item that class
1110
+ refuses on its own, dict keys coercion merged, a part of another shape than what was built, an iterable
1111
+ validation read up or left lazy, raw items no built item is left for that their declared type does not
1112
+ build again or that it declares through a choice. The payload is read as it is handed over, which
1113
+ `assert_conforms` does after putting back what validation changed in place: a validator renaming keys,
1114
+ or reordering or rewriting a container's items without changing its size, is not seen, and the walk
1115
+ reads those items by position.
1116
+ """
1117
+ model = type(instance)
1118
+ reads, fields, extras = _reads_of(model)
1119
+ replayed = _REPLAYED in _seen
1120
+ if getattr(model, "__pydantic_root_model__", False):
1121
+ return _declared_drift(payload, instance.root, path, _seen, fields[0][2], replayed) if fields else []
1122
+ if not isinstance(payload, (dict, collections.abc.Mapping)):
1123
+ _refuse_if_key_hides(
1124
+ payload, instance, path, f"the payload holds a {type(payload).__name__} where a model was built"
1125
+ )
1126
+ return []
1127
+ seen = _seen | {(id(payload), id(instance))}
1128
+ drift: list[_Found] = []
1129
+ sure = reads[0]
1130
+ left = [key for key in payload if key not in sure]
1131
+ if left:
1132
+ left = _unread(left, payload, reads)
1133
+ if getattr(model, "model_config", {}).get("extra") == "allow":
1134
+ drift += _extras_drift(payload, instance, path, seen, extras, replayed, left)
1135
+ elif left:
1136
+ drift += [_undeclared(payload, key, path, reads) for key in left]
1137
+ for name, sources, annotated in fields:
1138
+ source, raw = _raw_field(payload, sources)
1139
+ if source is None:
1140
+ continue
1141
+ if replayed and _chooses(annotated[0]):
1142
+ drift += _steered(raw, (path, name, source))
1143
+ continue
1144
+ value = getattr(instance, name, None)
1145
+ # a scalar built from a scalar holds nothing to pair
1146
+ if (value is None or isinstance(value, (str, int, float, bytes))) and not isinstance(raw, _CONTAINERS):
1147
+ continue
1148
+ drift += _value_drift(raw, value, (path, name, source), seen, annotated)
1149
+ return drift
1150
+
1151
+
1152
+ def _extras_drift(
1153
+ payload: collections.abc.Mapping,
1154
+ instance: Any,
1155
+ path: _Hop,
1156
+ seen: frozenset[tuple[int, int]],
1157
+ declared: _Declared | None,
1158
+ replayed: bool,
1159
+ left: list[object],
1160
+ ) -> list[_Found]:
1161
+ """Drift under the extras of a model that allows them: built through a typed `__pydantic_extra__` and walked as it
1162
+ *declared* them; untyped ones are the payload's own objects.
1163
+
1164
+ An extra the payload sent that the model no longer holds was built as that type and removed afterwards, by a
1165
+ validator. What it was built into is gone, so it is validated again as the declared type and walked beside what
1166
+ that builds, or refused where that cannot tell (`_replayed`). *left* are the keys no field was read from, which
1167
+ are the extras the model was sent.
1168
+ """
1169
+ held = getattr(instance, "__pydantic_extra__", None)
1170
+ # a validator can leave anything there: what is no dict holds no extra
1171
+ built = held if isinstance(held, dict) else {}
1172
+ drift = [
1173
+ entry
1174
+ for key, value in built.items()
1175
+ if key in payload
1176
+ for entry in _declared_drift(payload[key], value, (path, key, (key,)), seen, declared, replayed)
1177
+ ]
1178
+ if declared is not None:
1179
+ gone: list[tuple[_Hop, object]] = [((path, key, (key,)), payload[key]) for key in left if key not in built]
1180
+ if any(_holds(part, _is_mapping, read_text=True) for _, part in gone):
1181
+ reason = f"the model no longer holds {len(gone)} of the extras it was sent"
1182
+ drift += _replayed(payload, gone, path, seen, reason, _listed(declared))
1183
+ return drift
1184
+
1185
+
1186
+ def _listed(declared: _Declared) -> _Declared:
1187
+ """A list of what *declared* declares: the type several values declared so are validated again as."""
1188
+ key = (id(declared), "listed")
1189
+ known = _DECLARED.get(key)
1190
+ if known is not None and known[0] is declared:
1191
+ return known[1]
1192
+ annotation, owner, node = declared
1193
+ listed = (
1194
+ None if annotation is None else types.GenericAlias(list, (annotation,)),
1195
+ owner,
1196
+ {"type": "list", "items_schema": node},
1197
+ )
1198
+ _remember(_DECLARED, key, declared, listed, 1024)
1199
+ return listed
1200
+
1201
+
1202
+ def _declared_drift(
1203
+ raw: object,
1204
+ value: object,
1205
+ path: _Hop,
1206
+ seen: frozenset[tuple[int, int]],
1207
+ declared: _Declared | None,
1208
+ replayed: bool,
1209
+ ) -> list[_Found]:
1210
+ """Drift under a value its model declared, a root's or an extra's; `_steered` under a replay where validation built
1211
+ it through a declared type that offers a choice, or one that cannot be read."""
1212
+ if replayed and value is not raw and (declared is None or _chooses(declared[0])):
1213
+ return _steered(raw, path)
1214
+ return _value_drift(raw, value, path, seen, declared)
1215
+
1216
+
1217
+ def _steered(raw: object, path: _Hop) -> list[_Found]:
1218
+ """Nothing to read under a part a replay built where its type offered a choice, which the validators the replay
1219
+ skipped could have steered; a refusal where a key could hide in it."""
1220
+ if _holds(raw, _is_mapping, read_text=True):
1221
+ reason = "which of its declared types it became depends on validators not run again"
1222
+ raise UncheckableDriftError(path, reason, raw)
1223
+ return []
1224
+
1225
+
1226
+ def exactness_failure(pairs: Any, *, carrier: str) -> tuple[str, list[DiffEntry]] | None:
1227
+ """What an exact check of ``(payload, instance, index)`` pairs found: the words a failure continues with, and the
1228
+ same as diff entries. ``None`` when it found nothing.
1229
+
1230
+ The undeclared fields, after *carrier*, each an entry holding the value sent there with nothing expected of it;
1231
+ or the first part that cannot be checked, an entry holding that part against the reason. The steps of an entry
1232
+ lead into the payload, by the key the field was read from where its text names the field; where they enter JSON
1233
+ text, a ``json`` step holds what it decodes to.
1234
+ """
1235
+ drift: list[_Found] = []
1236
+ payload, start = None, None
1237
+ try:
1238
+ for payload, instance, index in pairs:
1239
+ start = None if index is None else (None, index, None)
1240
+ drift += contract_drift(payload, instance, start)
1241
+ except UncheckableDriftError as refusal:
1242
+ reason = f"cannot be checked: {refusal.reason}"
1243
+ entry = DiffEntry(path=refusal.path or ".", steps=refusal.steps, actual=refusal.part, expected=reason)
1244
+ return f"<{refusal.path or 'the payload'}> {reason}", [entry]
1245
+ except RecursionError:
1246
+ reason = "cannot be checked: it nests deeper than the walk can follow"
1247
+ where, steps = _placed(start)
1248
+ return f"<the payload> {reason}", [DiffEntry(path=where or ".", steps=steps, actual=payload, expected=reason)]
1249
+ if not drift:
1250
+ return None
1251
+ placed = sorted(((*_placed(hop), sent, why) for hop, sent, *why in drift), key=operator.itemgetter(0))
1252
+ entries = [DiffEntry(path=where, steps=steps, actual=sent, absent="expected") for where, steps, sent, _ in placed]
1253
+ paths = [entry.path for entry in entries]
1254
+ told = "".join(f"\n<{where}> is not read: {why[0]}" for where, _, _, why in placed if why)
1255
+ return f"{carrier}{len(entries)} undeclared field(s) the model does not declare: {paths}{told}", entries
1256
+
1257
+
1258
+ def _value_drift(
1259
+ raw: object, value: object, path: _Hop, seen: frozenset[tuple[int, int]], declared: _Declared | None = None
1260
+ ) -> list[_Found]:
1261
+ """Drift under one validated value: a model's own keys, and every element or dict value that became a model.
1262
+
1263
+ *declared* is the type the value was declared as and the model that declared it, where the walk knows them: what
1264
+ a model field or a root declares, and from it the item type of each container below.
1265
+ """
1266
+ # the payload's own object kept as it was, or a scalar: nothing was built from it
1267
+ if raw is value or value is None or isinstance(value, (str, int, float, bytes)):
1268
+ return []
1269
+ if isinstance(raw, (str, bytes, bytearray)):
1270
+ raw, path = _decoded_at(raw, path)
1271
+ # a pair already on the path is a cycle, the payload's own or one a validator built by assigning a model to itself
1272
+ pair = (id(raw), id(value))
1273
+ if pair in seen:
1274
+ return []
1275
+ seen = seen | {pair}
1276
+ if _is_model(value):
1277
+ return contract_drift(raw, value, path, seen)
1278
+ return _container_drift(raw, value, path, seen, declared)
1279
+
1280
+
1281
+ def _container_drift(
1282
+ raw: object, value: object, path: _Hop, seen: frozenset[tuple[int, int]], declared: _Declared | None
1283
+ ) -> list[_Found]:
1284
+ """Drift under a built container, paired with the raw part it came from as its kind allows."""
1285
+ if isinstance(value, (list, tuple)):
1286
+ return _sequence_drift(raw, value, path, seen, declared)
1287
+ if isinstance(value, dict):
1288
+ return _mapping_drift(raw, value, path, seen, declared)
1289
+ if isinstance(value, (set, frozenset)):
1290
+ return _set_drift(raw, value, path, seen, declared)
1291
+ if isinstance(value, collections.abc.Mapping):
1292
+ return _mapping_drift(raw, dict(value), path, seen, declared)
1293
+ if isinstance(value, collections.abc.Iterator):
1294
+ if _holds(raw, _is_mapping, read_text=True):
1295
+ reason = "validation is lazy here and builds the models only as the value is read"
1296
+ raise UncheckableDriftError(path, reason, raw)
1297
+ return []
1298
+ if hasattr(type(value), "__dataclass_fields__"):
1299
+ if not isinstance(raw, (dict, collections.abc.Mapping)):
1300
+ reason = f"the payload holds a {type(raw).__name__} where a dataclass was built"
1301
+ _refuse_if_key_hides(raw, value, path, reason)
1302
+ return []
1303
+ kind: Any = type(value)
1304
+ owner = None if declared is None else declared[1]
1305
+ stored = {field.name for field in dataclasses.fields(kind)}
1306
+ built = {name: getattr(value, name) for name in stored if hasattr(value, name)}
1307
+ # a dataclass that allows extras keeps them beside its fields
1308
+ extras = [name for name in getattr(value, "__dict__", ()) if name not in stored]
1309
+ return _record_drift(raw, built, extras, path, seen, _Record(None, kind), owner)
1310
+ # a sequence validation kept as the payload's own kind, a deque under `Sequence[A]`
1311
+ items = _items_of(value)
1312
+ return [] if items is None else _sequence_drift(raw, items, path, seen, declared)
1313
+
1314
+
1315
+ def _record_drift(
1316
+ raw: collections.abc.Mapping,
1317
+ built: collections.abc.Mapping,
1318
+ extras: collections.abc.Collection,
1319
+ path: _Hop,
1320
+ seen: frozenset[tuple[int, int]],
1321
+ record: _Record,
1322
+ owner: object,
1323
+ ) -> list[_Found]:
1324
+ """Drift under a dataclass or a `TypedDict`, which drop an undeclared key as a model does: the keys *raw* holds
1325
+ that the record neither read a field from nor keeps among its *extras*, where it allows them, and what lies under
1326
+ the fields it *built*.
1327
+
1328
+ A key that is the name of a field is no extra by being in what was built, where every field is: it is kept only
1329
+ by a record that keeps extras, which puts what it was sent there over the field.
1330
+ """
1331
+ reads, names, keeps, fields, _ = _record_reads(record, owner)
1332
+ replayed = _REPLAYED in seen
1333
+ sure = reads[0]
1334
+ left = [key for key in raw if key not in sure]
1335
+ drift: list[_Found] = []
1336
+ if left:
1337
+ drift = [
1338
+ _undeclared(raw, key, path, reads)
1339
+ for key in _unread(left, raw, reads)
1340
+ if (not keeps if key in names else key not in extras)
1341
+ ]
1342
+ for name, sources, declared in fields:
1343
+ source, part = _raw_field(raw, sources)
1344
+ if source is not None and name in built:
1345
+ drift += _declared_drift(part, built[name], (path, name, source), seen, declared, replayed)
1346
+ return drift
1347
+
1348
+
1349
+ def _unsure_drift(
1350
+ raw: collections.abc.Mapping,
1351
+ built: dict,
1352
+ unsure: _Unsure,
1353
+ path: _Hop,
1354
+ seen: frozenset[tuple[int, int]],
1355
+ owner: object,
1356
+ ) -> list[_Found]:
1357
+ """Drift under a dict one of several declared `TypedDict`s may have built.
1358
+
1359
+ One whose `Literal` fields do not hold what the dict holds did not build it, where no code of the owner's could
1360
+ have rewritten them since and no extra was put over them (`_put_over`). Where that leaves one, and nothing
1361
+ declared beside them may build a dict, the dict is
1362
+ read as that record. Else nothing tells the rest apart, and the walk does not choose as validation did. A key the
1363
+ payload sent that the dict holds neither under its name nor as the field one of them reads from it could have been
1364
+ dropped by any, so it refuses; so does a field two of them read from different parts of the payload. What the
1365
+ dict does hold is followed as a choice between whatever each of them, and each schema beside them, declares it as.
1366
+ """
1367
+ records = unsure.records
1368
+ if not runs_own_code(owner):
1369
+ records = tuple(record for record in records if _may_have_built(raw, built, record, owner))
1370
+ if len(records) == 1 and not unsure.beside:
1371
+ return _record_drift(raw, built, built, path, seen, _Record(records[0], None), owner)
1372
+ read_as, fields, other = _shared_reads(records, unsure.beside, owner)
1373
+ reason = "it holds a key the dict built from it does not, and its declared types do not say which built it"
1374
+ for key in raw:
1375
+ if key not in built and not any(name in built for name in read_as.get(key, ())):
1376
+ raise UncheckableDriftError(path, reason, raw)
1377
+ replayed = _REPLAYED in seen
1378
+ drift: list[_Found] = []
1379
+ for name, readings, declared in fields:
1380
+ read = {
1381
+ id(part): (source, part) for sources in readings for source, part in [_raw_field(raw, sources)] if source
1382
+ }
1383
+ if name in built and len(read) > 1:
1384
+ raise UncheckableDriftError(path, reason, raw)
1385
+ for source, part in read.values() if name in built else ():
1386
+ drift += _declared_drift(part, built[name], (path, name, source), seen, declared, replayed)
1387
+ declared_fields = {name for name, _, _ in fields}
1388
+ for key in built if other is not None else ():
1389
+ if key not in declared_fields and key in raw:
1390
+ drift += _declared_drift(raw[key], built[key], (path, key, (key,)), seen, other, replayed)
1391
+ return drift
1392
+
1393
+
1394
+ def _may_have_built(raw: collections.abc.Mapping, built: dict, record: Any, owner: object) -> bool:
1395
+ """Whether the `TypedDict` schema *record* may have built the dict *built* from *raw*: each of its `Literal` fields
1396
+ holds a value it takes, or one a key of the payload was put over."""
1397
+ over = _put_over(raw, record, owner)
1398
+ return all(
1399
+ name not in built or name in over or built[name] in allowed for name, allowed in _literals(record, owner)
1400
+ )
1401
+
1402
+
1403
+ def _put_over(raw: collections.abc.Mapping, record: Any, owner: object) -> collections.abc.Collection[object]:
1404
+ """The fields of the `TypedDict` schema *record* that a key of *raw* was put over, if it built the dict.
1405
+
1406
+ One that keeps extras keeps a key it read no field from, and where that key is the name of a field, as beside the
1407
+ alias the field was read from, it lands on what validation built there. What the dict holds under that name is
1408
+ then what was sent, and does not say that this one did not build it. One that drops extras had nothing put over
1409
+ its fields, so what another would have kept is nothing to it.
1410
+ """
1411
+ reads, names, keeps, _, _ = _record_reads(_Record(record, None), owner)
1412
+ if not keeps:
1413
+ return ()
1414
+ sure = reads[0]
1415
+ left = [key for key in raw if key in names and key not in sure]
1416
+ return _unread(left, raw, reads) if left else left
1417
+
1418
+
1419
+ def _literals(record: Any, owner: object) -> list[tuple[str, list[object]]]:
1420
+ """The fields of the `TypedDict` schema *record* that take nothing but the values a `Literal` lists, with those
1421
+ values, ``None`` among them where the field takes it too."""
1422
+ key = (id(record), "literals")
1423
+ known = _DECLARED.get(key)
1424
+ if known is not None and known[0] is record:
1425
+ return known[1]
1426
+ found = []
1427
+ for name, _, _, schema, _ in _schema_fields(_Record(record, None), owner):
1428
+ allowed: list[object] = []
1429
+ while _schema_kind(schema) == "nullable":
1430
+ allowed.append(None)
1431
+ schema = schema.get("schema")
1432
+ if _schema_kind(schema) == "literal":
1433
+ found.append((name, [*allowed, *schema.get("expected", ())]))
1434
+ _remember(_DECLARED, key, record, found, 1024)
1435
+ return found
1436
+
1437
+
1438
+ _SharedReads = tuple[
1439
+ dict[object, list[str]],
1440
+ tuple[tuple[str, tuple[tuple[tuple[object, ...], ...], ...], _Declared], ...],
1441
+ _Declared | None,
1442
+ ]
1443
+
1444
+
1445
+ def _shared_reads(records: tuple[Any, ...], beside: tuple[Any, ...], owner: object) -> _SharedReads:
1446
+ """What the `TypedDict` schemas *records* read between them: the fields each payload key is read into by one of
1447
+ them; each field with where each of them reads it, apart, and a choice between what they and the schemas *beside*
1448
+ them declare it as; and what those schemas declare a key that is no field of the records as."""
1449
+ key = (tuple(map(id, (*records, *beside))), "shared")
1450
+ known = _DECLARED.get(key)
1451
+ if known is not None and all(map(operator.is_, known[0], (*records, *beside))):
1452
+ return known[1]
1453
+ read_as: dict[object, list[str]] = {}
1454
+ read_at: dict[str, list[tuple[tuple[object, ...], ...]]] = {}
1455
+ schemas: dict[str, list[Any]] = {}
1456
+ for record in records:
1457
+ for name, sources, _, schema, _ in _schema_fields(_Record(record, None), owner):
1458
+ for source in sources:
1459
+ read_as.setdefault(source[0], []).append(name)
1460
+ read_at.setdefault(name, []).append(sources)
1461
+ schemas.setdefault(name, []).append(schema)
1462
+ values = _values_beside(beside)
1463
+ # what is declared beside them builds a key's value from that key alone, which is one more place to read it
1464
+ fields = tuple(
1465
+ (name, (*read_at[name], *([((name,),)] if values else [])), (None, owner, _either([*schemas[name], *values])))
1466
+ for name in read_at
1467
+ )
1468
+ answer = (read_as, fields, (None, owner, _either(values)) if values else None)
1469
+ _remember(_DECLARED, key, (*records, *beside), answer, 1024)
1470
+ return answer
1471
+
1472
+
1473
+ def _sequence_drift(
1474
+ raw: object,
1475
+ value: list | tuple,
1476
+ path: _Hop,
1477
+ seen: frozenset[tuple[int, int]],
1478
+ declared: _Declared | None = None,
1479
+ ) -> list[_Found]:
1480
+ """Paired by index, a collection pydantic also takes for a sequence (a deque, a set, a dict's values) in the
1481
+ order it iterates, which is the order validation read it in."""
1482
+ sent = raw
1483
+ if not isinstance(raw, (list, tuple)):
1484
+ items = _items_of(raw)
1485
+ if items is None:
1486
+ reason = f"the payload holds a {type(raw).__name__} where a sequence was built"
1487
+ return _wrapped(raw, value, path, seen, reason, declared)
1488
+ raw = items
1489
+ if len(raw) != len(value) or not isinstance(sent, collections.abc.Sequence):
1490
+ parts = _numbered(sent, raw, path)
1491
+ if len(raw) != len(value):
1492
+ reason = f"validation changed its length, {len(raw)} items became {len(value)}"
1493
+ return _unpaired(sent, parts, value, path, seen, reason, declared=declared)
1494
+ declarations = _item_declarations(declared, len(value))
1495
+ return [
1496
+ entry
1497
+ for (place, part), element, item in zip(parts, value, declarations, strict=True)
1498
+ for entry in _value_drift(part, element, place, seen, item)
1499
+ ]
1500
+ items = _declared_container(declared)
1501
+ if items is None or items[0] is None:
1502
+ common = None if items is None else items[1]
1503
+ return [
1504
+ entry
1505
+ for index, (part, element) in enumerate(zip(raw, value, strict=True))
1506
+ for entry in _value_drift(part, element, (path, index, None), seen, common)
1507
+ ]
1508
+ declarations = _item_declarations(declared, len(value))
1509
+ return [
1510
+ entry
1511
+ for index, (part, element, item) in enumerate(zip(raw, value, declarations, strict=True))
1512
+ for entry in _value_drift(part, element, (path, index, None), seen, item)
1513
+ ]
1514
+
1515
+
1516
+ def _numbered(sent: object, items: collections.abc.Iterable, path: _Hop) -> list[tuple[_Hop, object]]:
1517
+ """Each of the *items* a collection was *sent* holding, at the place a failure names it by: its number in the
1518
+ order it was read, which leads to it by position in a sequence and by the item itself in anything else."""
1519
+ if isinstance(sent, collections.abc.Sequence):
1520
+ return [((path, index, None), part) for index, part in enumerate(items)]
1521
+ return [((path, index, Step("item", part)), part) for index, part in enumerate(items)]
1522
+
1523
+
1524
+ def _mapping_drift(
1525
+ raw: object, value: dict, path: _Hop, seen: frozenset[tuple[int, int]], declared: _Declared | None = None
1526
+ ) -> list[_Found]:
1527
+ """Paired by key where validation kept the keys (a `TypedDict` reorders them), else by order, which dict validation
1528
+ keeps while it coerces them; a key built into something other than text or a number is walked beside its raw key."""
1529
+ if not isinstance(raw, (dict, collections.abc.Mapping)):
1530
+ reason = f"the payload holds a {type(raw).__name__} where a mapping was built"
1531
+ _refuse_if_key_hides(raw, value, path, reason, declared)
1532
+ return []
1533
+ item, record = (None, None) if declared is None else _declared_mapping(declared)
1534
+ owner = None if declared is None else declared[1]
1535
+ if isinstance(record, _Unsure):
1536
+ return _unsure_drift(raw, value, record, path, seen, owner)
1537
+ if record is not None:
1538
+ return _record_drift(raw, value, value, path, seen, record, owner)
1539
+ if len(raw) != len(value):
1540
+ # an overwritten value may have become another member of a union than the one that survived
1541
+ reason = f"validation changed its size, {len(raw)} keys became {len(value)}"
1542
+ _refuse_if_key_hides(raw, value, path, reason, declared)
1543
+ return []
1544
+ if raw.keys() == value.keys():
1545
+ return [
1546
+ entry
1547
+ for key, part in raw.items()
1548
+ for entry in _value_drift(part, value[key], (path, key, (key,)), seen, item)
1549
+ ]
1550
+ drift: list[_Found] = []
1551
+ for (key, part), (built, element) in zip(raw.items(), value.items(), strict=True):
1552
+ if not isinstance(built, (str, int)):
1553
+ drift += _value_drift(key, built, (path, None, Step("item", key)), seen)
1554
+ drift += _value_drift(part, element, (path, key, (key,)), seen, item)
1555
+ return drift
1556
+
1557
+
1558
+ def _set_drift(
1559
+ raw: object, value: set | frozenset, path: _Hop, seen: frozenset[tuple[int, int]], declared: _Declared | None
1560
+ ) -> list[_Found]:
1561
+ items = raw if isinstance(raw, (list, tuple)) else _items_of(raw)
1562
+ if items is None:
1563
+ reason = f"the payload holds a {type(raw).__name__} where a set was built"
1564
+ return _wrapped(raw, value, path, seen, reason, declared)
1565
+ reason = "a set keeps no order to pair its items with the models they became"
1566
+ return _unpaired(raw, _numbered(raw, items, path), value, path, seen, reason, merged=True, declared=declared)
1567
+
1568
+
1569
+ def _wrapped(
1570
+ raw: object,
1571
+ value: object,
1572
+ path: _Hop,
1573
+ seen: frozenset[tuple[int, int]],
1574
+ reason: str,
1575
+ declared: _Declared | None,
1576
+ ) -> list[_Found]:
1577
+ """Drift under a mapping a validator turned into a sequence or a set, a single object sent where many may be.
1578
+
1579
+ Only a mapping that became one item, or none, is that item; one that became several was expanded in a way no
1580
+ reading pairs.
1581
+ """
1582
+ if isinstance(raw, (dict, collections.abc.Mapping)) and len(_items_of(value) or ()) <= 1:
1583
+ return _unpaired(raw, [(path, raw)], value, path, seen, reason, declared=declared)
1584
+ _refuse_if_key_hides(raw, value, path, reason, declared)
1585
+ return []
1586
+
1587
+
1588
+ def _unpaired(
1589
+ raw: object,
1590
+ parts: list[tuple[_Hop, object]],
1591
+ value: object,
1592
+ path: _Hop,
1593
+ seen: frozenset[tuple[int, int]],
1594
+ reason: str,
1595
+ *,
1596
+ merged: bool = False,
1597
+ declared: _Declared | None = None,
1598
+ ) -> list[_Found]:
1599
+ """Drift under a container whose raw items no reading pairs one by one with the items validation built.
1600
+
1601
+ Where every built item is an instance of one model class, each raw item became one too, so each raw item that
1602
+ could hide a key is validated again on its own by that class and walked beside what it becomes. Where validation
1603
+ kept no model at all, the raw items are validated again whole by the type the field *declared*, in its model's
1604
+ config, and what that builds is walked (`_replayed`). Anything else refuses with *reason*: items of mixed classes,
1605
+ an item that does not validate on its own, which needed its parent, where equal items *merged*, a class whose own
1606
+ `__eq__` could have merged an item of another class into it, and raw items no replay can tell the type of.
1607
+ """
1608
+ if not _holds(raw, _is_mapping, read_text=True):
1609
+ return []
1610
+ if _holds(value, _builds_model):
1611
+ model = _sole_model_class(value)
1612
+ if model is None or (merged and not _equal_only_within_its_class(model)):
1613
+ raise UncheckableDriftError(path, reason, raw)
1614
+ return _revalidated(raw, parts, model, path, seen, reason)
1615
+ return _replayed(raw, parts, path, seen, reason, declared)
1616
+
1617
+
1618
+ def _replayed(
1619
+ raw: object,
1620
+ parts: list[tuple[_Hop, object]],
1621
+ path: _Hop,
1622
+ seen: frozenset[tuple[int, int]],
1623
+ reason: str,
1624
+ declared: _Declared | None,
1625
+ ) -> list[_Found]:
1626
+ """Drift under raw items validation left without a model: validated again whole as the *declared* type, in Python
1627
+ mode as validation read them (`_replay_target`). All of them are replayed first, so a fixed tuple's positions
1628
+ see what validation saw; where pydantic refuses that, only the items that could hide a key. What that builds is
1629
+ paired item by item; a set it builds tells the one class they became.
1630
+
1631
+ The replay skips the validators of the field itself, so whatever they did to the payload is missing from it, and
1632
+ it refuses with *reason* wherever that leaves the type of the items open: a declaration that offers a choice
1633
+ (`_chooses`) or none at all, items pydantic builds nothing from, or no sequence of as many.
1634
+ """
1635
+ if declared is None or _chooses(declared[0]):
1636
+ raise UncheckableDriftError(path, reason, raw)
1637
+ replayed = _replay_target(declared)
1638
+ adapter = _adapter(replayed)
1639
+ if adapter is None:
1640
+ raise UncheckableDriftError(path, reason, raw)
1641
+ origin = get_origin(replayed[0])
1642
+ kind = origin if origin in (tuple, collections.deque) else list
1643
+ own = runs_own_code(replayed[1])
1644
+ rebuilt = _replay(adapter, parts, kind, own)
1645
+ if rebuilt is None:
1646
+ parts = [(label, part) for label, part in parts if _holds(part, _is_mapping, read_text=True)]
1647
+ rebuilt = _replay(adapter, parts, kind, own)
1648
+ seen = seen | {_REPLAYED}
1649
+ if isinstance(rebuilt, collections.abc.Set):
1650
+ return _unpaired(raw, parts, rebuilt, path, seen, reason, merged=True)
1651
+ elements = _items_of(rebuilt)
1652
+ if elements is None or len(elements) != len(parts):
1653
+ raise UncheckableDriftError(path, reason, raw)
1654
+ declarations = _item_declarations(replayed, len(elements))
1655
+ return [
1656
+ entry
1657
+ for (label, part), element, item in zip(parts, elements, declarations, strict=True)
1658
+ for entry in _value_drift(part, element, label, seen, item)
1659
+ ]
1660
+
1661
+
1662
+ def _replay(
1663
+ adapter: Any, parts: list[tuple[_Hop, object]], kind: Callable[[list[object]], object], own: bool
1664
+ ) -> object:
1665
+ """What *adapter* builds from the raw items of *parts*, handed over as the *kind* of container a strict config
1666
+ takes for its type (`_validated_again`), or ``None`` where pydantic refuses them."""
1667
+ try:
1668
+ items = [part for _, part in parts]
1669
+ return _validated_again(adapter.validate_python, kind(items), items if own else ())
1670
+ # a validation error, or a schema pydantic builds only on first use and then refuses
1671
+ except (ValueError, TypeError, RuntimeError, NameError):
1672
+ return None
1673
+
1674
+
1675
+ def _replay_target(declared: _Declared) -> _Declared:
1676
+ """The type raw items are validated again as: the one member of the *declared* type without the `Annotated`
1677
+ validators of the container itself, which are what emptied it, and a set as a list of its items, so no model is
1678
+ hashed and each item keeps its place."""
1679
+ key = (id(declared), "replay")
1680
+ known = _DECLARED.get(key)
1681
+ if known is not None and known[0] is declared:
1682
+ return known[1]
1683
+ annotation, owner, node = declared
1684
+ member = _sole_member(annotation) or annotation
1685
+ if member in (set, frozenset) or get_origin(member) in (
1686
+ set,
1687
+ frozenset,
1688
+ collections.abc.Set,
1689
+ collections.abc.MutableSet,
1690
+ ):
1691
+ member = types.GenericAlias(list, get_args(member) or (Any,))
1692
+ target = (member, owner, node)
1693
+ _remember(_DECLARED, key, declared, target, 1024)
1694
+ return target
1695
+
1696
+
1697
+ def _revalidated(
1698
+ raw: object,
1699
+ parts: list[tuple[_Hop, object]],
1700
+ model: type,
1701
+ path: _Hop,
1702
+ seen: frozenset[tuple[int, int]],
1703
+ reason: str,
1704
+ ) -> list[_Found]:
1705
+ """Drift under each raw item that could hide a key, validated again on its own by *model*, JSON text in JSON mode
1706
+ as `Json[...]` read it. A dataclass is validated through an adapter of its own."""
1707
+ validate = getattr(model, "model_validate", None)
1708
+ validate_json = getattr(model, "model_validate_json", None)
1709
+ if validate is None and hasattr(model, "__dataclass_fields__"):
1710
+ adapter = _adapter(_record_reads(_Record(None, model))[4])
1711
+ validate, validate_json = getattr(adapter, "validate_python", None), getattr(adapter, "validate_json", None)
1712
+ if validate is None or validate_json is None:
1713
+ raise UncheckableDriftError(path, reason, raw)
1714
+ drift: list[_Found] = []
1715
+ own = runs_own_code(model)
1716
+ for label, item in parts:
1717
+ part, place = _decoded_at(item, label) if isinstance(item, (str, bytes, bytearray)) else (item, label)
1718
+ held = (part,) if own else ()
1719
+ if _holds(part, _is_mapping, read_text=True):
1720
+ try:
1721
+ built = _validated_again(validate, part, held) if part is item else validate_json(item)
1722
+ except ValueError:
1723
+ raise UncheckableDriftError(path, reason, raw) from None
1724
+ drift += _value_drift(part, built, place, seen)
1725
+ return drift
1726
+
1727
+
1728
+ def _equal_only_within_its_class(model: type | None) -> bool:
1729
+ """Whether equal instances of *model* share its class: its `__eq__` is pydantic's own, or the one `dataclasses`
1730
+ writes, both of which compare classes.
1731
+
1732
+ A parametrized generic model does not: pydantic compares its origin, which every specialization shares.
1733
+ """
1734
+ if (getattr(model, "__pydantic_generic_metadata__", None) or {}).get("origin") is not None:
1735
+ return False
1736
+ owner = next((klass for klass in getattr(model, "__mro__", ()) if "__eq__" in vars(klass)), object)
1737
+ # the method `dataclasses` writes is compiled from text, so it names no file
1738
+ written = getattr(getattr(vars(owner)["__eq__"], "__code__", None), "co_filename", None) == "<string>"
1739
+ return owner.__module__.startswith("pydantic.") or (written and hasattr(owner, "__dataclass_fields__"))
1740
+
1741
+
1742
+ def _sole_model_class(value: object) -> type | None:
1743
+ """The one model or dataclass class every item of a built container is an instance of, or ``None``."""
1744
+ items = _items_of(value) or []
1745
+ kinds = {type(item) for item in items}
1746
+ return kinds.pop() if len(kinds) == 1 and _is_record(items[0]) else None
1747
+
1748
+
1749
+ def _decoded_at(text: str | bytes | bytearray, path: _Hop) -> tuple[object, _Hop]:
1750
+ """What JSON *text* decodes to (`_decoded_container`) and the place inside it, one hop past *path* that holds
1751
+ that very object; the text and *path* themselves where it is no JSON to enter."""
1752
+ decoded = _decoded_container(text)
1753
+ return (text, path) if decoded is text else (decoded, (path, None, Step("json", decoded)))
1754
+
1755
+
1756
+ def _decoded_container(text: str | bytes | bytearray) -> object:
1757
+ """What JSON *text* decodes to, decoded again while that is text, when it ends in a mapping or a list; else *text*.
1758
+
1759
+ A model read from JSON text (`Json[...]`) is walked beside what the text holds. Text that is not JSON, or nests
1760
+ deeper than the decoder goes, is left as it is.
1761
+ """
1762
+ try:
1763
+ current = json.loads(text)
1764
+ while isinstance(current, str):
1765
+ current = json.loads(current)
1766
+ except (ValueError, RecursionError):
1767
+ return text
1768
+ return current if isinstance(current, (dict, list)) else text
1769
+
1770
+
1771
+ def _refuse_if_key_hides(
1772
+ raw: object, value: object, path: _Hop, reason: str, declared: _Declared | None = None
1773
+ ) -> None:
1774
+ """Refuse a part no reading pairs with what it became, when a model was built there, or a dict a `TypedDict`
1775
+ *declared* for it builds, and a key could hide in it.
1776
+
1777
+ A number, a model given as is, an object read by attributes and text that is not JSON hold no key to drift.
1778
+ """
1779
+ keyed = _holds(value, _builds_model) or (declared is not None and _records_under(declared[2], declared[1]))
1780
+ if keyed and _holds(raw, _is_mapping, read_text=True):
1781
+ raise UncheckableDriftError(path, reason, raw)
1782
+
1783
+
1784
+ def _holds(value: object, found: Callable[[object], bool], *, read_text: bool = False) -> bool:
1785
+ """Whether *value*, or anything inside its containers (a dict's keys too), is *found*.
1786
+
1787
+ With *read_text*, text counts as the JSON it decodes to, decoded again while that is text, and two things count
1788
+ as found, since what they hold cannot be told: text nesting deeper than the decoder goes, and an iterable that is
1789
+ no collection, which validation may have read up or may answer differently when read again. Every part stays
1790
+ referenced until the search ends, so the id of one already seen is never taken by a part decoded later.
1791
+ """
1792
+ pending, visited = [value], {id(value): value}
1793
+ while pending:
1794
+ current = pending.pop()
1795
+ if read_text and isinstance(current, (str, bytes, bytearray)):
1796
+ try:
1797
+ pending.append(json.loads(current))
1798
+ except ValueError:
1799
+ pass
1800
+ except RecursionError:
1801
+ return True
1802
+ continue
1803
+ elif read_text and _read_once(current):
1804
+ return True
1805
+ if found(current):
1806
+ return True
1807
+ for part in _parts(current):
1808
+ if id(part) not in visited:
1809
+ visited[id(part)] = part
1810
+ pending.append(part)
1811
+ return False
1812
+
1813
+
1814
+ def _is_mapping(value: object) -> bool:
1815
+ return isinstance(value, (dict, collections.abc.Mapping))
1816
+
1817
+
1818
+ def _builds_model(value: object) -> bool:
1819
+ """A model or a dataclass, or an iterator validation left lazy, which builds its models only as it is read."""
1820
+ return _is_record(value) or isinstance(value, collections.abc.Iterator)
1821
+
1822
+
1823
+ def _read_once(value: object) -> bool:
1824
+ """An iterable that is no collection and no model given as is: what it held cannot be read again."""
1825
+ return (
1826
+ isinstance(value, collections.abc.Iterable)
1827
+ and not isinstance(value, collections.abc.Collection)
1828
+ and not _is_model(value)
1829
+ )
1830
+
1831
+
1832
+ def _items_of(value: object) -> list[Any] | None:
1833
+ """The items of a container read again as often as asked, not text and not a mapping; ``None`` for the rest."""
1834
+ if isinstance(value, collections.abc.Collection) and not isinstance(
1835
+ value, (str, bytes, bytearray, collections.abc.Mapping)
1836
+ ):
1837
+ return list(value)
1838
+ return None
1839
+
1840
+
1841
+ def _parts(value: object) -> Any:
1842
+ """What a container holds, a mapping's keys too, or nothing for anything else."""
1843
+ if isinstance(value, (list, tuple, set, frozenset)):
1844
+ return value
1845
+ if isinstance(value, (dict, collections.abc.Mapping)):
1846
+ return [*value, *value.values()]
1847
+ return _items_of(value) or ()
1848
+
1849
+
1850
+ def shape(value: object, _seen: frozenset[int] = frozenset()) -> object:
1851
+ """The structural shape of a value: paths and type *categories*, never values.
1852
+
1853
+ Numbers collapse to one category (so ``5`` and ``5.0`` do not read as drift) and ``None`` becomes
1854
+ ``"null"`` (a nullable wildcard). A list becomes a single merged element shape. This is what a
1855
+ contract snapshot stores, so later runs pass when values change but fail on structural drift.
1856
+ """
1857
+ if value is None:
1858
+ return "null"
1859
+ if isinstance(value, bool):
1860
+ return "bool"
1861
+ if isinstance(value, (int, float)):
1862
+ return "number"
1863
+ if isinstance(value, str):
1864
+ return "str"
1865
+ if isinstance(value, (dict, list, tuple)):
1866
+ if id(value) in _seen:
1867
+ # a self-referential graph would otherwise recurse until the interpreter gives up
1868
+ return "<circular ref>"
1869
+ nested = _seen | {id(value)}
1870
+ if isinstance(value, dict):
1871
+ return {key: shape(item, nested) for key, item in value.items()}
1872
+ element_shapes = [shape(item, nested) for item in value]
1873
+ return [reduce(_merge, element_shapes)] if element_shapes else []
1874
+ return type(value).__name__
1875
+
1876
+
1877
+ def _merge(left: Any, right: Any) -> Any:
1878
+ """Merge two element shapes into one representative shape (``null`` yields to a concrete type)."""
1879
+ if left == right:
1880
+ return left
1881
+ if left == "null":
1882
+ return right
1883
+ if right == "null":
1884
+ return left
1885
+ if isinstance(left, dict) and isinstance(right, dict):
1886
+ return {key: _merge(left.get(key, "null"), right.get(key, "null")) for key in set(left) | set(right)}
1887
+ if isinstance(left, list) and isinstance(right, list):
1888
+ if not left:
1889
+ return right
1890
+ if not right:
1891
+ return left
1892
+ return [_merge(left[0], right[0])]
1893
+ return "mixed"
1894
+
1895
+
1896
+ def _shape_name(part: object) -> str:
1897
+ if isinstance(part, str):
1898
+ return part
1899
+ return "object" if isinstance(part, dict) else "list"
1900
+
1901
+
1902
+ def _join(path: str, key: str) -> str:
1903
+ return f"{path}.{key}" if path else key
1904
+
1905
+
1906
+ def shape_diff(old: Any, new: Any, path: str = "") -> list[tuple[str, str, str]]:
1907
+ """Structural drift between two shapes: ``(kind, path, detail)`` for added / removed / retyped leaves.
1908
+
1909
+ A ``null`` on either side is a nullable wildcard and never counts as drift.
1910
+ """
1911
+ if old == new or old == "null" or new == "null":
1912
+ return []
1913
+ if isinstance(old, dict) and isinstance(new, dict):
1914
+ drift: list[tuple[str, str, str]] = [("added", _join(path, key), "") for key in new if key not in old]
1915
+ for key in old:
1916
+ child = _join(path, key)
1917
+ if key not in new:
1918
+ drift.append(("removed", child, ""))
1919
+ else:
1920
+ drift += shape_diff(old[key], new[key], child)
1921
+ return drift
1922
+ if isinstance(old, list) and isinstance(new, list):
1923
+ if not old or not new:
1924
+ return []
1925
+ return shape_diff(old[0], new[0], f"{path}[*]")
1926
+ return [("retyped", path, f"{_shape_name(old)} -> {_shape_name(new)}")]