codekingpro/portable-devtools
114k
1"""2Validation errors, and some surrounding helpers.3"""4from __future__ import annotations5 6from collections import defaultdict, deque7from pprint import pformat8from textwrap import dedent, indent9from typing import TYPE_CHECKING, Any, ClassVar10import heapq11import re12import warnings13 14from attrs import define15from referencing.exceptions import Unresolvable as _Unresolvable16 17from jsonschema import _utils18 19if TYPE_CHECKING:20 from collections.abc import Iterable, Mapping, MutableMapping, Sequence21 22 from jsonschema import _types23 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 _RefResolutionError47 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 = message82 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__ = cause86 self.validator = validator87 self.validator_value = validator_value88 self.instance = instance89 self.schema = schema90 self.parent = parent91 self._type_checker = type_checker92 93 for error in context:94 error.parent = self95 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.message105 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 @classmethod129 def create_from(cls, other: _Error):130 return cls(**other._contents())131 132 @property133 def absolute_path(self) -> Sequence[str | int]:134 parent = self.parent135 if parent is None:136 return self.relative_path137 138 path = deque(self.relative_path)139 path.extendleft(reversed(parent.absolute_path))140 return path141 142 @property143 def absolute_schema_path(self) -> Sequence[str | int]:144 parent = self.parent145 if parent is None:146 return self.relative_schema_path147 148 path = deque(self.relative_schema_path)149 path.extendleft(reversed(parent.absolute_schema_path))150 return path151 152 @property153 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 += "." + elem160 else:161 escaped_elem = elem.replace("\\", "\\\\").replace("'", r"\'")162 path += "['" + escaped_elem + "']"163 return path164 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_checker172 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 happens187 expected = self.schema["type"] # type: ignore[index]188 except (KeyError, TypeError):189 return False190 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 expected197 )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: PLW1641220 """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: Exception231 232 def __eq__(self, other):233 if self.__class__ is not other.__class__:234 return NotImplemented # pragma: no cover -- uncovered but deprecated # noqa: E501235 return self._cause == other._cause236 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: E501242 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._wrapped248 elif other.__class__ is self._wrapped.__class__:249 return self._wrapped == other250 return NotImplemented251 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 = type272 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 = type284 self.instance = instance285 self.schema = schema286 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 = message309 self.cause = self.__cause__ = cause310 311 def __str__(self):312 return self.message313 314 315class ErrorTree:316 """317 ErrorTrees make it easier to check which validations failed.318 """319 320 _instance = _unset321 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 = self328 for element in error.path:329 container = container[element]330 container.errors[error.validator] = error331 332 container._instance = error.instance333 334 def __contains__(self, index: str | int):335 """336 Check whether ``instance[index]`` has any errors.337 """338 return index in self._contents339 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 corresponds345 to and is not known by this tree, whatever error would be raised346 by ``instance.__getitem__`` will be propagated (usually this is347 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.0358 359 Setting items on an `ErrorTree` is deprecated without replacement.360 To populate a tree, provide all of its sub-errors when you361 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_errors381 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 @property388 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_errors394 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 be403 "weak". If there are two errors at the same level of the404 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 be407 superseded by other same-level validation errors.408 409 strong (set):410 a collection of validation keywords to consider to be411 "strong"412 413 """414 415 def relevance(error):416 validator = error.validator417 return ( # prefer errors which are ...418 -len(error.path), # 'deeper' and thereby more specific419 error.path, # earlier (for sibling errors)420 validator not in weak, # for a non-low-priority keyword421 validator in strong, # for a high priority keyword422 not error._matches_type(), # at least match the instance's type423 ) # otherwise we'll treat them the same424 425 return relevance426 427 428relevance = by_relevance()429"""430A key function (e.g. to use with `sorted`) which sorts errors by relevance.431 432Example:433 434.. code:: python435 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 which445 `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`, the449 *opposite* assumption is made -- i.e. the deepest error is picked,450 since these keywords only need to match once, and any other errors451 may not be relevant.452 453 Arguments:454 errors (collections.abc.Iterable):455 456 the errors to select from. Do not provide a mixture of457 errors from different validation attempts (i.e. from458 different instances or schemas), since it won't produce459 sensical output.460 461 key (collections.abc.Callable):462 463 the key to use when sorting errors. See `relevance` and464 transitively `by_relevance` for more details (the default is465 to sort with the defaults of that function). Changing the466 default is only useful if you want to change the function467 that rates errors but still want the error context descent468 done by this function.469 470 Returns:471 the best matching error, or ``None`` if the iterable was empty472 473 .. note::474 475 This function is a heuristic. Its return value may change for a given476 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 return482 483 while best.context:484 # Calculate the minimum via nsmallest, because we don't recurse if485 # 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: PLR2004488 return best489 best = smallest[0]490 return best491 