Skip to content

Add **kwargs to validator function calls for more flexibility #1421

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

Open
redruin1 opened this issue Mar 26, 2025 · 1 comment
Open

Add **kwargs to validator function calls for more flexibility #1421

redruin1 opened this issue Mar 26, 2025 · 1 comment

Comments

@redruin1
Copy link

redruin1 commented Mar 26, 2025

Part of the reason why attrs is so appealing is that all validations are essentially just plain functions that are run at certain times. However, I find myself pining for a little bit more control on how those functions are run. Notably, I'm looking for the ability to collect a report of all exceptions/warnings issued from validators in some object returned to users, which can then be manipulated and optionally reissued (somewhat similar to how cattrs uses ExceptionGroups, but on the attrs instance itself). In a trivial case, consider a toy example where two validators always raise an exception:

@attrs.define
class Example:
    name: str = attrs.field()

    @name.validator
    def always_fails(self, attr, value):
        raise ValueError()
    
    @name.validator
    def would_never_run(self, attr, value): 
        # In pure attrs this function will never even run because of the previous validator
        raise TypeError() 

The simplest solution I can think of is to simply change the signature of validate() and validators to handle kwargs:

def attrs.validate(inst, **kwargs):
    ...

def validator(self, attr, value, **kwargs):
    ...

This would allow you pass whatever values you wanted to attrs.validate() and they would be passed straight through to every evaluated function. In cases where no extra args are specified (such as during attribute setting) then they would either error (appropriately) or resolve to defaults:

@attrs.define
class Example:
    name: str = attrs.field()

    @name.validator
    def only_when_strict(self, attr, value, strict: Optional[bool] = False):
        if strict:
            raise ValueError()
        
e = Example(name="passes, because 'strict' defaults to 'False'")
e.name = "same as above"
attrs.validate(e)               # Same as above
attrs.validate(e, strict=True)  # Raises ValueError

To achieve simple error collection, I could simply pass in an optional list into kwargs and have errors be collected there if a validator detects said list:

@attrs.define
class Example:
    name: str = attrs.field()

    @name.validator
    def always_fails(self, attr, value, error_list: Optional[list] = None):
        if error_list:
            error_list.append(ValueError())
        else:
            raise ValueError()
    
    @name.validator
    def would_never_run(self, attr, value, error_list: Optional[list] = None): 
        if error_list:
            error_list.append(TypeError())
        else:
            raise TypeError() 

with attrs.validators.disabled():
    e = Example(name="something") # Normally raises because `error_list` defaults to `None`

error_list = []
attrs.validate(e, error_list=error_list) # Collect errors in `error_list`
print(error_list) # -> [ValueError(), TypeError()]

In essence, this works in the same way as Pydantic's context argument. Writing a decorator like @collect_errors to remove the boilerplate of the if statement would be trivial, while also preserving the notion that validators are just "regular" functions. This (to me) seems a very simple and unobtrusive way to expand the functionality of existing validators while also maintaining backwards compatibility, though it unfortunately is only limited to cases where you explicitly call attrs.validate().

I don't believe cattrs itself is a solution here, because from what I understand cattrs is only concerned with validation of the object as it is being converted to/from a "raw" format (such as a dictionary to an attrs instance). In my case, I want the validators to run on manipulation of the attrs class itself, which only attrs itself is in charge of.

@redruin1
Copy link
Author

redruin1 commented May 9, 2025

I've played around with attrs a lot since opening this issue, and I think I've narrowed down my critiques somewhat. Here's a (fairly) simple example of the kind of syntax I'm trying to gain. Imagine that I'm writing a suite of validators, where each one is only run if the given mode is above the "severity" that the validator should run (minimum, strict, pedantic, etc.):

def conditional(severity):
    """
    Only run the validator if `mode` is greater than a given severity.
    If an `errors` list is provided, mutate that instead of raising.
    """
    def decorator(meth):
        def validator(self, attr, value, mode=None, errors=None):
            if mode is None or mode < severity:
                return
            try:
                meth(self, attr, value) # No kwargs needed (yet)
            except Exception as e:
                if errors is None:
                    raise e
                else:
                    errors.append(e)
        return validator
    return decorator

@attrs.define
class Example:
    x: int = attrs.field(default=10)

    @x.validator
    @conditional(ValidationMode.STRICT)
    def _validate_x(self, attr, value):
        raise TypeError("blah")

    def validate(self, mode):
        """
        Runs all the validators on `self`. Essentially the same as `attrs.validate`
        but with the `mode` and `errors` argument.
        """
        error_list = []
        for attr in attrs.fields(type(self)):
            if attr.validator is None:
                continue
            attr.validator(self, attr, getattr(self, attr.name), mode=mode, errors=error_list)
        return error_list

# This works pretty well:
e = Example()
error_list = e.validate(ValidationMode.STRICT)
print(error_list) # [TypeError('blah')]
error_list = e.validate(ValidationMode.NONE)
print(error_list) # []

However, it becomes impossible for me to simply add more than one decorated validator to Example:

    @x.validator
    @conditional(ValidationMode.PEDANTIC)
    def _validate_x_2(self, attr, value):
        raise UserWarning("blah 2")

... because attrs.validators.and_ cannot cope with unexpected kwargs:

Traceback (most recent call last):
  ...
TypeError: _AndValidator.__call__() got an unexpected keyword argument 'mode'

On its own, this is not catastrophic; I've already implemented my own and_ which handles the extra keyword arguments. I can manually overwrite the validator method on _CountingAttr:

from attr._make import _CountingAttr
 
def my_validator_decorator(self, meth):
    ...

_CountingAttr.validator = my_validator_decorator

But while this is great for my circumstance, it entirely messes with any other library using this decorator, which is a problem waiting to happen. A better pattern would probably be to subclass _CountingAttr (or similar), which would allow me to add anything/everything I need:

class _MyCountingAttr(_CountingAttr):
    def validator(self, meth):
        # I could even probably move the `conditional` decorator logic into here,
        # saving even more keystrokes across my codebase
        ...

    def unstructure(self, meth):
        # Register a cattrs hook for this specific field on this specific class
        ...

    # etc.

def custom_field(**kwargs):
    return _MyCountingAttr(**kwargs)

Unfortunately, I cannot do this alone because attrs specifically checks for instances of _CountingAttr:

    ...
    elif auto_attribs is True:
        ca_names = {
            name
            for name, attr in cd.items()
            if attr.__class__ is _CountingAttr
        }
        ca_list = []
        annot_names = set()
        for attr_name, type in anns.items():
            if _is_class_var(type):
                continue
            annot_names.add(attr_name)
            a = cd.get(attr_name, NOTHING)

            if a.__class__ is not _CountingAttr:
                a = attrib(a)
            ca_list.append((attr_name, a))
    ...

I could maybe convert my instances of _MyCountingAttr back into _CountingAttrs right before running attrs.define:

def custom_define(cls):
    for name, value in {k: v for k, v in cls.__dict__.items() if isinstance(v, _MyCountingAttr)}.items():
        setattr(
            cls, 
            name, 
            _CountingAttr(
                default=value._default,
                validator=value._validator,
                repr=value.repr,
                cmp=None,
                hash=value.hash,
                init=value.init,
                converter=value.converter,
                metadata=dict(value.metadata),
                type=value.type,
                kw_only=value.kw_only,
                eq=value.eq,
                eq_key=value.eq_key,
                order=value.order,
                order_key=value.order_key,
                on_setattr=value.on_setattr,
                alias=value.alias,
            )
        )
        
    return attrs.define(cls)

@custom_define
class Example:
    ...

But this is not particularly elegant.

Reviewing solutions again with this in mind:

  1. I still believe that adding **kwargs to all validator entry/exit points is probably a good idea. In this particular case, it allows me to circumvent all of the crazy ineriting/overwriting that I would have to do in order to get around it otherwise. It also remains the least intrusive option to the rest of the backend, from how I understand it.
  2. However, perhaps there is some merit to making a "sanctioned" way to extend attrs.field using inheritance in this manner. If isinstance(_CountingAttr) was changed to issubclass(_CountingAttr), that would theoretically allow field object customization out of the box without having to convert back and forth between them; though it would mean exposing a private element to the public API, which is also less than ideal.

@redruin1 redruin1 changed the title Capture a report of all errors and warnings issued from validators Add **kwargs to validator function calls for more flexibility May 9, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant