Convenience decorators for promoting functions to attrs-compatible validators
znicholls opened this issue · comments
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
First, thanks for an amazing project! Words can't express how much attrs has changed my programming career for the better.
Thank you! ❤️
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.
Oof man, this is a topic that's almost as old as attrs: #146
Giving up on my hopes and dreams of my youth, I guess we could replicate here something we've done to __attrs_pre_init__
recently: look at the number of arguments and wrap the validator if it has exactly one.
However, skimming your example you seem to have a much bigger need for customization?
Oof man, this is a topic that's almost as old as attrs: #146
Ah I see. I had a look through that but have to admit I didn't read far enough to see the exact parallels. You've thought about it much more than me, so happy to close this and let you work it out elsewhere!
However, skimming your example you seem to have a much bigger need for customization?
Not necessarily. If there was a built in way to do this in atttrs, I'd probably just use that. Because I wrote it myself, I added some extra info so I got slightly more verbose error messages, but ultimately it just boils down to what you discussed above, except in reverse I guess. I.e. rather than attrs doing the wrapping automatically, it would just provide a utility so users could explicitly 'upcast' their own functions if they wanted. That would mean you don't have to modify the attrs API as it stands, but could provide a convenient way for people to work around this problem if they want (rather than rolling their own solution and getting it wrong multiple times, like I did :)).
Oof man, this is a topic that's almost as old as attrs: #146
Ah I see. I had a look through that but have to admit I didn't read far enough to see the exact parallels. You've thought about it much more than me, so happy to close this and let you work it out elsewhere!
That's not what I meant at all – as you can see I've been thinking about it for 7 years and never came up with anything good. Given PEP 712 (converters for dataclass transforms) and #1267 – I don't think the unification will ever happen.
However, skimming your example you seem to have a much bigger need for customization?
Not necessarily. If there was a built in way to do this in atttrs, I'd probably just use that. Because I wrote it myself, I added some extra info so I got slightly more verbose error messages, but ultimately it just boils down to what you discussed above, except in reverse I guess. I.e. rather than attrs doing the wrapping automatically, it would just provide a utility so users could explicitly 'upcast' their own functions if they wanted. That would mean you don't have to modify the attrs API as it stands, but could provide a convenient way for people to work around this problem if they want (rather than rolling their own solution and getting it wrong multiple times, like I did :)).
I think this could be made to fit the approach in #1267 by introducing a Validator
class?