Skip to content

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

Open
@znicholls

Description

@znicholls

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions