707 lines
18 KiB
Python
707 lines
18 KiB
Python
# SPDX-License-Identifier: MIT
|
|
|
|
"""
|
|
Tests for PEP-526 type annotations.
|
|
"""
|
|
|
|
import sys
|
|
import types
|
|
import typing
|
|
|
|
import pytest
|
|
|
|
import attr
|
|
|
|
from attr._compat import PY_3_14_PLUS
|
|
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",
|
|
) == 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] = attr.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).converter.__annotations__ == {
|
|
"val": int,
|
|
"return": str,
|
|
}
|
|
assert attr.converters.pipe(
|
|
int2str, strlen
|
|
).converter.__annotations__ == {
|
|
"val": int,
|
|
"return": int,
|
|
}
|
|
assert attr.converters.pipe(
|
|
identity, strlen
|
|
).converter.__annotations__ == {"return": int}
|
|
assert attr.converters.pipe(
|
|
int2str, identity
|
|
).converter.__annotations__ == {"val": int}
|
|
|
|
def int2str_(x: int, y: int = 0) -> str:
|
|
return str(x)
|
|
|
|
assert attr.converters.pipe(int2str_).converter.__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.converter.__annotations__
|
|
|
|
t = p.converter.__annotations__["val"]
|
|
|
|
assert isinstance(t, typing.TypeVar)
|
|
assert p.converter.__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).converter.__annotations__ == {}
|
|
|
|
def test_pipe_nullary(self):
|
|
"""
|
|
pipe() doesn't crash when passed a nullary converter.
|
|
"""
|
|
|
|
def noop():
|
|
pass
|
|
|
|
assert attr.converters.pipe(noop).converter.__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]" = attr.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()
|
|
|
|
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.
|
|
"""
|
|
if PY_3_14_PLUS and not slots:
|
|
pytest.xfail("References are changing a lot in 3.14.")
|
|
|
|
@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.
|
|
"""
|
|
if PY_3_14_PLUS and not slots:
|
|
pytest.xfail("Forward references are changing a lot in 3.14.")
|
|
|
|
@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)
|