# SPDX-License-Identifier: MIT """ Commonly useful validators. """ import operator import re from contextlib import contextmanager from re import Pattern from ._config import get_run_validators, set_run_validators from ._make import _AndValidator, and_, attrib, attrs from .converters import default_if_none from .exceptions import NotCallableError __all__ = [ "and_", "deep_iterable", "deep_mapping", "disabled", "ge", "get_disabled", "gt", "in_", "instance_of", "is_callable", "le", "lt", "matches_re", "max_len", "min_len", "not_", "optional", "or_", "set_disabled", ] def set_disabled(disabled): """ Globally disable or enable running validators. By default, they are run. Args: disabled (bool): If `True`, disable running all validators. .. warning:: This function is not thread-safe! .. versionadded:: 21.3.0 """ set_run_validators(not disabled) def get_disabled(): """ Return a bool indicating whether validators are currently disabled or not. Returns: bool:`True` if validators are currently disabled. .. versionadded:: 21.3.0 """ return not get_run_validators() @contextmanager def disabled(): """ Context manager that disables running validators within its context. .. warning:: This context manager is not thread-safe! .. versionadded:: 21.3.0 """ set_run_validators(False) try: yield finally: set_run_validators(True) @attrs(repr=False, slots=True, unsafe_hash=True) class _InstanceOfValidator: type = attrib() def __call__(self, inst, attr, value): """ We use a callable class to be able to change the ``__repr__``. """ if not isinstance(value, self.type): msg = f"'{attr.name}' must be {self.type!r} (got {value!r} that is a {value.__class__!r})." raise TypeError( msg, attr, self.type, value, ) def __repr__(self): return f"" def instance_of(type): """ A validator that raises a `TypeError` if the initializer is called with a wrong type for this particular attribute (checks are performed using `isinstance` therefore it's also valid to pass a tuple of types). Args: type (type | tuple[type]): The type to check for. Raises: TypeError: With a human readable error message, the attribute (of type `attrs.Attribute`), the expected type, and the value it got. """ return _InstanceOfValidator(type) @attrs(repr=False, frozen=True, slots=True) class _MatchesReValidator: pattern = attrib() match_func = attrib() def __call__(self, inst, attr, value): """ We use a callable class to be able to change the ``__repr__``. """ if not self.match_func(value): msg = f"'{attr.name}' must match regex {self.pattern.pattern!r} ({value!r} doesn't)" raise ValueError( msg, attr, self.pattern, value, ) def __repr__(self): return f"" def matches_re(regex, flags=0, func=None): r""" A validator that raises `ValueError` if the initializer is called with a string that doesn't match *regex*. Args: regex (str, re.Pattern): A regex string or precompiled pattern to match against flags (int): Flags that will be passed to the underlying re function (default 0) func (typing.Callable): Which underlying `re` function to call. Valid options are `re.fullmatch`, `re.search`, and `re.match`; the default `None` means `re.fullmatch`. For performance reasons, the pattern is always precompiled using `re.compile`. .. versionadded:: 19.2.0 .. versionchanged:: 21.3.0 *regex* can be a pre-compiled pattern. """ valid_funcs = (re.fullmatch, None, re.search, re.match) if func not in valid_funcs: msg = "'func' must be one of {}.".format( ", ".join( sorted(e and e.__name__ or "None" for e in set(valid_funcs)) ) ) raise ValueError(msg) if isinstance(regex, Pattern): if flags: msg = "'flags' can only be used with a string pattern; pass flags to re.compile() instead" raise TypeError(msg) pattern = regex else: pattern = re.compile(regex, flags) if func is re.match: match_func = pattern.match elif func is re.search: match_func = pattern.search else: match_func = pattern.fullmatch return _MatchesReValidator(pattern, match_func) @attrs(repr=False, slots=True, unsafe_hash=True) class _OptionalValidator: validator = attrib() def __call__(self, inst, attr, value): if value is None: return self.validator(inst, attr, value) def __repr__(self): return f"" def optional(validator): """ A validator that makes an attribute optional. An optional attribute is one which can be set to `None` in addition to satisfying the requirements of the sub-validator. Args: validator (typing.Callable | tuple[typing.Callable] | list[typing.Callable]): A validator (or validators) that is used for non-`None` values. .. versionadded:: 15.1.0 .. versionchanged:: 17.1.0 *validator* can be a list of validators. .. versionchanged:: 23.1.0 *validator* can also be a tuple of validators. """ if isinstance(validator, (list, tuple)): return _OptionalValidator(_AndValidator(validator)) return _OptionalValidator(validator) @attrs(repr=False, slots=True, unsafe_hash=True) class _InValidator: options = attrib() _original_options = attrib(hash=False) def __call__(self, inst, attr, value): try: in_options = value in self.options except TypeError: # e.g. `1 in "abc"` in_options = False if not in_options: msg = f"'{attr.name}' must be in {self._original_options!r} (got {value!r})" raise ValueError( msg, attr, self._original_options, value, ) def __repr__(self): return f"" def in_(options): """ A validator that raises a `ValueError` if the initializer is called with a value that does not belong in the *options* provided. The check is performed using ``value in options``, so *options* has to support that operation. To keep the validator hashable, dicts, lists, and sets are transparently transformed into a `tuple`. Args: options: Allowed options. Raises: ValueError: With a human readable error message, the attribute (of type `attrs.Attribute`), the expected options, and the value it got. .. versionadded:: 17.1.0 .. versionchanged:: 22.1.0 The ValueError was incomplete until now and only contained the human readable error message. Now it contains all the information that has been promised since 17.1.0. .. versionchanged:: 24.1.0 *options* that are a list, dict, or a set are now transformed into a tuple to keep the validator hashable. """ repr_options = options if isinstance(options, (list, dict, set)): options = tuple(options) return _InValidator(options, repr_options) @attrs(repr=False, slots=False, unsafe_hash=True) class _IsCallableValidator: def __call__(self, inst, attr, value): """ We use a callable class to be able to change the ``__repr__``. """ if not callable(value): message = ( "'{name}' must be callable " "(got {value!r} that is a {actual!r})." ) raise NotCallableError( msg=message.format( name=attr.name, value=value, actual=value.__class__ ), value=value, ) def __repr__(self): return "" def is_callable(): """ A validator that raises a `attrs.exceptions.NotCallableError` if the initializer is called with a value for this particular attribute that is not callable. .. versionadded:: 19.1.0 Raises: attrs.exceptions.NotCallableError: With a human readable error message containing the attribute (`attrs.Attribute`) name, and the value it got. """ return _IsCallableValidator() @attrs(repr=False, slots=True, unsafe_hash=True) class _DeepIterable: member_validator = attrib(validator=is_callable()) iterable_validator = attrib( default=None, validator=optional(is_callable()) ) def __call__(self, inst, attr, value): """ We use a callable class to be able to change the ``__repr__``. """ if self.iterable_validator is not None: self.iterable_validator(inst, attr, value) for member in value: self.member_validator(inst, attr, member) def __repr__(self): iterable_identifier = ( "" if self.iterable_validator is None else f" {self.iterable_validator!r}" ) return ( f"" ) def deep_iterable(member_validator, iterable_validator=None): """ A validator that performs deep validation of an iterable. Args: member_validator: Validator to apply to iterable members. iterable_validator: Validator to apply to iterable itself (optional). Raises TypeError: if any sub-validators fail .. versionadded:: 19.1.0 """ if isinstance(member_validator, (list, tuple)): member_validator = and_(*member_validator) return _DeepIterable(member_validator, iterable_validator) @attrs(repr=False, slots=True, unsafe_hash=True) class _DeepMapping: key_validator = attrib(validator=is_callable()) value_validator = attrib(validator=is_callable()) mapping_validator = attrib(default=None, validator=optional(is_callable())) def __call__(self, inst, attr, value): """ We use a callable class to be able to change the ``__repr__``. """ if self.mapping_validator is not None: self.mapping_validator(inst, attr, value) for key in value: self.key_validator(inst, attr, key) self.value_validator(inst, attr, value[key]) def __repr__(self): return f"" def deep_mapping(key_validator, value_validator, mapping_validator=None): """ A validator that performs deep validation of a dictionary. Args: key_validator: Validator to apply to dictionary keys. value_validator: Validator to apply to dictionary values. mapping_validator: Validator to apply to top-level mapping attribute (optional). .. versionadded:: 19.1.0 Raises: TypeError: if any sub-validators fail """ return _DeepMapping(key_validator, value_validator, mapping_validator) @attrs(repr=False, frozen=True, slots=True) class _NumberValidator: bound = attrib() compare_op = attrib() compare_func = attrib() def __call__(self, inst, attr, value): """ We use a callable class to be able to change the ``__repr__``. """ if not self.compare_func(value, self.bound): msg = f"'{attr.name}' must be {self.compare_op} {self.bound}: {value}" raise ValueError(msg) def __repr__(self): return f"" def lt(val): """ A validator that raises `ValueError` if the initializer is called with a number larger or equal to *val*. The validator uses `operator.lt` to compare the values. Args: val: Exclusive upper bound for values. .. versionadded:: 21.3.0 """ return _NumberValidator(val, "<", operator.lt) def le(val): """ A validator that raises `ValueError` if the initializer is called with a number greater than *val*. The validator uses `operator.le` to compare the values. Args: val: Inclusive upper bound for values. .. versionadded:: 21.3.0 """ return _NumberValidator(val, "<=", operator.le) def ge(val): """ A validator that raises `ValueError` if the initializer is called with a number smaller than *val*. The validator uses `operator.ge` to compare the values. Args: val: Inclusive lower bound for values .. versionadded:: 21.3.0 """ return _NumberValidator(val, ">=", operator.ge) def gt(val): """ A validator that raises `ValueError` if the initializer is called with a number smaller or equal to *val*. The validator uses `operator.ge` to compare the values. Args: val: Exclusive lower bound for values .. versionadded:: 21.3.0 """ return _NumberValidator(val, ">", operator.gt) @attrs(repr=False, frozen=True, slots=True) class _MaxLengthValidator: max_length = attrib() def __call__(self, inst, attr, value): """ We use a callable class to be able to change the ``__repr__``. """ if len(value) > self.max_length: msg = f"Length of '{attr.name}' must be <= {self.max_length}: {len(value)}" raise ValueError(msg) def __repr__(self): return f"" def max_len(length): """ A validator that raises `ValueError` if the initializer is called with a string or iterable that is longer than *length*. Args: length (int): Maximum length of the string or iterable .. versionadded:: 21.3.0 """ return _MaxLengthValidator(length) @attrs(repr=False, frozen=True, slots=True) class _MinLengthValidator: min_length = attrib() def __call__(self, inst, attr, value): """ We use a callable class to be able to change the ``__repr__``. """ if len(value) < self.min_length: msg = f"Length of '{attr.name}' must be >= {self.min_length}: {len(value)}" raise ValueError(msg) def __repr__(self): return f"" def min_len(length): """ A validator that raises `ValueError` if the initializer is called with a string or iterable that is shorter than *length*. Args: length (int): Minimum length of the string or iterable .. versionadded:: 22.1.0 """ return _MinLengthValidator(length) @attrs(repr=False, slots=True, unsafe_hash=True) class _SubclassOfValidator: type = attrib() def __call__(self, inst, attr, value): """ We use a callable class to be able to change the ``__repr__``. """ if not issubclass(value, self.type): msg = f"'{attr.name}' must be a subclass of {self.type!r} (got {value!r})." raise TypeError( msg, attr, self.type, value, ) def __repr__(self): return f"" def _subclass_of(type): """ A validator that raises a `TypeError` if the initializer is called with a wrong type for this particular attribute (checks are performed using `issubclass` therefore it's also valid to pass a tuple of types). Args: type (type | tuple[type, ...]): The type(s) to check for. Raises: TypeError: With a human readable error message, the attribute (of type `attrs.Attribute`), the expected type, and the value it got. """ return _SubclassOfValidator(type) @attrs(repr=False, slots=True, unsafe_hash=True) class _NotValidator: validator = attrib() msg = attrib( converter=default_if_none( "not_ validator child '{validator!r}' " "did not raise a captured error" ) ) exc_types = attrib( validator=deep_iterable( member_validator=_subclass_of(Exception), iterable_validator=instance_of(tuple), ), ) def __call__(self, inst, attr, value): try: self.validator(inst, attr, value) except self.exc_types: pass # suppress error to invert validity else: raise ValueError( self.msg.format( validator=self.validator, exc_types=self.exc_types, ), attr, self.validator, value, self.exc_types, ) def __repr__(self): return f"" def not_(validator, *, msg=None, exc_types=(ValueError, TypeError)): """ A validator that wraps and logically 'inverts' the validator passed to it. It will raise a `ValueError` if the provided validator *doesn't* raise a `ValueError` or `TypeError` (by default), and will suppress the exception if the provided validator *does*. Intended to be used with existing validators to compose logic without needing to create inverted variants, for example, ``not_(in_(...))``. Args: validator: A validator to be logically inverted. msg (str): Message to raise if validator fails. Formatted with keys ``exc_types`` and ``validator``. exc_types (tuple[type, ...]): Exception type(s) to capture. Other types raised by child validators will not be intercepted and pass through. Raises: ValueError: With a human readable error message, the attribute (of type `attrs.Attribute`), the validator that failed to raise an exception, the value it got, and the expected exception types. .. versionadded:: 22.2.0 """ try: exc_types = tuple(exc_types) except TypeError: exc_types = (exc_types,) return _NotValidator(validator, msg, exc_types) @attrs(repr=False, slots=True, unsafe_hash=True) class _OrValidator: validators = attrib() def __call__(self, inst, attr, value): for v in self.validators: try: v(inst, attr, value) except Exception: # noqa: BLE001, PERF203, S112 continue else: return msg = f"None of {self.validators!r} satisfied for value {value!r}" raise ValueError(msg) def __repr__(self): return f"" def or_(*validators): """ A validator that composes multiple validators into one. When called on a value, it runs all wrapped validators until one of them is satisfied. Args: validators (~collections.abc.Iterable[typing.Callable]): Arbitrary number of validators. Raises: ValueError: If no validator is satisfied. Raised with a human-readable error message listing all the wrapped validators and the value that failed all of them. .. versionadded:: 24.1.0 """ vals = [] for v in validators: vals.extend(v.validators if isinstance(v, _OrValidator) else [v]) return _OrValidator(tuple(vals))