devKanapka
devKanapka

Reputation: 83

How to annotate attrs field with validator?

I am having trouble annotating attrs class attribute.

I am using NewType for defining new UserId type and attrs frozen classes.

This is code where mypy doesn't complain and everything is alright:

from typing import NewType
from attr import frozen, field


UserId = NewType("UserId", str)


@frozen
class Order:
    id: UserId = field()

mypy does not have any issues when checking this code. The problem appears after using validators from attrs.

from typing import NewType
from attr import frozen, field, validators


UserId = NewType("UserId", str)


@frozen
class Order:
    id: UserId = field(validator=validators.matches_re("^\d+$"))

mypy now complains about incorrect types:

project/test_annotation.py:10: error: Incompatible types in assignment (expression has type "str", variable has type "UserId") [assignment]

Found 1 error in 1 file (checked 1 source file)

I don't understand how field() returns string type right now.

Can somebody explain that? Also, how can we work around this problem?

env:

Python 3.10.6

attrs==22.1.0

cattrs==22.2.0

Upvotes: 0

Views: 1037

Answers (1)

STerliakov
STerliakov

Reputation: 7933

To make it happy, cast. field is considerably complicated, your field returns a str thanks to this overload:

... # other overloads
# This form catches an explicit None or no default and infers the type from the
# other arguments.
@overload
def field(
    *,
    default: None = ...,
    validator: Optional[_ValidatorArgType[_T]] = ...,
    repr: _ReprArgType = ...,
    hash: Optional[bool] = ...,
    init: bool = ...,
    metadata: Optional[Mapping[Any, Any]] = ...,
    converter: Optional[_ConverterType] = ...,
    factory: Optional[Callable[[], _T]] = ...,
    kw_only: bool = ...,
    eq: Optional[_EqOrderType] = ...,
    order: Optional[_EqOrderType] = ...,
    on_setattr: Optional[_OnSetAttrArgType] = ...,
    alias: Optional[str] = ...,
) -> _T: ...

It basically says "when validators is a [sequence of or one of] validators working on some type T, then field returns T".

So, you pass a validator that works on str, and thus field type is str as well. NewType("UserID", str) is not a subtype of str, so this assignment fails. You have two major options:

  • cast to desired type:

    from typing import cast
    
    ...
    @frozen
    class Order:
         id: UserId = cast(str, field(validator=validators.matches_re("^\d+$")))
    
  • create your own validator. You don't need to replicate the logic, only change the signature and call original implementation with type: ignore or casts.

    from typing import TYPE_CHECKING, Callable, Match, Optional, Pattern, Union, cast
    if TYPE_CHECKING:
         from attr import _ValidatorType
    
    def userid_matches_re(
         regex: Union[Pattern[str], str],
         flags: int = ...,
         func: Optional[
             Callable[[str, str, int], Optional[Match[str]]]
         ] = ...,
    ) -> '_ValidatorType[UserID]':
         return cast('_ValidatorType[UserID]', validators.matches_re(regex, flags, func))
    

    ... and use it in your class instead of validators.matches_re. The signature above is stolen from stubs with AnyStr replaced with str, because you don't allow bytes anyway.

I'd recommend the first variant, because another solution is just more boilerplate with the same cast as a result, it gives you no more safety. However, it may be viable if you have many fields with this validator.

Upvotes: 1

Related Questions