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.
- assertpy/__init__.py +87 -0
- assertpy/_clustering.py +511 -0
- assertpy/_dangling.py +505 -0
- assertpy/_engine/__init__.py +1 -0
- assertpy/_engine/_builder_check_typing.py +1365 -0
- assertpy/_engine/_capable_typing.py +772 -0
- assertpy/_engine/_check_typing.py +753 -0
- assertpy/_engine/_compare.py +983 -0
- assertpy/_engine/_compat.py +38 -0
- assertpy/_engine/_contract.py +1926 -0
- assertpy/_engine/_diff.py +1058 -0
- assertpy/_engine/_equality.py +843 -0
- assertpy/_engine/_introspection.py +729 -0
- assertpy/_engine/_membership.py +522 -0
- assertpy/_engine/_mixin_base.py +258 -0
- assertpy/_engine/_negated_typing.py +2176 -0
- assertpy/_engine/_operations.py +75 -0
- assertpy/_engine/_ordering.py +884 -0
- assertpy/_engine/_pairing.py +70 -0
- assertpy/_engine/_path.py +158 -0
- assertpy/_engine/_poll_typing.py +6039 -0
- assertpy/_engine/_require.py +312 -0
- assertpy/_engine/_size.py +31 -0
- assertpy/_engine/_text.py +39 -0
- assertpy/_engine/_typing.py +1226 -0
- assertpy/_hints.py +1213 -0
- assertpy/_inline.py +484 -0
- assertpy/_matcher_impls.py +1876 -0
- assertpy/_passes.py +249 -0
- assertpy/_renamed.py +64 -0
- assertpy/_satisfies.py +835 -0
- assertpy/_snapshot_codec.py +290 -0
- assertpy/assertpy.py +2640 -0
- assertpy/async_assertions.py +864 -0
- assertpy/base.py +862 -0
- assertpy/behave_matchers.py +96 -0
- assertpy/bytes_mixin.py +158 -0
- assertpy/collection.py +528 -0
- assertpy/contains.py +1068 -0
- assertpy/dataframe.py +200 -0
- assertpy/date.py +327 -0
- assertpy/dict.py +249 -0
- assertpy/dynamic.py +222 -0
- assertpy/errors.py +782 -0
- assertpy/exception.py +838 -0
- assertpy/extracting.py +266 -0
- assertpy/file.py +324 -0
- assertpy/helpers.py +747 -0
- assertpy/http_mixin.py +218 -0
- assertpy/json_mixin.py +1265 -0
- assertpy/matchers.py +639 -0
- assertpy/numeric.py +696 -0
- assertpy/outcome.py +163 -0
- assertpy/py.typed +0 -0
- assertpy/pytest_plugin.py +1650 -0
- assertpy/snapshot.py +1031 -0
- assertpy/string.py +803 -0
- assertpy/warning.py +137 -0
- assertpy-2.35.0.dist-info/METADATA +324 -0
- assertpy-2.35.0.dist-info/RECORD +63 -0
- assertpy-2.35.0.dist-info/WHEEL +4 -0
- assertpy-2.35.0.dist-info/entry_points.txt +2 -0
- 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)}")]
|