Description
Hi there,
First, thanks for an amazing project! Words can't express how much attrs has changed my programming career for the better.
I have found that I often write some validation function which has to check something about some value. In most cases, the inbuilt validators are perfect, in some I still have to write something custom and that is where the question begins. For example, let's say I had some validation function like the below:
def check_weird_constraint(value):
if not isinstance(value, MyOddClass) and value.class_special_attribute in ["some", "list", "values"]:
raise ValueError("Bad value")
This is nice, because the function only takes in one value, which makes it easy to rationalise and re-use in the code in non-attrs contexts if needed. If I want to use it in attrs, as far as I can tell (and please correct me if I need to rtfd), I have two options.
The first is to add instance
and attribute
as args to my function, e.g.
def check_weird_constraint(instance, attribute, value):
if not isinstance(value, MyOddClass) and value.class_special_attribute in ["some", "list", "values"]:
raise ValueError("Bad value")
This feels a bit off, because instance and attribute aren't used in the function. It is also confusing if you use it in a non-attrs context, because you have to call it as check_weird_constraint(_, _, value)
.
The other option is to write a wrapper that applies purely for the attrs context, e.g.
def check_weird_constraint_attrs_validator(instance, attribute, value):
try:
check_weird_constraint(value))
except ValueError as exc:
raise ValueError(f"Failed to initialise {instance} {attribute}") from exc
The second option feels better, but you end up having heaps of functions floating around.
So, my question, would there be any interest in including some convenience decorators in attrs to help with this situation? In some projects I have done, I have used the code below. I'd be happy to contribute a PR here if it that's of any interest
Details
from __future__ import annotations
from functools import wraps
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any, Callable, TypeVar
import attr
T = TypeVar("T")
class AttributeInitialisationError(ValueError):
"""
Raised when there is an issue while initialising an :obj:`attr.Attribute`
"""
def __init__(
self,
instance: Any,
attribute: attr.Attribute[Any],
value: T,
) -> None:
"""
Initialise the error
Parameters
----------
instance
Instance being initialised
attribute
Attribute being set
value
Value being used to set the attribute
"""
error_msg = (
"Error raised while initialising attribute "
f"``{attribute.name}`` of ``{type(instance)}``. "
f"Value provided: {value}"
)
super().__init__(error_msg)
def add_attrs_context(
original: Callable[[Any, attr.Attribute[Any], T], None],
) -> Callable[[Any, attr.Attribute[Any], T], None]:
"""
Decorate function with a ``try...except`` to add the :mod:`attrs` context
This means that the information about what attribute was being set and
what value it was passed is also shown to the user
Parameters
----------
original
Function to decorate
Returns
-------
Decorated function
"""
@wraps(original)
def with_attrs_context(
instance: Any,
attribute: attr.Attribute[Any],
value: T,
) -> None:
try:
original(instance, attribute, value)
except Exception as exc:
raise AttributeInitialisationError(
instance=instance, attribute=attribute, value=value
) from exc
return with_attrs_context
def make_attrs_validator_compatible_input_only(
func_to_wrap: Callable[[T], None],
) -> Callable[[Any, attr.Attribute[Any], T], None]:
"""
Create a function that is compatible with validation via :func:`attrs.field`
This assumes that the function you're wrapping only takes the input
(not the instance or the attribute being set).
Parameters
----------
func_to_wrap
Function to wrap
Returns
-------
Wrapped function, which can be used as a validator with
:func:`attrs.field`
"""
@add_attrs_context
@wraps(func_to_wrap)
def attrs_compatible(
instance: Any,
attribute: attr.Attribute[Any],
value: T,
) -> None:
func_to_wrap(value)
return attrs_compatible
def make_attrs_validator_compatible_attribute_value_input(
func_to_wrap: Callable[[attr.Attribute[Any], T], None],
) -> Callable[[Any, attr.Attribute[Any], T], None]:
"""
Create a function that is compatible with validation via :func:`attrs.field`
This assumes that the function you're wrapping takes the attribute being set
and the input (not the instance being set).
Parameters
----------
func_to_wrap
Function to wrap
Returns
-------
Wrapped function, which can be used as a validator with
:func:`attrs.field`
"""
@add_attrs_context
@wraps(func_to_wrap)
def attrs_compatible(
instance: Any,
attribute: attr.Attribute[Any],
value: T,
) -> None:
func_to_wrap(attribute, value)
return attrs_compatible