1"""
2Validation errors, and some surrounding helpers.
3"""
4from __future__ import annotations
5
6from collections import defaultdict, deque
7from pprint import pformat
8from textwrap import dedent, indent
9from typing import TYPE_CHECKING, Any, ClassVar
10import heapq
11import re
12import warnings
13
14from attrs import define
15from referencing.exceptions import Unresolvable as _Unresolvable
16
17from jsonschema import _utils
18
19if TYPE_CHECKING:
20 from collections.abc import Iterable, Mapping, MutableMapping, Sequence
21
22 from jsonschema import _types
23
24WEAK_MATCHES: frozenset[str] = frozenset(["anyOf", "oneOf"])
25STRONG_MATCHES: frozenset[str] = frozenset()
26
27_JSON_PATH_COMPATIBLE_PROPERTY_PATTERN = re.compile("^[a-zA-Z][a-zA-Z0-9_]*$")
28
29_unset = _utils.Unset()
30
31
32def _pretty(thing: Any, prefix: str):
33 """
34 Format something for an error message as prettily as we currently can.
35 """
36 return indent(pformat(thing, width=72, sort_dicts=False), prefix).lstrip()
37
38
39def __getattr__(name):
40 if name == "RefResolutionError":
41 warnings.warn(
42 _RefResolutionError._DEPRECATION_MESSAGE,
43 DeprecationWarning,
44 stacklevel=2,
45 )
46 return _RefResolutionError
47 raise AttributeError(f"module {__name__} has no attribute {name}")
48
49
50class _Error(Exception):
51
52 _word_for_schema_in_error_message: ClassVar[str]
53 _word_for_instance_in_error_message: ClassVar[str]
54
55 def __init__(
56 self,
57 message: str,
58 validator: str = _unset, # type: ignore[assignment]
59 path: Iterable[str | int] = (),
60 cause: Exception | None = None,
61 context=(),
62 validator_value: Any = _unset,
63 instance: Any = _unset,
64 schema: Mapping[str, Any] | bool = _unset, # type: ignore[assignment]
65 schema_path: Iterable[str | int] = (),
66 parent: _Error | None = None,
67 type_checker: _types.TypeChecker = _unset, # type: ignore[assignment]
68 ) -> None:
69 super().__init__(
70 message,
71 validator,
72 path,
73 cause,
74 context,
75 validator_value,
76 instance,
77 schema,
78 schema_path,
79 parent,
80 )
81 self.message = message
82 self.path = self.relative_path = deque(path)
83 self.schema_path = self.relative_schema_path = deque(schema_path)
84 self.context = list(context)
85 self.cause = self.__cause__ = cause
86 self.validator = validator
87 self.validator_value = validator_value
88 self.instance = instance
89 self.schema = schema
90 self.parent = parent
91 self._type_checker = type_checker
92
93 for error in context:
94 error.parent = self
95
96 def __repr__(self) -> str:
97 return f"<{self.__class__.__name__}: {self.message!r}>"
98
99 def __str__(self) -> str:
100 essential_for_verbose = (
101 self.validator, self.validator_value, self.instance, self.schema,
102 )
103 if any(m is _unset for m in essential_for_verbose):
104 return self.message
105
106 schema_path = _utils.format_as_index(
107 container=self._word_for_schema_in_error_message,
108 indices=list(self.relative_schema_path)[:-1],
109 )
110 instance_path = _utils.format_as_index(
111 container=self._word_for_instance_in_error_message,
112 indices=self.relative_path,
113 )
114 prefix = 16 * " "
115
116 return dedent(
117 f"""\
118 {self.message}
119
120 Failed validating {self.validator!r} in {schema_path}:
121 {_pretty(self.schema, prefix=prefix)}
122
123 On {instance_path}:
124 {_pretty(self.instance, prefix=prefix)}
125 """.rstrip(),
126 )
127
128 @classmethod
129 def create_from(cls, other: _Error):
130 return cls(**other._contents())
131
132 @property
133 def absolute_path(self) -> Sequence[str | int]:
134 parent = self.parent
135 if parent is None:
136 return self.relative_path
137
138 path = deque(self.relative_path)
139 path.extendleft(reversed(parent.absolute_path))
140 return path
141
142 @property
143 def absolute_schema_path(self) -> Sequence[str | int]:
144 parent = self.parent
145 if parent is None:
146 return self.relative_schema_path
147
148 path = deque(self.relative_schema_path)
149 path.extendleft(reversed(parent.absolute_schema_path))
150 return path
151
152 @property
153 def json_path(self) -> str:
154 path = "$"
155 for elem in self.absolute_path:
156 if isinstance(elem, int):
157 path += "[" + str(elem) + "]"
158 elif _JSON_PATH_COMPATIBLE_PROPERTY_PATTERN.match(elem):
159 path += "." + elem
160 else:
161 escaped_elem = elem.replace("\\", "\\\\").replace("'", r"\'")
162 path += "['" + escaped_elem + "']"
163 return path
164
165 def _set(
166 self,
167 type_checker: _types.TypeChecker | None = None,
168 **kwargs: Any,
169 ) -> None:
170 if type_checker is not None and self._type_checker is _unset:
171 self._type_checker = type_checker
172
173 for k, v in kwargs.items():
174 if getattr(self, k) is _unset:
175 setattr(self, k, v)
176
177 def _contents(self):
178 attrs = (
179 "message", "cause", "context", "validator", "validator_value",
180 "path", "schema_path", "instance", "schema", "parent",
181 )
182 return {attr: getattr(self, attr) for attr in attrs}
183
184 def _matches_type(self) -> bool:
185 try:
186 # We ignore this as we want to simply crash if this happens
187 expected = self.schema["type"] # type: ignore[index]
188 except (KeyError, TypeError):
189 return False
190
191 if isinstance(expected, str):
192 return self._type_checker.is_type(self.instance, expected)
193
194 return any(
195 self._type_checker.is_type(self.instance, expected_type)
196 for expected_type in expected
197 )
198
199
200class ValidationError(_Error):
201 """
202 An instance was invalid under a provided schema.
203 """
204
205 _word_for_schema_in_error_message = "schema"
206 _word_for_instance_in_error_message = "instance"
207
208
209class SchemaError(_Error):
210 """
211 A schema was invalid under its corresponding metaschema.
212 """
213
214 _word_for_schema_in_error_message = "metaschema"
215 _word_for_instance_in_error_message = "schema"
216
217
218@define(slots=False)
219class _RefResolutionError(Exception): # noqa: PLW1641
220 """
221 A ref could not be resolved.
222 """
223
224 _DEPRECATION_MESSAGE = (
225 "jsonschema.exceptions.RefResolutionError is deprecated as of version "
226 "4.18.0. If you wish to catch potential reference resolution errors, "
227 "directly catch referencing.exceptions.Unresolvable."
228 )
229
230 _cause: Exception
231
232 def __eq__(self, other):
233 if self.__class__ is not other.__class__:
234 return NotImplemented # pragma: no cover -- uncovered but deprecated # noqa: E501
235 return self._cause == other._cause
236
237 def __str__(self) -> str:
238 return str(self._cause)
239
240
241class _WrappedReferencingError(_RefResolutionError, _Unresolvable): # pragma: no cover -- partially uncovered but to be removed # noqa: E501
242 def __init__(self, cause: _Unresolvable):
243 object.__setattr__(self, "_wrapped", cause)
244
245 def __eq__(self, other):
246 if other.__class__ is self.__class__:
247 return self._wrapped == other._wrapped
248 elif other.__class__ is self._wrapped.__class__:
249 return self._wrapped == other
250 return NotImplemented
251
252 def __getattr__(self, attr):
253 return getattr(self._wrapped, attr)
254
255 def __hash__(self):
256 return hash(self._wrapped)
257
258 def __repr__(self):
259 return f"<WrappedReferencingError {self._wrapped!r}>"
260
261 def __str__(self):
262 return f"{self._wrapped.__class__.__name__}: {self._wrapped}"
263
264
265class UndefinedTypeCheck(Exception):
266 """
267 A type checker was asked to check a type it did not have registered.
268 """
269
270 def __init__(self, type: str) -> None:
271 self.type = type
272
273 def __str__(self) -> str:
274 return f"Type {self.type!r} is unknown to this type checker"
275
276
277class UnknownType(Exception):
278 """
279 A validator was asked to validate an instance against an unknown type.
280 """
281
282 def __init__(self, type, instance, schema):
283 self.type = type
284 self.instance = instance
285 self.schema = schema
286
287 def __str__(self):
288 prefix = 16 * " "
289
290 return dedent(
291 f"""\
292 Unknown type {self.type!r} for validator with schema:
293 {_pretty(self.schema, prefix=prefix)}
294
295 While checking instance:
296 {_pretty(self.instance, prefix=prefix)}
297 """.rstrip(),
298 )
299
300
301class FormatError(Exception):
302 """
303 Validating a format failed.
304 """
305
306 def __init__(self, message, cause=None):
307 super().__init__(message, cause)
308 self.message = message
309 self.cause = self.__cause__ = cause
310
311 def __str__(self):
312 return self.message
313
314
315class ErrorTree:
316 """
317 ErrorTrees make it easier to check which validations failed.
318 """
319
320 _instance = _unset
321
322 def __init__(self, errors: Iterable[ValidationError] = ()):
323 self.errors: MutableMapping[str, ValidationError] = {}
324 self._contents: Mapping[str, ErrorTree] = defaultdict(self.__class__)
325
326 for error in errors:
327 container = self
328 for element in error.path:
329 container = container[element]
330 container.errors[error.validator] = error
331
332 container._instance = error.instance
333
334 def __contains__(self, index: str | int):
335 """
336 Check whether ``instance[index]`` has any errors.
337 """
338 return index in self._contents
339
340 def __getitem__(self, index):
341 """
342 Retrieve the child tree one level down at the given ``index``.
343
344 If the index is not in the instance that this tree corresponds
345 to and is not known by this tree, whatever error would be raised
346 by ``instance.__getitem__`` will be propagated (usually this is
347 some subclass of `LookupError`.
348 """
349 if self._instance is not _unset and index not in self:
350 self._instance[index]
351 return self._contents[index]
352
353 def __setitem__(self, index: str | int, value: ErrorTree):
354 """
355 Add an error to the tree at the given ``index``.
356
357 .. deprecated:: v4.20.0
358
359 Setting items on an `ErrorTree` is deprecated without replacement.
360 To populate a tree, provide all of its sub-errors when you
361 construct the tree.
362 """
363 warnings.warn(
364 "ErrorTree.__setitem__ is deprecated without replacement.",
365 DeprecationWarning,
366 stacklevel=2,
367 )
368 self._contents[index] = value # type: ignore[index]
369
370 def __iter__(self):
371 """
372 Iterate (non-recursively) over the indices in the instance with errors.
373 """
374 return iter(self._contents)
375
376 def __len__(self):
377 """
378 Return the `total_errors`.
379 """
380 return self.total_errors
381
382 def __repr__(self):
383 total = len(self)
384 errors = "error" if total == 1 else "errors"
385 return f"<{self.__class__.__name__} ({total} total {errors})>"
386
387 @property
388 def total_errors(self):
389 """
390 The total number of errors in the entire tree, including children.
391 """
392 child_errors = sum(len(tree) for _, tree in self._contents.items())
393 return len(self.errors) + child_errors
394
395
396def by_relevance(weak=WEAK_MATCHES, strong=STRONG_MATCHES):
397 """
398 Create a key function that can be used to sort errors by relevance.
399
400 Arguments:
401 weak (set):
402 a collection of validation keywords to consider to be
403 "weak". If there are two errors at the same level of the
404 instance and one is in the set of weak validation keywords,
405 the other error will take priority. By default, :kw:`anyOf`
406 and :kw:`oneOf` are considered weak keywords and will be
407 superseded by other same-level validation errors.
408
409 strong (set):
410 a collection of validation keywords to consider to be
411 "strong"
412
413 """
414
415 def relevance(error):
416 validator = error.validator
417 return ( # prefer errors which are ...
418 -len(error.path), # 'deeper' and thereby more specific
419 error.path, # earlier (for sibling errors)
420 validator not in weak, # for a non-low-priority keyword
421 validator in strong, # for a high priority keyword
422 not error._matches_type(), # at least match the instance's type
423 ) # otherwise we'll treat them the same
424
425 return relevance
426
427
428relevance = by_relevance()
429"""
430A key function (e.g. to use with `sorted`) which sorts errors by relevance.
431
432Example:
433
434.. code:: python
435
436 sorted(validator.iter_errors(12), key=jsonschema.exceptions.relevance)
437"""
438
439
440def best_match(errors, key=relevance):
441 """
442 Try to find an error that appears to be the best match among given errors.
443
444 In general, errors that are higher up in the instance (i.e. for which
445 `ValidationError.path` is shorter) are considered better matches,
446 since they indicate "more" is wrong with the instance.
447
448 If the resulting match is either :kw:`oneOf` or :kw:`anyOf`, the
449 *opposite* assumption is made -- i.e. the deepest error is picked,
450 since these keywords only need to match once, and any other errors
451 may not be relevant.
452
453 Arguments:
454 errors (collections.abc.Iterable):
455
456 the errors to select from. Do not provide a mixture of
457 errors from different validation attempts (i.e. from
458 different instances or schemas), since it won't produce
459 sensical output.
460
461 key (collections.abc.Callable):
462
463 the key to use when sorting errors. See `relevance` and
464 transitively `by_relevance` for more details (the default is
465 to sort with the defaults of that function). Changing the
466 default is only useful if you want to change the function
467 that rates errors but still want the error context descent
468 done by this function.
469
470 Returns:
471 the best matching error, or ``None`` if the iterable was empty
472
473 .. note::
474
475 This function is a heuristic. Its return value may change for a given
476 set of inputs from version to version if better heuristics are added.
477
478 """
479 best = max(errors, key=key, default=None)
480 if best is None:
481 return
482
483 while best.context:
484 # Calculate the minimum via nsmallest, because we don't recurse if
485 # all nested errors have the same relevance (i.e. if min == max == all)
486 smallest = heapq.nsmallest(2, best.context, key=key)
487 if len(smallest) == 2 and key(smallest[0]) == key(smallest[1]): # noqa: PLR2004
488 return best
489 best = smallest[0]
490 return best