attrs icon indicating copy to clipboard operation
attrs copied to clipboard

Typing with converters not working

Open berquist opened this issue 6 years ago • 5 comments

Using attrs 19.1.0 on Python 3.6.7,

from typing import Any, Iterable, List, Tuple, TypeVar

from attr import attrs, attrib

T = TypeVar("T")

def to_tuple(val: Iterable[T]) -> Tuple[T, ...]:
    return tuple(val)

def to_list(val: Iterable[T]) -> List[T]:
    return list(val)

@attrs
class C:
    elements: Tuple[str, ...] = attrib(converter=to_tuple)

@attrs
class D:
    elements: Tuple[str, ...] = attrib(converter=tuple)

@attrs
class E:
    elements: List[str] = attrib(converter=list)

@attrs
class F:
    elements: List[str] = attrib(converter=to_list)


if __name__ == "__main__":
    l = ["a", "b", "c"]
    c = C(l)
    d = D(l)
    e = E(l)
    f = F(l)

gives

32: error: Argument 1 to "C" has incompatible type "List[str]"; expected "Iterable[T]"
33: error: Argument 1 to "D" has incompatible type "List[str]"; expected "Iterable[_T_co]"
34: error: Argument 1 to "E" has incompatible type "List[str]"; expected "Iterable[_T]"
35: error: Argument 1 to "F" has incompatible type "List[str]"; expected "Iterable[T]"

This may be related to https://github.com/python/mypy/issues/5738, but the issue goes back as far as mypy 0.610.

berquist avatar Mar 15 '19 20:03 berquist

That looks like exactly @euresti's comment in that mypy bug?

hynek avatar Mar 21 '19 14:03 hynek

That mypy bug appears to have been fixed, but I'm seeing something that looks like a similar problem with dict. The example in @berquist's original comment is also still failing for me.

@berquist, did you get anywhere with a workaround? Or have you seen any other mypy issues that look relevant? (I'm struggling to find any)

my minimal example with `dict`
import attrs


@attrs.frozen
class C:
    foo: dict = attrs.field(factory=dict, converter=dict)


C(foo={"a": 1})
$ mypy t.py
t.py:9: error: Argument "foo" to "C" has incompatible type "dict[str, int]"; expected "_VT | SupportsKeysAndGetItem[_KT, _VT] | SupportsKeysAndGetItem[str, _VT] | Iterable[tuple[_KT, _VT]] | Iterable[tuple[str, _VT]] | Iterable[list[str]] | Iterable[list[bytes]]"  [arg-type]
Found 1 error in 1 file (checked 1 source file)
$ python --version
Python 3.11.5

$ pip freeze
attrs==23.1.0
mypy==1.5.1
mypy-extensions==1.0.0
typing_extensions==4.7.1

samueljsb avatar Sep 01 '23 16:09 samueljsb

I just ran into the same problem in an essentially analogous situation to @berquist's. (Hiya, Eric! Been a while, hope all is well.)

I was able to satisfy mypy by wrapping the list conversion in a lambda, instead of just passing the raw type itself.

Toy example:

import attrs


@attrs.define(kw_only=True)
class ClassConverter:
    my_list: list[int] = attrs.field(converter=list)

    def evolve_my_list(self, new_list: list[int]):
        return self.__class__(my_list=new_list)


@attrs.define(kw_only=True)
class LambdaConverter:
    my_list: list[int] = attrs.field(converter=lambda v: list(v))

    def evolve_my_list(self, new_list: list[int]):
        return self.__class__(my_list=new_list)

mypy output:

9: error: Argument "my_list" to "ClassConverter" has incompatible type "list[int]"; expected "Iterable[_T]"  [arg-type]

And no typing error on LambdaConverter.

Python 3.11.7 on Debian WSL2 attrs 23.1.0 mypy 1.8.0 (compiled: yes)

I don't know if this is a good or right way to address this, but it's semantically correct and makes mypy happy, sooooo.... ¯\_(ツ)_/¯

bskinn avatar Jan 05 '24 02:01 bskinn

Hi @bskinn, you need to use a dedicate converter function for typing to work correctly, because list is not Callable[[Any], list[int]]. You should also use the @classmethod decorator for your factory functions.

The following code works with Python 3.12 and mypy 1.8:

from typing import Any, Self, Type

import attrs


def to_int(v: Any) -> list[int]:
    return [int(item) for item in v]


@attrs.define(kw_only=True)
class ClassConverter:
    my_list: list[int] = attrs.field(converter=to_int)

    @classmethod
    def evolve_my_list(cls: Type[Self], new_list: list[int]) -> Self:
        return cls(my_list=new_list)


@attrs.define(kw_only=True)
class LambdaConverter:
    my_list: list[int] = attrs.field(converter=lambda v: list(v))

    @classmethod
    def evolve_my_list(cls: Type[Self], new_list: list[int]) -> Self:
        return cls(my_list=new_list)

sscherfke avatar Jan 05 '24 08:01 sscherfke

Thanks, @sscherfke!

I was wondering if a function with an explicit internal int cast would be the most correct way to do it.

And you're right, for that toy example, @classmethod is the way to go. For my actual use-case (private code, unfortunately), I'm defining custom __add__ and __mul__, so as best I understand I do need instance methods and self.__class__(...).

(FWIW I also need runtime checks to be sure that the incoming argument is actually isinstance(addend, int), so there's yet further difference there, but that's all implementation-specific. Didn't want to clutter my proposed solution with all that.)

bskinn avatar Jan 05 '24 15:01 bskinn