Source code for typing_validation.diagnosis

# SPDX-License-Identifier: LGPL-3.0-or-later

"""
Explaining a failure, after the fact.

The validators fail hard and say only *that* they failed. Everything a user reads
about *why* is produced here, by a slower second traversal of the same
``(val, t)``. This is the single most valuable consequence of the architecture:
because diagnostics are produced in exactly one place, there is exactly one
implementation of them — so the conformance obligation between mechanisms reduces
to *"do they agree on the boolean"*, which is a far smaller thing to police than
*"do they agree on the message"*.

And because this runs only on a failure, which is by definition exceptional, it
may be as slow, allocating and thorough as it likes — where its costs stop
mattering and its quality is the entire point.

The second traversal is sound only because validation is pure: the value handed
here is the value the validator saw, undisturbed.
"""

# This is, in effect, v1's validate — rich and allocating — demoted from the hot
# path to diagnostics duty.

import enum
from collections.abc import Collection, Iterator, Mapping
from dataclasses import dataclass, field
from typing import (
    NewType,
    TypeAliasType,
    Any,
    final,
    get_args,
    get_origin,
    Literal,
    NamedTuple,
    Tuple,
    Union,
)
from .nodes import TypeForm, TypeNode, node_for
from .plugins import registered_validator

__all__ = (
    "Detail",
    "Place",
    "Location",
    "ValidationFailure",
    "diagnose",
)


[docs] @final class Detail(enum.Enum): """Why one node of a failure tree failed.""" NOT_AN_INSTANCE = "not an instance" """The value is not an instance of the type, or of the type's origin.""" NOT_NONE = "not None" """The type is ``None`` and the value is not.""" NO_LITERAL = "no matching literal" """The value equals none of the literals, at its own type.""" WRONG_LENGTH = "wrong length" """A fixed-length tuple of the wrong size.""" MISSING_KEY = "missing required key" """A ``TypedDict`` required key that is absent.""" NON_STRING_KEY = "key is not a string" """A ``TypedDict`` key that is not a string.""" NOT_A_CLASS = "not a class" """``Type[T]`` was given something that is not a class.""" NOT_A_SUBCLASS = "not a subclass" """``Type[T]`` was given a class that does not derive from ``T``.""" NOT_A_NAMED_TUPLE = "not a named tuple" """Bare ``typing.NamedTuple`` was given something that is not one.""" NO_UNION_MEMBER = "no member matched" """Every member of a union failed. The causes say how each one did.""" PLUGIN_REJECTED = "rejected by plugin" """A plugin's own check said no, and offered no further detail.""" IN_COMPONENT = "a component failed" """ This node is fine in itself; something inside it is not. The causes say what, and their locations say where. """
[docs] @final class Place(enum.Enum): """What kind of position a failure occupies within its parent value.""" INDEX = "index" """A position in an ordered collection, which is addressable.""" POSITION = "position" """ A position in an *unordered* collection. Iteration order is **not stable across runs**, so this is a witness that something failed, not an address the user can go to. It must be reported as such rather than implying a location. """ KEY = "key" """A mapping key that is itself invalid.""" VALUE_AT = "value at key" """The value stored under a mapping key.""" FIELD = "field" """A named field of a ``TypedDict`` or named tuple.""" MEMBER = "union member" """One alternative of a union, all of which failed.""" WRAPPED = "wrapped type" """ What an alias, ``Annotated``, ``NewType`` or type variable resolves to. The wrapper is not transparent: it reports as itself and the cause reports what it stands for. """
[docs] @final @dataclass(frozen=True, slots=True) class Location: """Where a failure sits inside the value that contains it.""" place: Place """What kind of position this is.""" at: Any = None """The index, key or field name, where the place has one."""
[docs] @final @dataclass(frozen=True, slots=True, eq=False, repr=False) class ValidationFailure: """ One node of a failure tree: a value, the type it failed against, why, and what failed inside it. Nothing here recurses over ``causes``, and that is not fastidiousness. A failure tree is as deep as the *value*, so the generated ``__repr__`` and ``__eq__`` would raise :class:`RecursionError` on exactly the deeply nested values the rest of this library goes out of its way to handle — turning an ordinary failure into a stack overflow at the moment someone tries to look at it. So they are turned off and replaced with iterative equivalents. """ val: Any """The value that failed.""" t: Any """The type it failed against.""" detail: Detail """Why it failed.""" location: Location | None = None """ Where this sits within the value that contains it. :obj:`None` at the root. """ causes: tuple[ValidationFailure, ...] = () """The failures inside this one."""
[docs] def walk(self) -> Iterator[ValidationFailure]: """Every failure in the tree, this one first, depth-first.""" stack = [self] while stack: failure = stack.pop() yield failure stack.extend(reversed(failure.causes))
[docs] def depth(self) -> int: """How deep the tree goes.""" deepest = 0 stack = [(self, 1)] while stack: failure, level = stack.pop() deepest = max(deepest, level) stack.extend((cause, level + 1) for cause in failure.causes) return deepest
def __repr__(self) -> str: return ( f"<ValidationFailure {self.t!r}: {self.detail.value}, " f"{len(self.causes)} cause(s)>" ) def __str__(self) -> str: """ The failure as a message: what was expected, where, and in what. Three slots, of which the third is dropped when the first has already filled it:: expected int, got str '1975' at: value.year in: Movie The tree records everything; the message reports the one place worth looking at. Which place that is takes some finding — see :func:`_locate`. """ path, deepest = _locate(self) lines = [_says(deepest), f" at: value{path}"] # `in:` names the type the caller asked about. Line one has already # named it whenever the failure is at the root and the detail says # "expected <type>", so repeating it would add nothing. The details that # never name a type are exactly the ones that still need it. already_named = ( self.t is deepest.t and deepest.detail not in _NAMELESS_DETAILS ) if not already_named: lines.append(f" in: {_show(self.t)}") return "\n".join(lines)
# TODO: make the message's verbosity an option, once there is an option manager # to hang it on. The tree records every level and the message reports one place, # which is right by default and occasionally not: someone debugging a union that # should have matched wants to see what each member objected to, and someone # reading a deeply nested failure may want the containment chain rather than just # its endpoint. That is a switch, not a rewrite — the tree already holds all of # it, and this is the only place that decides what to show. _NAMELESS_DETAILS = (Detail.MISSING_KEY, Detail.NON_STRING_KEY) """ The details whose message never names the type it is about. Every other detail reads *"expected <type>"*, which is what makes ``in:`` redundant for a failure at the root. These two do not, so they keep it. """ def _show(t: Any, /) -> str: """ A type as the user wrote it, rather than as Python reprs it. ``int`` rather than ``<class 'int'>``, and ``UserId`` rather than ``module.UserId``: the message is read by someone who has the type in front of them, and its module is noise. """ if t is None or t is type(None): return "None" if isinstance(t, type): return t.__name__ name = getattr(t, "__name__", None) if name is not None and type(t) in (TypeAliasType, NewType): return str(name) return str(t).replace("typing.", "") def _plural(n: int, noun: str, /) -> str: return f"{n} {noun}" if n == 1 else f"{n} {noun}s" def _says(f: ValidationFailure, /) -> str: """What went wrong, in one line, at the place worth looking at.""" if f.detail is Detail.MISSING_KEY: return f"missing required key {f.location.at!r}" # type: ignore[union-attr] if f.detail is Detail.NON_STRING_KEY: return f"keys must be strings, got {type(f.val).__name__}" if f.detail is Detail.WRONG_LENGTH: return f"expected {_show(f.t)}, got {_plural(len(f.val), 'element')}" if f.detail is Detail.NO_LITERAL: # A literal's own type is the point, so naming the value's type as well # would be saying the same thing twice. This is the one detail that does # not end in a type-and-value pair, deliberately. return f"expected {_show(f.t)}, got {f.val!r}" if f.detail is Detail.NOT_A_CLASS: return f"expected a class, got {type(f.val).__name__} {f.val!r}" if f.detail is Detail.NOT_A_NAMED_TUPLE: return f"expected a named tuple, got {type(f.val).__name__} {f.val!r}" return f"expected {_show(f.t)}, got {type(f.val).__name__} {f.val!r}" def _step(f: ValidationFailure, /) -> str: """How to reach this failure from the value that contains it, in code.""" if f.location is None: return "" place, at = f.location.place, f.location.at if place is Place.INDEX: return f"[{at}]" if place is Place.POSITION: # Braces rather than brackets, because iteration order is not stable # across runs: this is a witness that something failed, not an address # the reader can go to. return f"{{{at}}}" if place is Place.KEY: return f".keys(){{{at!r}}}" if place is Place.VALUE_AT: return f"[{at!r}]" if place is Place.FIELD: return f".{at}" return "" def _locate(root: ValidationFailure, /) -> tuple[str, ValidationFailure]: """ The deepest place in the value the failure reaches, and what was expected *there*. Two rules, and the naive walk gets both wrong. **Through a union, follow the member that got furthest.** A union fails only when every member fails, so listing them all says nothing the union type has not already said — and most of them fail on sight. Five of ``JSON``'s six members reject a dict immediately; only ``dict[str, JSON]`` gets deep enough to be worth reading, and it is the one that finds the offending float. **Report the type recorded at the deepest step**, not whatever the walk bottoms out in. An alias is not transparent, so at ``value['a'][1]['b']`` the answer is *"expected JSON"* — not the six-member union that ``JSON`` happens to expand to, which is longer and says less. """ f, path, deepest = root, "", root while True: if f.detail is Detail.IN_COMPONENT and f.causes: child = f.causes[0] step = _step(child) f = child if step: path += step deepest = child elif f.detail is Detail.NO_UNION_MEMBER and f.causes: best = max(f.causes, key=lambda c: c.depth()) if best.depth() <= 1: # Every member failed on sight, so there is nothing deeper to # report and the union itself is the answer. return path, deepest f = best else: return path, deepest
[docs] @final class DiagnosisFailure(RuntimeError): """ Raised when a validator reports a failure that diagnosis cannot reproduce. This is a **library bug** — a mechanism has drifted from the catalogue — and it is reported as one. Diagnosis must never answer a reported failure with an implicit *"actually, it's fine"*: the failure is not swallowed, and no validation error is quietly downgraded to success. """
[docs] def diagnose(val: Any, t: Any, /) -> ValidationFailure: """ Explain why a value is not valid for a type. :raises DiagnosisFailure: if the value turns out to be valid after all, which means a mechanism has drifted from the specification. """ failure = _diagnose(val, t) if failure is None: raise DiagnosisFailure( f"Validation of {val!r} against {t!r} failed, but diagnosis could " f"not reproduce the failure. This is a bug in typing-validation; " f"please report it." ) return failure
@dataclass(slots=True) class _Frame: """ One node of the traversal, part-built. Mutable, unlike everything it produces: a tree is built from the bottom up and a frame accumulates its causes as its children report back. """ val: Any t: Any location: Location | None detail: Detail todo: list[tuple[Any, Any, Location]] = field(default_factory=list) causes: list[ValidationFailure] = field(default_factory=list) any_member_passed: bool = False is_union: bool = False settled: bool = False """Set once the outcome is known and no further children need examining.""" def _diagnose(val: Any, t: Any, /) -> ValidationFailure | None: """ The traversal proper, iterative for the same reason the interpreter is. The design called this recursive, on the grounds that it runs only on failures and may be as slow as it likes. Slow it may be; deep it may not. A failure tree is as deep as the *value*, so a list nested twenty thousand deep — which ``validate`` handles precisely because it uses a work stack — would fail here with ``RecursionError`` on the way out of a perfectly ordinary ``ValidationError``. Turning a validation failure into a stack overflow is exactly the dishonest error the work stack exists to prevent, so diagnosis gets one too. """ stack = [_expand(val, t, None)] result: ValidationFailure | None = None have_result = False while stack: frame = stack[-1] if have_result: have_result = False if frame.is_union: if result is None: # A member passed, so the union is satisfied and whatever # the validator saw is not here. frame.any_member_passed = True frame.settled = True else: frame.causes.append(result) elif result is not None: # Everything else is conjunctive: the first failing component is # the one to report, and looking further would only add noise. frame.causes.append(result) frame.settled = True if not frame.settled and frame.todo: child_val, child_t, location = frame.todo.pop(0) stack.append(_expand(child_val, child_t, location)) continue stack.pop() result = _finish(frame) have_result = True return result def _finish(frame: _Frame, /) -> ValidationFailure | None: if frame.is_union: if frame.any_member_passed: return None return ValidationFailure( frame.val, frame.t, Detail.NO_UNION_MEMBER, frame.location, tuple(frame.causes), ) if frame.detail is Detail.IN_COMPONENT and not frame.causes: return None return ValidationFailure( frame.val, frame.t, frame.detail, frame.location, tuple(frame.causes) ) def _ok(val: Any, t: Any, location: Location | None, /) -> _Frame: """A frame that fails only if one of its children does.""" return _Frame(val, t, location, Detail.IN_COMPONENT) def _bad( val: Any, t: Any, location: Location | None, detail: Detail, / ) -> _Frame: """A frame that has already failed, with no children to examine.""" frame = _Frame(val, t, location, detail) frame.settled = True return frame def _expand(val: Any, t: Any, location: Location | None, /) -> _Frame: """ Work out whether a value fails against a type, and what to look at next. This reads the node model rather than re-deriving the type's shape, which costs nothing here: nodes are interned, this runs only on failures, and ``inspect_type`` had to build the graph anyway. The interpreter is the one mechanism that may not do this. """ node = node_for(t) form = node.form children = node.children if form is TypeForm.ANY: return _ok(val, t, location) if form is TypeForm.NONE: if val is None: return _ok(val, t, location) return _bad(val, t, location, Detail.NOT_NONE) if form is TypeForm.CLASS or form is TypeForm.PROTOCOL: if isinstance(val, _isinstance_target(node)): return _ok(val, t, location) return _bad(val, t, location, Detail.NOT_AN_INSTANCE) if form in _WRAPPERS: frame = _ok(val, t, location) if children: frame.todo.append((val, children[0].t, Location(Place.WRAPPED))) return frame if form is TypeForm.UNION: frame = _ok(val, t, location) frame.is_union = True for i, child in enumerate(children): frame.todo.append((val, child.t, Location(Place.MEMBER, i))) return frame if form is TypeForm.LITERAL: val_t = type(val) for literal in get_args(t): if literal is val or (type(literal) is val_t and literal == val): return _ok(val, t, location) return _bad(val, t, location, Detail.NO_LITERAL) if form is TypeForm.COLLECTION: origin = get_origin(t) if not isinstance(val, origin): return _bad(val, t, location, Detail.NOT_AN_INSTANCE) frame = _ok(val, t, location) if children: # For an unordered collection the index is a position in iteration # order and is not stable across runs, so it is marked as such rather # than implying an addressable location. place = ( Place.INDEX if isinstance(val, (list, tuple, str, bytes)) else Place.POSITION ) for i, item in enumerate(val): frame.todo.append((item, children[0].t, Location(place, i))) return frame if form is TypeForm.MAPPING: origin = get_origin(t) if not isinstance(val, origin): return _bad(val, t, location, Detail.NOT_AN_INSTANCE) frame = _ok(val, t, location) if children: for key, item in val.items(): frame.todo.append( (key, children[0].t, Location(Place.KEY, key)) ) frame.todo.append( (item, children[1].t, Location(Place.VALUE_AT, key)) ) return frame if form is TypeForm.TUPLE: return _expand_tuple(val, t, location, node) if form is TypeForm.TYPED_DICT: return _expand_typed_dict(val, t, location, node) if form is TypeForm.NAMED_TUPLE: if not isinstance(val, t): return _bad(val, t, location, Detail.NOT_AN_INSTANCE) frame = _ok(val, t, location) labels = node.labels or () for name, child in zip(labels, children): frame.todo.append( (getattr(val, name), child.t, Location(Place.FIELD, name)) ) return frame if form is TypeForm.ANY_NAMED_TUPLE: if isinstance(val, tuple) and hasattr(type(val), "_fields"): return _ok(val, t, location) return _bad(val, t, location, Detail.NOT_A_NAMED_TUPLE) if form is TypeForm.TYPE_OF: return _expand_type_of(val, t, location, node) if form is TypeForm.ITERATOR: if isinstance(val, get_origin(t)): return _ok(val, t, location) return _bad(val, t, location, Detail.NOT_AN_INSTANCE) if form is TypeForm.MAYBE_ITEMS: origin = get_origin(t) if not isinstance(val, origin): return _bad(val, t, location, Detail.NOT_AN_INSTANCE) frame = _ok(val, t, location) if children and isinstance(val, Collection): for i, item in enumerate(val): frame.todo.append( (item, children[0].t, Location(Place.INDEX, i)) ) return frame if form is TypeForm.GENERIC_CLASS: if isinstance(val, get_origin(t)): return _ok(val, t, location) return _bad(val, t, location, Detail.NOT_AN_INSTANCE) if form is TypeForm.PLUGIN: return _expand_plugin(val, t, location, node) # An unsupported type is not a failure to explain: the value was never in # question, and something upstream should have raised long before here. return _ok(val, t, location) _WRAPPERS = frozenset( { TypeForm.ALIAS, TypeForm.ANNOTATED, TypeForm.NEW_TYPE, TypeForm.TYPE_VAR, } ) """ Forms that stand for another type without being transparent. Each reports as itself and delegates the verdict to what it wraps, so a failure says both — ``UserId``, and then the ``int`` it turned out not to be. """ def _isinstance_target(node: TypeNode, /) -> Any: t = node.t origin = get_origin(t) return t if origin is None else origin def _expand_tuple( val: Any, t: Any, location: Location | None, node: TypeNode, / ) -> _Frame: if not isinstance(val, tuple): return _bad(val, t, location, Detail.NOT_AN_INSTANCE) args = get_args(t) if not args: if t is Tuple or not val: return _ok(val, t, location) return _bad(val, t, location, Detail.WRONG_LENGTH) frame = _ok(val, t, location) if len(args) == 2 and args[1] is Ellipsis: for i, item in enumerate(val): frame.todo.append( (item, node.children[0].t, Location(Place.INDEX, i)) ) return frame if len(val) != len(args): return _bad(val, t, location, Detail.WRONG_LENGTH) for i, (item, child) in enumerate(zip(val, node.children)): frame.todo.append((item, child.t, Location(Place.INDEX, i))) return frame def _expand_typed_dict( val: Any, t: Any, location: Location | None, node: TypeNode, / ) -> _Frame: if not isinstance(val, Mapping): return _bad(val, t, location, Detail.NOT_AN_INSTANCE) for key in t.__required_keys__: if key not in val: return _bad( val, t, Location(Place.FIELD, key) if location is None else location, Detail.MISSING_KEY, ) fields = dict(zip(node.labels or (), node.children)) frame = _ok(val, t, location) for key, item in val.items(): if not isinstance(key, str): return _bad(val, t, location, Detail.NON_STRING_KEY) child = fields.get(key) if child is None: continue frame.todo.append((item, child.t, Location(Place.FIELD, key))) return frame def _expand_type_of( val: Any, t: Any, location: Location | None, node: TypeNode, / ) -> _Frame: if not isinstance(val, type): return _bad(val, t, location, Detail.NOT_A_CLASS) args = get_args(t) if not args: return _ok(val, t, location) (arg,) = args if arg is Any: return _ok(val, t, location) target = arg.__args__ if type(arg) is Union else arg # type: ignore[comparison-overlap] if issubclass(val, target): return _ok(val, t, location) return _bad(val, t, location, Detail.NOT_A_SUBCLASS) def _expand_plugin( val: Any, t: Any, location: Location | None, node: TypeNode, / ) -> _Frame: origin = get_origin(t) if not isinstance(val, origin): return _bad(val, t, location, Detail.NOT_AN_INSTANCE) check = getattr(origin, "__validate__", None) if check is None: check = registered_validator(origin) if check is not None and check(val, get_args(t)): return _ok(val, t, location) # A plugin's obligation is a boolean, so this is all there is to say unless # it chooses to say more. Diagnostics are an optional thing a plugin may # supply, not a toll for supporting one type. return _bad(val, t, location, Detail.PLUGIN_REJECTED)