Skip to content

Commit 8072fa4

Browse files
committed
fix: refuse what an emptied container leaves exact=True unable to tell
1 parent 95be783 commit 8072fa4

4 files changed

Lines changed: 426 additions & 137 deletions

File tree

‎assertpy2/_engine/_contract.py‎

Lines changed: 125 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import decimal
1313
import enum
1414
import fractions
15+
import inspect
1516
import json
1617
import pathlib
1718
import sys
@@ -149,7 +150,17 @@ def _alternatives(annotation: object) -> tuple[object, ...]:
149150

150151
_DECLARED: dict[tuple[int, object], tuple[_Declared, Any]] = {}
151152
"""What a declaration says a container built from it holds, per declaration and kind of container: worked out once,
152-
since a list of 1000 orders asks it for each order. At most 1024 entries."""
153+
since a list of 1000 orders asks it for each order. At most 1024 entries (`_remember`)."""
154+
155+
156+
def _remember(
157+
cache: dict[Any, tuple[_Declared, Any]], key: object, declared: _Declared, answer: object, bound: int
158+
) -> None:
159+
"""Keep *answer* for *declared* under *key*, beside the declaration so the id stays its own. A full *cache* starts
160+
over rather than refusing new entries: a long run fills it early, and what it asks now is what it asks again."""
161+
if len(cache) >= bound:
162+
cache.clear()
163+
cache[key] = (declared, answer)
153164

154165

155166
def _declared_container(declared: _Declared | None) -> tuple[tuple[_Declared, ...] | None, _Declared | None] | None:
@@ -174,8 +185,7 @@ def _declared_container(declared: _Declared | None) -> tuple[tuple[_Declared, ..
174185
items = (tuple((argument, owner) for argument in arguments), None)
175186
else:
176187
items = (None, (arguments[0], owner))
177-
if len(_DECLARED) < 1024:
178-
_DECLARED[key] = (declared, items)
188+
_remember(_DECLARED, key, declared, items, 1024)
179189
return items
180190

181191

@@ -194,8 +204,7 @@ def _declared_mapping_value(declared: _Declared | None) -> _Declared | None:
194204
if get_origin(mapping) in _MAPPING_ORIGINS and len(get_args(mapping)) == 2
195205
else None
196206
)
197-
if len(_DECLARED) < 1024:
198-
_DECLARED[key] = (declared, value)
207+
_remember(_DECLARED, key, declared, value, 1024)
199208
return value
200209

201210

@@ -207,9 +216,17 @@ def _sole_member(annotation: object) -> object:
207216

208217
def _chooses(annotation: object) -> bool:
209218
"""Whether validation chose anywhere in *annotation* above the models it names: between two or more members of a
210-
union besides ``None``, or inside a named type alias, which is not opened."""
219+
union besides ``None``, or inside a named type alias (`typing` or `typing_extensions`, plain or parametrized),
220+
which is not opened."""
211221
members = [member for member in _alternatives(annotation) if member is not type(None)]
212-
if len(members) > 1 or any(hasattr(member, "__value__") for member in members):
222+
aliases = tuple(
223+
kind
224+
for module in ("typing", "typing_extensions")
225+
if isinstance(kind := getattr(sys.modules.get(module), "TypeAliasType", None), type)
226+
)
227+
if len(members) > 1 or any(
228+
isinstance(named, aliases) for member in members for named in (member, get_origin(member))
229+
):
213230
return True
214231
return any(_chooses(argument) for member in members for argument in get_args(member) if argument is not Ellipsis)
215232

@@ -233,7 +250,7 @@ def _item_declarations(declared: _Declared | None, count: int) -> list[_Declared
233250

234251
_ADAPTERS: dict[int, tuple[_Declared, Any]] = {}
235252
"""A pydantic `TypeAdapter` per declaration, or ``None`` where none can be built: building one costs a schema. At
236-
most 256 entries, each holding its declaration so the id stays its own."""
253+
most 256 entries (`_remember`)."""
237254

238255

239256
def _adapter(declared: _Declared) -> Any:
@@ -251,23 +268,23 @@ def _adapter(declared: _Declared) -> Any:
251268
adapter = adapter_class(annotation, config=getattr(owner, "model_config", None) or None)
252269
except (TypeError, RuntimeError, NameError):
253270
adapter = None
254-
if len(_ADAPTERS) < 256:
255-
_ADAPTERS[id(declared)] = (declared, adapter)
271+
_remember(_ADAPTERS, id(declared), declared, adapter, 256)
256272
return adapter
257273

258274

259275
_FieldReads = tuple[tuple[str, tuple[tuple[object, ...], ...], _Declared], ...]
260276

261-
_READS: dict[type, tuple[object, frozenset[str], _FieldReads]] = {}
262-
"""Per model class, with the validator they were read beside: the keys it declares and where each field is read.
277+
_READS: dict[type, tuple[object, frozenset[str], _FieldReads, _Declared | None]] = {}
278+
"""Per model class, with the validator they were read beside: the keys it declares, where each field is read, and
279+
what a typed `__pydantic_extra__` declares its extra values as.
263280
264281
Worked out per level of every payload, it was most of the walk: a list of 1000 nested models asked for it 1000 times.
265282
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.
266283
A rebuild replaces the validator, and with it what the class reads, so an entry answers only for the validator it
267284
was made with. At most 256 classes are kept, so models made on the fly cannot grow it without bound."""
268285

269286

270-
def _reads_of(model: Any) -> tuple[frozenset[str], _FieldReads]:
287+
def _reads_of(model: Any) -> tuple[frozenset[str], _FieldReads, _Declared | None]:
271288
validator = getattr(model, "__pydantic_validator__", None)
272289
known = _READS.get(model)
273290
if known is None or known[0] is not validator:
@@ -277,10 +294,29 @@ def _reads_of(model: Any) -> tuple[frozenset[str], _FieldReads]:
277294
for name, info in model.model_fields.items()
278295
if _may_hold_model(getattr(info, "annotation", None))
279296
)
280-
known = (validator, frozenset(_declared_keys(model)), fields)
297+
extras = _extra_values(model) if config.get("extra") == "allow" else None
298+
known = (validator, frozenset(_declared_keys(model)), fields, extras)
281299
if model in _READS or len(_READS) < 256:
282300
_READS[model] = known
283-
return known[1], known[2]
301+
return known[1], known[2], known[3]
302+
303+
304+
def _extra_values(model: Any) -> _Declared | None:
305+
"""The declaration a typed `__pydantic_extra__` gives the extra values of *model*, as written.
306+
307+
``None`` where it has none, and where it cannot be read: text, which pydantic read in the namespace of the frame
308+
that defined the model, where a name can mean another type than in its module, and, from 3.14, annotations that
309+
name what is defined only later, which reading them evaluates.
310+
"""
311+
try:
312+
for klass in getattr(model, "__mro__", ()):
313+
own = inspect.get_annotations(klass)
314+
if "__pydantic_extra__" in own:
315+
annotation = own["__pydantic_extra__"]
316+
return None if isinstance(annotation, str) else _declared_mapping_value((annotation, model))
317+
except NameError:
318+
return None
319+
return None
284320

285321

286322
def _followed(payload: object, path: tuple[object, ...]) -> tuple[bool, object]:
@@ -318,8 +354,8 @@ def _is_model(value: object) -> bool:
318354
_CONTAINERS = (dict, list, tuple, set, frozenset)
319355

320356
_REPLAYED = (0, -1)
321-
"""In the pairs a walk has seen, where no pair of ids can be: the walk is under a replay (`_replayed`), which reads no
322-
field where validation chose, since the validators it skipped could have steered the choice."""
357+
"""In the pairs a walk has seen, where no pair of ids can be: the walk is under a replay (`_replayed`), where a field
358+
whose type offers a choice cannot be read (`_steered`)."""
323359

324360

325361
class UncheckableDriftError(Exception):
@@ -346,16 +382,16 @@ def contract_drift(
346382
inside the JSON text it is) cannot be paired with a model built from it: a set, a resized list or an
347383
object wrapped into either whose built items are not all one model class, or whose raw item that class
348384
refuses on its own, dict keys coercion merged, a part of another shape than what was built, an iterable
349-
validation read up or left lazy. The payload is read as validation left it: a validator renaming keys,
350-
changing the payload in place, or reordering or rewriting a container's items without changing its size
351-
is not seen, and the walk reads those items by position.
385+
validation read up or left lazy, raw items no built item is left for that their declared type does not
386+
build again or that it declares through a choice. The payload is read as validation left it: a validator
387+
renaming keys, changing the payload in place, or reordering or rewriting a container's items without
388+
changing its size is not seen, and the walk reads those items by position.
352389
"""
353390
model = type(instance)
354-
declared, fields = _reads_of(model)
355-
if _REPLAYED in _seen:
356-
fields = tuple(field for field in fields if not _chooses(field[2][0]))
391+
declared, fields, extras = _reads_of(model)
392+
replayed = _REPLAYED in _seen
357393
if getattr(model, "__pydantic_root_model__", False):
358-
return _value_drift(payload, instance.root, path, _seen, fields[0][2]) if fields else []
394+
return _declared_drift(payload, instance.root, path, _seen, fields[0][2], replayed) if fields else []
359395
if not isinstance(payload, (dict, collections.abc.Mapping)):
360396
_refuse_if_key_hides(
361397
payload, instance, path, f"the payload holds a {type(payload).__name__} where a model was built"
@@ -367,14 +403,14 @@ def contract_drift(
367403
if getattr(model, "model_config", {}).get("extra") != "allow":
368404
drift += [f"{prefix}{key}" for key in payload if key not in declared]
369405
else:
370-
# extras typed through `__pydantic_extra__` are built too; untyped ones are the payload's own objects
371-
for key, built in (getattr(instance, "__pydantic_extra__", None) or {}).items():
372-
if key in payload:
373-
drift += _value_drift(payload[key], built, f"{prefix}{key}", seen)
406+
drift += _extras_drift(payload, instance, prefix, seen, extras, replayed)
374407
for name, sources, annotated in fields:
375408
found, raw = _raw_field(payload, sources)
376409
if not found:
377410
continue
411+
if replayed and _chooses(annotated[0]):
412+
drift += _steered(raw, f"{prefix}{name}")
413+
continue
378414
value = getattr(instance, name, None)
379415
# a scalar built from a scalar holds nothing to pair
380416
if (value is None or isinstance(value, (str, int, float, bytes))) and not isinstance(raw, _CONTAINERS):
@@ -383,6 +419,47 @@ def contract_drift(
383419
return drift
384420

385421

422+
def _extras_drift(
423+
payload: collections.abc.Mapping,
424+
instance: Any,
425+
prefix: str,
426+
seen: frozenset[tuple[int, int]],
427+
declared: _Declared | None,
428+
replayed: bool,
429+
) -> list[str]:
430+
"""Drift under the extras of a model that allows them: built through a typed `__pydantic_extra__` and walked as it
431+
*declared* them; untyped ones are the payload's own objects."""
432+
return [
433+
entry
434+
for key, built in (getattr(instance, "__pydantic_extra__", None) or {}).items()
435+
if key in payload
436+
for entry in _declared_drift(payload[key], built, f"{prefix}{key}", seen, declared, replayed)
437+
]
438+
439+
440+
def _declared_drift(
441+
raw: object,
442+
value: object,
443+
path: str,
444+
seen: frozenset[tuple[int, int]],
445+
declared: _Declared | None,
446+
replayed: bool,
447+
) -> list[str]:
448+
"""Drift under a value its model declared, a root's or an extra's; `_steered` under a replay where validation built
449+
it through a declared type that offers a choice, or one that cannot be read."""
450+
if replayed and value is not raw and (declared is None or _chooses(declared[0])):
451+
return _steered(raw, path)
452+
return _value_drift(raw, value, path, seen, declared)
453+
454+
455+
def _steered(raw: object, path: str) -> list[str]:
456+
"""Nothing to read under a part a replay built where its type offered a choice, which the validators the replay
457+
skipped could have steered; a refusal where a key could hide in it."""
458+
if _holds(raw, _is_mapping, read_text=True):
459+
raise UncheckableDriftError(path, "which of its declared types it became depends on validators not run again")
460+
return []
461+
462+
386463
def exactness_failure(pairs: Any, *, carrier: str) -> str | None:
387464
"""What an exact check of ``(payload, instance, path)`` pairs found, in the words a failure continues with.
388465
@@ -555,8 +632,8 @@ def _unpaired(
555632
could hide a key is validated again on its own by that class and walked beside what it becomes. Where validation
556633
kept no model at all, the raw items are validated again whole by the type the field *declared*, in its model's
557634
config, and what that builds is walked (`_replayed`). Anything else refuses with *reason*: items of mixed classes,
558-
an item that does not validate on its own, which needed its parent, and, where equal items *merged*, a class whose
559-
own `__eq__` could have merged an item of another class into it.
635+
an item that does not validate on its own, which needed its parent, where equal items *merged*, a class whose own
636+
`__eq__` could have merged an item of another class into it, and raw items no replay can tell the type of.
560637
"""
561638
if not _holds(raw, _is_mapping, read_text=True):
562639
return []
@@ -565,41 +642,43 @@ def _unpaired(
565642
if model is None or (merged and not _equal_only_within_its_class(model)):
566643
raise UncheckableDriftError(path, reason)
567644
return _revalidated(parts, model, path, seen, reason)
568-
return [] if declared is None else _replayed(parts, path, seen, reason, declared)
645+
return _replayed(parts, path, seen, reason, declared)
569646

570647

571648
def _replayed(
572649
parts: list[tuple[str, object]],
573650
path: str,
574651
seen: frozenset[tuple[int, int]],
575652
reason: str,
576-
declared: _Declared,
653+
declared: _Declared | None,
577654
) -> list[str]:
578655
"""Drift under raw items validation left without a model: validated again whole as the *declared* type, in Python
579656
mode as validation read them (`_replay_target`). All of them are replayed first, so a fixed tuple's positions
580657
see what validation saw; where pydantic refuses that, only the items that could hide a key. What that builds is
581658
paired item by item; a set it builds tells the one class they became.
582659
583-
The replay skips the field's own validators, so whatever they did to the payload is missing from it. Where that
584-
could have steered a choice (`_chooses`), nothing is read below it; where pydantic builds nothing from the items,
585-
or no sequence of as many, nothing is read at all, since its refusal says nothing about the payload.
660+
The replay skips the validators of the field itself, so whatever they did to the payload is missing from it, and
661+
it refuses with *reason* wherever that leaves the type of the items open: a declaration that offers a choice
662+
(`_chooses`) or none at all, items pydantic builds nothing from, or no sequence of as many.
586663
"""
587-
if _chooses(declared[0]):
588-
return []
664+
if declared is None or _chooses(declared[0]):
665+
raise UncheckableDriftError(path, reason)
589666
replayed = _replay_target(declared)
590667
adapter = _adapter(replayed)
591668
if adapter is None:
592-
return []
593-
rebuilt = _replay(adapter, parts)
669+
raise UncheckableDriftError(path, reason)
670+
origin = get_origin(replayed[0])
671+
kind = origin if origin in (tuple, collections.deque) else list
672+
rebuilt = _replay(adapter, parts, kind)
594673
if rebuilt is None:
595674
parts = [(label, part) for label, part in parts if _holds(part, _is_mapping, read_text=True)]
596-
rebuilt = _replay(adapter, parts)
675+
rebuilt = _replay(adapter, parts, kind)
597676
seen = seen | {_REPLAYED}
598677
if isinstance(rebuilt, collections.abc.Set):
599678
return _unpaired([part for _, part in parts], parts, rebuilt, path, seen, reason, merged=True)
600679
elements = _items_of(rebuilt)
601680
if elements is None or len(elements) != len(parts):
602-
return []
681+
raise UncheckableDriftError(path, reason)
603682
declarations = _item_declarations(replayed, len(elements))
604683
return [
605684
entry
@@ -608,10 +687,11 @@ def _replayed(
608687
]
609688

610689

611-
def _replay(adapter: Any, parts: list[tuple[str, object]]) -> object:
612-
"""What *adapter* builds from the raw items of *parts*, or ``None`` where pydantic refuses them."""
690+
def _replay(adapter: Any, parts: list[tuple[str, object]], kind: Callable[[list[object]], object]) -> object:
691+
"""What *adapter* builds from the raw items of *parts*, handed over as the *kind* of container a strict config
692+
takes for its type, or ``None`` where pydantic refuses them."""
613693
try:
614-
return adapter.validate_python([part for _, part in parts])
694+
return adapter.validate_python(kind([part for _, part in parts]))
615695
# a validation error, or a schema pydantic builds only on first use and then refuses
616696
except (ValueError, TypeError, RuntimeError, NameError):
617697
return None
@@ -635,8 +715,7 @@ def _replay_target(declared: _Declared) -> _Declared:
635715
):
636716
member = types.GenericAlias(list, get_args(member) or (Any,))
637717
target = (member, owner)
638-
if len(_DECLARED) < 1024:
639-
_DECLARED[key] = (declared, target)
718+
_remember(_DECLARED, key, declared, target, 1024)
640719
return target
641720

642721

‎assertpy2/assertpy.py‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -707,15 +707,15 @@ def assert_conforms(
707707
filtered list, an object wrapped into a list) are validated again by their model class when they all
708708
became one, which runs that class's validators once more for them, and JSON text is read as the original
709709
JSON input. Raw items a validator left no built item for (an emptied list) are checked against the type
710-
their field declares: validated again as it, without the field's own validators, so a validator that would
711-
have built them otherwise is not seen. Items pydantic will not build that type from, and anything below a
712-
union of two or more types besides ``None`` or below a named type alias, are not checked. What still
713-
cannot be paired (items of mixed classes in a set or a resized list, merged dict keys, a generator
714-
consumed during validation, a lazy ``Iterable``) fails with ``<path> cannot be checked`` and the reason.
715-
It reads the payload as validation left it: a validator that renames keys, changes the payload in place,
716-
or reorders or rewrites the items of a container without changing its size is not seen, and those items
717-
are read by position. A model inside a container of plain values (``list[Any]``) or inside a dataclass is
718-
not reached.
710+
their field declares: validated again as it, which runs the item model's validators but not the field's
711+
own, so a field validator that would have built them otherwise is not seen. What still cannot be paired
712+
(items of mixed classes in a set or a resized list, emptied items the declared type does not build or
713+
declares through a union of two or more types besides ``None`` or a named type alias, merged dict keys, a
714+
generator consumed during validation, a lazy ``Iterable``) fails with ``<path> cannot be checked`` and the
715+
reason. It reads the payload as validation left it: a validator that renames keys, changes the payload in
716+
place, or reorders or rewrites the items of a container without changing its size is not seen, and those
717+
items are read by position. A model inside a container of plain values (``list[Any]``) or inside a
718+
dataclass is not reached.
719719
720720
Args:
721721
val: the raw payload to validate (e.g. a decoded JSON response)

0 commit comments

Comments
 (0)