Skip to content
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

Flesh out resolve_types #1099

Merged
merged 7 commits into from
Mar 18, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1099.change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`attrs.resolve_types()` can now pass `include_extras` to `typing.get_type_hints()` on Python 3.9+, and does so by default.
6 changes: 4 additions & 2 deletions src/attr/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ _ValidatorArgType = Union[_ValidatorType[_T], Sequence[_ValidatorType[_T]]]
class AttrsInstance(AttrsInstance_, Protocol):
pass

_A = TypeVar("_A", bound=AttrsInstance)
# _make --

class _Nothing(enum.Enum):
Expand Down Expand Up @@ -488,11 +489,12 @@ def fields(cls: Type[AttrsInstance]) -> Any: ...
def fields_dict(cls: Type[AttrsInstance]) -> Dict[str, Attribute[Any]]: ...
def validate(inst: AttrsInstance) -> None: ...
def resolve_types(
cls: _C,
cls: _A,
globalns: Optional[Dict[str, Any]] = ...,
localns: Optional[Dict[str, Any]] = ...,
attribs: Optional[List[Attribute[Any]]] = ...,
) -> _C: ...
include_extras: bool = ...,
) -> _A: ...

# TODO: add support for returning a proper attrs class from the mypy plugin
# we use Any instead of _CountingAttr so that e.g. `make_class('Foo',
Expand Down
1 change: 1 addition & 0 deletions src/attr/_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@


PYPY = platform.python_implementation() == "PyPy"
PY_3_9_PLUS = sys.version_info[:2] >= (3, 9)
PY310 = sys.version_info[:2] >= (3, 10)
PY_3_12_PLUS = sys.version_info[:2] >= (3, 12)

Expand Down
18 changes: 15 additions & 3 deletions src/attr/_funcs.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import copy

from ._compat import get_generic_base
from ._compat import PY_3_9_PLUS, get_generic_base
from ._make import NOTHING, _obj_setattr, fields
from .exceptions import AttrsAttributeNotFoundError

Expand Down Expand Up @@ -379,7 +379,9 @@ def evolve(inst, **changes):
return cls(**changes)


def resolve_types(cls, globalns=None, localns=None, attribs=None):
def resolve_types(
cls, globalns=None, localns=None, attribs=None, include_extras=True
):
"""
Resolve any strings and forward annotations in type annotations.

Expand All @@ -399,6 +401,10 @@ def resolve_types(cls, globalns=None, localns=None, attribs=None):
:param Optional[list] attribs: List of attribs for the given class.
This is necessary when calling from inside a ``field_transformer``
since *cls* is not an *attrs* class yet.
:param bool include_extras: Resolve more accurately, if possible.
Pass ``include_extras`` to ``typing.get_hints``, if supported by the
typing module. On supported Python versions (3.9+), this resolves the
types more accurately.

:raise TypeError: If *cls* is not a class.
:raise attrs.exceptions.NotAnAttrsClassError: If *cls* is not an *attrs*
Expand All @@ -411,14 +417,20 @@ class and you didn't pass any attribs.

.. versionadded:: 20.1.0
.. versionadded:: 21.1.0 *attribs*
.. versionadded:: 23.1.0 *include_extras*

"""
# Since calling get_type_hints is expensive we cache whether we've
# done it already.
if getattr(cls, "__attrs_types_resolved__", None) != cls:
import typing

hints = typing.get_type_hints(cls, globalns=globalns, localns=localns)
kwargs = {"globalns": globalns, "localns": localns}

if PY_3_9_PLUS:
kwargs["include_extras"] = include_extras

hints = typing.get_type_hints(cls, **kwargs)
for field in fields(cls) if attribs is None else attribs:
if field.name in hints:
# Since fields have been frozen we must work around it.
Expand Down
28 changes: 28 additions & 0 deletions tests/test_annotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,34 @@ class C:
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 attr.fields(C).x.type == Annotated[float, "test"]

@attr.define
class D:
x: 'Annotated[float, "test"]'

attr.resolve_types(D, globals, include_extras=False)

assert attr.fields(D).x.type == float

def test_resolve_types_auto_attrib(self, slots):
"""
Types can be resolved even when strings are involved.
Expand Down