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

Convenience decorators for promoting functions to attrs-compatible validators #1257

Open
znicholls opened this issue Mar 7, 2024 · 5 comments
Labels

Comments

@znicholls
Copy link

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

@hynek
Copy link
Member

hynek commented Mar 14, 2024

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?

@znicholls
Copy link
Author

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

@hynek
Copy link
Member

hynek commented Mar 18, 2024

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?

@znicholls
Copy link
Author

I think this could be made to fit the approach in #1267 by introducing a Validator class?

Yep, if there were a Validator class that had the same idea as the Converter in #1267 I would just use that!

@hynek hynek added the Feature label Mar 18, 2024
@second-ed
Copy link

I have some code I use for this, been considering adding it as a PR

def validate_bool_func(bool_func) -> Callable:
    """
    Validate the value using a custom boolean function.

    Args:
        bool_func: The boolean function to apply to the value.

    Returns:
        Callable: A validation function.

    Raises:
        TypeError: If the provided boolean function is not callable.
    """
    if not isinstance(bool_func, Callable):
        raise TypeError("provided boolean function must be callable")

    def _(instance, attribute, value) -> None:
        if not bool_func(value):
            raise ValueError(
                f"{attribute.name} does not pass {bool_func.__name__},"
                f" received {value}. "
            )

    return _

example tests from my custom validators here:

@pytest.mark.parametrize(
    "val_func, inputs, expectation",
    [
        (
            validate_bool_func(np.isnan),
            np.nan,
            does_not_raise(),
        ),
        (
            validate_bool_func(np.isnan),
            1,
            pytest.raises(ValueError),
        ),
    ],
)
def test_validate_bool_func(val_func, inputs, expectation):
    @attr.define
    class TestClass:
        attrib: list = attr.ib(validator=[val_func])

    with expectation:
        TestClass(inputs)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

3 participants