| # SPDX-License-Identifier: MIT |
| |
| """ |
| Tests for PEP-526 type annotations. |
| """ |
| |
| import sys |
| import types |
| import typing |
| |
| import pytest |
| |
| import attr |
| import attrs |
| |
| from attr._make import _is_class_var |
| from attr.exceptions import UnannotatedAttributeError |
| |
| |
| def assert_init_annotations(cls, **annotations): |
| """ |
| Assert cls.__init__ has the correct annotations. |
| """ |
| __tracebackhide__ = True |
| |
| annotations["return"] = type(None) |
| |
| assert annotations == typing.get_type_hints(cls.__init__) |
| |
| |
| class TestAnnotations: |
| """ |
| Tests for types derived from variable annotations (PEP-526). |
| """ |
| |
| def test_basic_annotations(self): |
| """ |
| Sets the `Attribute.type` attr from basic type annotations. |
| """ |
| |
| @attr.resolve_types |
| @attr.s |
| class C: |
| x: int = attr.ib() |
| y = attr.ib(type=str) |
| z = attr.ib() |
| |
| assert int is attr.fields(C).x.type |
| assert str is attr.fields(C).y.type |
| assert None is attr.fields(C).z.type |
| assert_init_annotations(C, x=int, y=str) |
| |
| def test_catches_basic_type_conflict(self): |
| """ |
| Raises ValueError if type is specified both ways. |
| """ |
| with pytest.raises(ValueError) as e: |
| |
| @attr.s |
| class C: |
| x: int = attr.ib(type=int) |
| |
| assert ( |
| "Type annotation and type argument cannot both be present for 'x'.", |
| ) == e.value.args |
| |
| def test_typing_annotations(self): |
| """ |
| Sets the `Attribute.type` attr from typing annotations. |
| """ |
| |
| @attr.resolve_types |
| @attr.s |
| class C: |
| x: typing.List[int] = attr.ib() |
| y = attr.ib(type=typing.Optional[str]) |
| |
| assert typing.List[int] is attr.fields(C).x.type |
| assert typing.Optional[str] is attr.fields(C).y.type |
| assert_init_annotations(C, x=typing.List[int], y=typing.Optional[str]) |
| |
| def test_only_attrs_annotations_collected(self): |
| """ |
| Annotations that aren't set to an attr.ib are ignored. |
| """ |
| |
| @attr.resolve_types |
| @attr.s |
| class C: |
| x: typing.List[int] = attr.ib() |
| y: int |
| |
| assert 1 == len(attr.fields(C)) |
| assert_init_annotations(C, x=typing.List[int]) |
| |
| @pytest.mark.skipif( |
| sys.version_info[:2] < (3, 11), |
| reason="Incompatible behavior on older Pythons", |
| ) |
| def test_auto_attribs(self, slots): |
| """ |
| If *auto_attribs* is True, bare annotations are collected too. |
| Defaults work and class variables are ignored. |
| """ |
| |
| @attr.s(auto_attribs=True, slots=slots) |
| class C: |
| cls_var: typing.ClassVar[int] = 23 |
| a: int |
| x: typing.List[int] = attrs.Factory(list) |
| y: int = 2 |
| z: int = attr.ib(default=3) |
| foo: typing.Any = None |
| |
| i = C(42) |
| assert "C(a=42, x=[], y=2, z=3, foo=None)" == repr(i) |
| |
| attr_names = {a.name for a in C.__attrs_attrs__} |
| assert "a" in attr_names # just double check that the set works |
| assert "cls_var" not in attr_names |
| |
| attr.resolve_types(C) |
| |
| assert int is attr.fields(C).a.type |
| |
| assert attr.Factory(list) == attr.fields(C).x.default |
| assert typing.List[int] is attr.fields(C).x.type |
| |
| assert int is attr.fields(C).y.type |
| assert 2 == attr.fields(C).y.default |
| |
| assert int is attr.fields(C).z.type |
| |
| assert typing.Any == attr.fields(C).foo.type |
| |
| # Class body is clean. |
| if slots is False: |
| with pytest.raises(AttributeError): |
| C.y |
| |
| assert 2 == i.y |
| else: |
| assert isinstance(C.y, types.MemberDescriptorType) |
| |
| i.y = 23 |
| assert 23 == i.y |
| |
| assert_init_annotations( |
| C, |
| a=int, |
| x=typing.List[int], |
| y=int, |
| z=int, |
| foo=typing.Any, |
| ) |
| |
| def test_auto_attribs_unannotated(self, slots): |
| """ |
| Unannotated `attr.ib`s raise an error. |
| """ |
| with pytest.raises(UnannotatedAttributeError) as e: |
| |
| @attr.s(slots=slots, auto_attribs=True) |
| class C: |
| v = attr.ib() |
| x: int |
| y = attr.ib() |
| z: str |
| |
| assert ( |
| "The following `attr.ib`s lack a type annotation: v, y.", |
| ) == e.value.args |
| |
| def test_auto_attribs_subclassing(self, slots): |
| """ |
| Attributes from base classes are inherited, it doesn't matter if the |
| subclass has annotations or not. |
| |
| Ref #291 |
| """ |
| |
| @attr.resolve_types |
| @attr.s(slots=slots, auto_attribs=True) |
| class A: |
| a: int = 1 |
| |
| @attr.resolve_types |
| @attr.s(slots=slots, auto_attribs=True) |
| class B(A): |
| b: int = 2 |
| |
| @attr.resolve_types |
| @attr.s(slots=slots, auto_attribs=True) |
| class C(A): |
| pass |
| |
| assert "B(a=1, b=2)" == repr(B()) |
| assert "C(a=1)" == repr(C()) |
| assert_init_annotations(A, a=int) |
| assert_init_annotations(B, a=int, b=int) |
| assert_init_annotations(C, a=int) |
| |
| def test_converter_annotations(self): |
| """ |
| An unannotated attribute with an annotated converter gets its |
| annotation from the converter. |
| """ |
| |
| def int2str(x: int) -> str: |
| return str(x) |
| |
| @attr.s |
| class A: |
| a = attr.ib(converter=int2str) |
| |
| assert_init_annotations(A, a=int) |
| |
| def int2str_(x: int, y: str = ""): |
| return str(x) |
| |
| @attr.s |
| class A: |
| a = attr.ib(converter=int2str_) |
| |
| assert_init_annotations(A, a=int) |
| |
| def test_converter_attrib_annotations(self): |
| """ |
| If a converter is provided, an explicit type annotation has no |
| effect on an attribute's type annotation. |
| """ |
| |
| def int2str(x: int) -> str: |
| return str(x) |
| |
| @attr.s |
| class A: |
| a: str = attr.ib(converter=int2str) |
| b = attr.ib(converter=int2str, type=str) |
| |
| assert_init_annotations(A, a=int, b=int) |
| |
| def test_non_introspectable_converter(self): |
| """ |
| A non-introspectable converter doesn't cause a crash. |
| """ |
| |
| @attr.s |
| class A: |
| a = attr.ib(converter=print) |
| |
| def test_nullary_converter(self): |
| """ |
| A converter with no arguments doesn't cause a crash. |
| """ |
| |
| def noop(): |
| pass |
| |
| @attr.s |
| class A: |
| a = attr.ib(converter=noop) |
| |
| assert A.__init__.__annotations__ == {"return": None} |
| |
| def test_pipe(self): |
| """ |
| pipe() uses the input annotation of its first argument and the |
| output annotation of its last argument. |
| """ |
| |
| def int2str(x: int) -> str: |
| return str(x) |
| |
| def strlen(y: str) -> int: |
| return len(y) |
| |
| def identity(z): |
| return z |
| |
| assert attr.converters.pipe(int2str).__annotations__ == { |
| "val": int, |
| "return": str, |
| } |
| assert attr.converters.pipe(int2str, strlen).__annotations__ == { |
| "val": int, |
| "return": int, |
| } |
| assert attr.converters.pipe(identity, strlen).__annotations__ == { |
| "return": int |
| } |
| assert attr.converters.pipe(int2str, identity).__annotations__ == { |
| "val": int |
| } |
| |
| def int2str_(x: int, y: int = 0) -> str: |
| return str(x) |
| |
| assert attr.converters.pipe(int2str_).__annotations__ == { |
| "val": int, |
| "return": str, |
| } |
| |
| def test_pipe_empty(self): |
| """ |
| pipe() with no converters is annotated like the identity. |
| """ |
| |
| p = attr.converters.pipe() |
| |
| assert "val" in p.__annotations__ |
| |
| t = p.__annotations__["val"] |
| |
| assert isinstance(t, typing.TypeVar) |
| assert p.__annotations__ == {"val": t, "return": t} |
| |
| def test_pipe_non_introspectable(self): |
| """ |
| pipe() doesn't crash when passed a non-introspectable converter. |
| """ |
| |
| assert attr.converters.pipe(print).__annotations__ == {} |
| |
| def test_pipe_nullary(self): |
| """ |
| pipe() doesn't crash when passed a nullary converter. |
| """ |
| |
| def noop(): |
| pass |
| |
| assert attr.converters.pipe(noop).__annotations__ == {} |
| |
| def test_optional(self): |
| """ |
| optional() uses the annotations of the converter it wraps. |
| """ |
| |
| def int2str(x: int) -> str: |
| return str(x) |
| |
| def int_identity(x: int): |
| return x |
| |
| def strify(x) -> str: |
| return str(x) |
| |
| def identity(x): |
| return x |
| |
| assert attr.converters.optional(int2str).__annotations__ == { |
| "val": typing.Optional[int], |
| "return": typing.Optional[str], |
| } |
| assert attr.converters.optional(int_identity).__annotations__ == { |
| "val": typing.Optional[int] |
| } |
| assert attr.converters.optional(strify).__annotations__ == { |
| "return": typing.Optional[str] |
| } |
| assert attr.converters.optional(identity).__annotations__ == {} |
| |
| def int2str_(x: int, y: int = 0) -> str: |
| return str(x) |
| |
| assert attr.converters.optional(int2str_).__annotations__ == { |
| "val": typing.Optional[int], |
| "return": typing.Optional[str], |
| } |
| |
| def test_optional_non_introspectable(self): |
| """ |
| optional() doesn't crash when passed a non-introspectable |
| converter. |
| """ |
| |
| assert attr.converters.optional(print).__annotations__ == {} |
| |
| def test_optional_nullary(self): |
| """ |
| optional() doesn't crash when passed a nullary converter. |
| """ |
| |
| def noop(): |
| pass |
| |
| assert attr.converters.optional(noop).__annotations__ == {} |
| |
| @pytest.mark.skipif( |
| sys.version_info[:2] < (3, 11), |
| reason="Incompatible behavior on older Pythons", |
| ) |
| def test_annotations_strings(self, slots): |
| """ |
| String annotations are passed into __init__ as is. |
| |
| The strings keep changing between releases. |
| """ |
| import typing as t |
| |
| from typing import ClassVar |
| |
| @attr.s(auto_attribs=True, slots=slots) |
| class C: |
| cls_var1: "typing.ClassVar[int]" = 23 |
| cls_var2: "ClassVar[int]" = 23 |
| cls_var3: "t.ClassVar[int]" = 23 |
| a: "int" |
| x: "typing.List[int]" = attrs.Factory(list) |
| y: "int" = 2 |
| z: "int" = attr.ib(default=3) |
| foo: "typing.Any" = None |
| |
| attr.resolve_types(C, locals(), globals()) |
| |
| assert_init_annotations( |
| C, |
| a=int, |
| x=typing.List[int], |
| y=int, |
| z=int, |
| foo=typing.Any, |
| ) |
| |
| def test_typing_extensions_classvar(self, slots): |
| """ |
| If ClassVar is coming from typing_extensions, it is recognized too. |
| """ |
| |
| @attr.s(auto_attribs=True, slots=slots) |
| class C: |
| cls_var: "typing_extensions.ClassVar" = 23 # noqa: F821 |
| |
| assert_init_annotations(C) |
| |
| def test_keyword_only_auto_attribs(self): |
| """ |
| `kw_only` propagates to attributes defined via `auto_attribs`. |
| """ |
| |
| @attr.s(auto_attribs=True, kw_only=True) |
| class C: |
| x: int |
| y: int |
| |
| with pytest.raises(TypeError): |
| C(0, 1) |
| |
| with pytest.raises(TypeError): |
| C(x=0) |
| |
| c = C(x=0, y=1) |
| |
| assert c.x == 0 |
| assert c.y == 1 |
| |
| def test_base_class_variable(self): |
| """ |
| Base class' class variables can be overridden with an attribute |
| without resorting to using an explicit `attr.ib()`. |
| """ |
| |
| class Base: |
| x: int = 42 |
| |
| @attr.s(auto_attribs=True) |
| class C(Base): |
| x: int |
| |
| assert 1 == C(1).x |
| |
| def test_removes_none_too(self): |
| """ |
| Regression test for #523: make sure defaults that are set to None are |
| removed too. |
| """ |
| |
| @attr.s(auto_attribs=True) |
| class C: |
| x: int = 42 |
| y: typing.Any = None |
| |
| with pytest.raises(AttributeError): |
| C.x |
| |
| with pytest.raises(AttributeError): |
| C.y |
| |
| def test_non_comparable_defaults(self): |
| """ |
| Regression test for #585: objects that are not directly comparable |
| (for example numpy arrays) would cause a crash when used as |
| default values of an attrs auto-attrib class. |
| """ |
| |
| class NonComparable: |
| def __eq__(self, other): |
| raise ValueError |
| |
| @attr.s(auto_attribs=True) |
| class C: |
| x: typing.Any = NonComparable() # noqa: RUF009 |
| |
| def test_basic_resolve(self): |
| """ |
| Resolve the `Attribute.type` attr from basic type annotations. |
| Unannotated types are ignored. |
| """ |
| |
| @attr.s |
| class C: |
| x: "int" = attr.ib() |
| y = attr.ib(type=str) |
| z = attr.ib() |
| |
| attr.resolve_types(C) |
| |
| assert int is attr.fields(C).x.type |
| assert str is attr.fields(C).y.type |
| assert None is attr.fields(C).z.type |
| |
| @pytest.mark.skipif( |
| sys.version_info[:2] < (3, 9), |
| reason="Incompatible behavior on older Pythons", |
| ) |
| def test_extra_resolve(self): |
| """ |
| `get_type_hints` returns extra type hints. |
| """ |
| from typing import Annotated |
| |
| globals = {"Annotated": Annotated} |
| |
| @attr.define |
| class C: |
| x: 'Annotated[float, "test"]' |
| |
| attr.resolve_types(C, globals) |
| |
| assert Annotated[float, "test"] is attr.fields(C).x.type |
| |
| @attr.define |
| class D: |
| x: 'Annotated[float, "test"]' |
| |
| attr.resolve_types(D, globals, include_extras=False) |
| |
| assert float is attr.fields(D).x.type |
| |
| def test_resolve_types_auto_attrib(self, slots): |
| """ |
| Types can be resolved even when strings are involved. |
| """ |
| |
| @attr.s(slots=slots, auto_attribs=True) |
| class A: |
| a: typing.List[int] |
| b: typing.List["int"] |
| c: "typing.List[int]" |
| |
| # Note: I don't have to pass globals and locals here because |
| # int is a builtin and will be available in any scope. |
| attr.resolve_types(A) |
| |
| assert typing.List[int] == attr.fields(A).a.type |
| assert typing.List[int] == attr.fields(A).b.type |
| assert typing.List[int] == attr.fields(A).c.type |
| |
| def test_resolve_types_decorator(self, slots): |
| """ |
| Types can be resolved using it as a decorator. |
| """ |
| |
| @attr.resolve_types |
| @attr.s(slots=slots, auto_attribs=True) |
| class A: |
| a: typing.List[int] |
| b: typing.List["int"] |
| c: "typing.List[int]" |
| |
| assert typing.List[int] == attr.fields(A).a.type |
| assert typing.List[int] == attr.fields(A).b.type |
| assert typing.List[int] == attr.fields(A).c.type |
| |
| def test_self_reference(self, slots): |
| """ |
| References to self class using quotes can be resolved. |
| """ |
| |
| @attr.s(slots=slots, auto_attribs=True) |
| class A: |
| a: "A" |
| b: typing.Optional["A"] # will resolve below -- noqa: F821 |
| |
| attr.resolve_types(A, globals(), locals()) |
| |
| assert A == attr.fields(A).a.type |
| assert typing.Optional[A] == attr.fields(A).b.type |
| |
| def test_forward_reference(self, slots): |
| """ |
| Forward references can be resolved. |
| """ |
| |
| @attr.s(slots=slots, auto_attribs=True) |
| class A: |
| a: typing.List["B"] # will resolve below -- noqa: F821 |
| |
| @attr.s(slots=slots, auto_attribs=True) |
| class B: |
| a: A |
| |
| attr.resolve_types(A, globals(), locals()) |
| attr.resolve_types(B, globals(), locals()) |
| |
| assert typing.List[B] == attr.fields(A).a.type |
| assert A == attr.fields(B).a.type |
| |
| assert typing.List[B] == attr.fields(A).a.type |
| assert A == attr.fields(B).a.type |
| |
| def test_init_type_hints(self): |
| """ |
| Forward references in __init__ can be automatically resolved. |
| """ |
| |
| @attr.s |
| class C: |
| x = attr.ib(type="typing.List[int]") |
| |
| assert_init_annotations(C, x=typing.List[int]) |
| |
| def test_init_type_hints_fake_module(self): |
| """ |
| If you somehow set the __module__ to something that doesn't exist |
| you'll lose __init__ resolution. |
| """ |
| |
| class C: |
| x = attr.ib(type="typing.List[int]") |
| |
| C.__module__ = "totally fake" |
| C = attr.s(C) |
| |
| with pytest.raises(NameError): |
| typing.get_type_hints(C.__init__) |
| |
| def test_inheritance(self): |
| """ |
| Subclasses can be resolved after the parent is resolved. |
| """ |
| |
| @attr.define() |
| class A: |
| n: "int" |
| |
| @attr.define() |
| class B(A): |
| pass |
| |
| attr.resolve_types(A) |
| attr.resolve_types(B) |
| |
| assert int is attr.fields(A).n.type |
| assert int is attr.fields(B).n.type |
| |
| def test_resolve_twice(self): |
| """ |
| You can call resolve_types as many times as you like. |
| This test is here mostly for coverage. |
| """ |
| |
| @attr.define() |
| class A: |
| n: "int" |
| |
| attr.resolve_types(A) |
| |
| assert int is attr.fields(A).n.type |
| |
| attr.resolve_types(A) |
| |
| assert int is attr.fields(A).n.type |
| |
| |
| @pytest.mark.parametrize( |
| "annot", |
| [ |
| typing.ClassVar, |
| "typing.ClassVar", |
| "'typing.ClassVar[dict]'", |
| "t.ClassVar[int]", |
| ], |
| ) |
| def test_is_class_var(annot): |
| """ |
| ClassVars are detected, even if they're a string or quoted. |
| """ |
| assert _is_class_var(annot) |