Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a not_ validator #1010

Merged
merged 1 commit into from
Sep 4, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1010.change.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added ``attrs.validators.not_(wrapped_validator)`` to logically invert *wrapped_validator* by accepting only values where *wrapped_validator* rejects the value with a ``ValueError`` or ``TypeError`` (by default, exception types configurable).
26 changes: 26 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,32 @@ All objects from ``attrs.validators`` are also available from ``attr.validators`
x = attrs.field(validator=attrs.validators.and_(v1, v2, v3))
x = attrs.field(validator=[v1, v2, v3])

.. autofunction:: attrs.validators.not_

For example:

.. doctest::

>>> reserved_names = {"id", "time", "source"}
>>> @attrs.define
... class Measurement:
... tags = attrs.field(
... validator=attrs.validators.deep_mapping(
... key_validator=attrs.validators.not_(
... attrs.validators.in_(reserved_names),
... msg="reserved tag key",
... ),
... value_validator=attrs.validators.instance_of((str, int)),
... )
... )
>>> Measurement(tags={"source": "universe"})
Traceback (most recent call last):
...
ValueError: ("reserved tag key", Attribute(name='tags', default=NOTHING, validator=<not_ validator wrapping <in_ validator with options {'id', 'time', 'source'}>, capturing (<class 'ValueError'>, <class 'TypeError'>)>, type=None, kw_only=False), <in_ validator with options {'id', 'time', 'source'}>, {'source_': 'universe'}, (<class 'ValueError'>, <class 'TypeError'>))
>>> Measurement(tags={"source_": "universe"})
Measurement(tags={'source_': 'universe'})


.. autofunction:: attrs.validators.optional

For example:
Expand Down
120 changes: 120 additions & 0 deletions src/attr/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

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


Expand All @@ -37,6 +38,7 @@
"matches_re",
"max_len",
"min_len",
"not_",
"optional",
"provides",
"set_disabled",
Expand Down Expand Up @@ -592,3 +594,121 @@ def min_len(length):
.. versionadded:: 22.1.0
"""
return _MinLengthValidator(length)


@attrs(repr=False, slots=True, 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):
raise TypeError(
"'{name}' must be a subclass of {type!r} "
"(got {value!r}).".format(
name=attr.name,
type=self.type,
value=value,
),
attr,
self.type,
value,
)

def __repr__(self):
return "<subclass_of validator for type {type!r}>".format(
hynek marked this conversation as resolved.
Show resolved Hide resolved
type=self.type
)


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).

:param type: The type to check for.
:type type: type or tuple of types

: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, 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 (
"<not_ validator wrapping {what!r}, " "capturing {exc_types!r}>"
).format(
what=self.validator,
exc_types=self.exc_types,
)


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_(...))``.

:param validator: A validator to be logically inverted.
:param msg: Message to raise if validator fails.
Formatted with keys ``exc_types`` and ``validator``.
:type msg: str
:param exc_types: 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)
9 changes: 9 additions & 0 deletions src/attr/validators.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,12 @@ def ge(val: _T) -> _ValidatorType[_T]: ...
def gt(val: _T) -> _ValidatorType[_T]: ...
def max_len(length: int) -> _ValidatorType[_T]: ...
def min_len(length: int) -> _ValidatorType[_T]: ...
def not_(
validator: _ValidatorType[_T],
*,
msg: Optional[str] = None,
exc_types: Union[Type[Exception], Iterable[Type[Exception]]] = (
ValueError,
TypeError,
)
) -> _ValidatorType[_T]: ...
Loading