Repository: python-attrs/attrs (python)

Issue:
Forward references
Hello, there's some food for thought. Imagine a class referencing itself.

```
@attr.s
class A:
    a: 'A' = attr.ib()
```

This is the proper way of doing this as per mypy. In the case of attrs though:

```
>>> attr.fields(A).a
Attribute(name='a', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, convert=None, metadata=mappingproxy({}), type='A')
```

The string just gets copied into the type attribute. `A.__annotations__` has the same problem. Ideally the type would be the actual class object. Mypy deals with this by having more than one pass when parsing.

The first question to answer if whether we should deal with this here, in attrs, or let code making use of the type attribute deal with it. Maybe instead of expecting higher levels to deal with this, Python itself should handle it (although good luck waiting for this :). I think it's worth trying to deal with this problem in attrs, to make it easier for others.

Dealing with just a class referencing itself is very doable. Now imagine circular references.

```
@attr.s
class A:
    b: 'B' = attr.ib()

@attr.s
class B:
    a: A = attr.ib()
```

Not really sure what to do here.

Pull request description:
Add attr.resolve_types
This adds `attr.resolve_types` which can be used to resolve forward declarations in classes created using `__annotations__`

Fixes #265

# Pull Request Check List

This is just a reminder about the most common mistakes.  Please make sure that you tick all *appropriate* boxes.  But please read our [contribution guide](http://www.attrs.org/en/latest/contributing.html) at least once, it will save you unnecessary review cycles!

- [x] Added **tests** for changed code.
- [ ] New features have been added to our [Hypothesis testing strategy](https://github.com/python-attrs/attrs/blob/master/tests/utils.py).
- [x] Updated **documentation** for changed code.
- [x] Documentation in `.rst` files is written using [semantic newlines](http://rhodesmill.org/brandon/2012/one-sentence-per-line/).
- [x] Changed/added classes/methods/functions have appropriate `versionadded`, `versionchanged`, or `deprecated` [directives](http://www.sphinx-doc.org/en/stable/markup/para.html#directive-versionadded).
- [x] Changes (and possible deprecations) have news fragments in [`changelog.d`](https://github.com/python-attrs/attrs/blob/master/changelog.d).

If you have *any* questions to *any* of the points above, just **submit and ask**!  This checklist is here to *help* you, not to deter you from contributing!


Patch:
diff --git a/changelog.d/302.change.rst b/changelog.d/302.change.rst
new file mode 100644
index 0000000..fe56ff1
--- /dev/null
+++ b/changelog.d/302.change.rst
@@ -0,0 +1,2 @@
+Added ``attr.resolve_types()``.
+This function lets you resolve forward references that might exist in your annotations.
diff --git a/docs/api.rst b/docs/api.rst
index 0f88833..2c406ac 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -248,6 +248,33 @@ Helpers
       >>> attr.astuple(C(1,2))
       (1, 2)
 
+.. autofunction:: attr.resolve_types
+
+    For example:
+
+    .. doctest::
+
+        >>> import typing
+        >>> @attr.s(auto_attribs=True)
+        ... class A:
+        ...     a: typing.List['A']
+        ...     b: 'B'
+        ...
+        >>> @attr.s(auto_attribs=True)
+        ... class B:
+        ...     a: A
+        ...
+        >>> attr.fields(A).a.type
+        typing.List[ForwardRef('A')]
+        >>> attr.fields(A).b.type
+        'B'
+        >>> attr.resolve_types(A, globals(), locals())
+        <class 'A'>
+        >>> attr.fields(A).a.type
+        typing.List[A]
+        >>> attr.fields(A).b.type
+        <class 'B'>
+
 ``attrs`` includes some handy helpers for filtering:
 
 .. autofunction:: attr.filters.include
diff --git a/docs/examples.rst b/docs/examples.rst
index e2d2806..508aa9f 100644
--- a/docs/examples.rst
+++ b/docs/examples.rst
@@ -530,6 +530,33 @@ If you don't mind annotating *all* attributes, you can even drop the `attr.ib` a
 
 The generated ``__init__`` method will have an attribute called ``__annotations__`` that contains this type information.
 
+If your annotations contain strings (e.g. forward references),
+you can resolve these after all references have been defined by using :func:`attr.resolve_types`.
+This will replace the *type* attribute in the respective fields.
+
+.. doctest::
+
+    >>> import typing
+    >>> @attr.s(auto_attribs=True)
+    ... class A:
+    ...     a: typing.List['A']
+    ...     b: 'B'
+    ...
+    >>> @attr.s(auto_attribs=True)
+    ... class B:
+    ...     a: A
+    ...
+    >>> attr.fields(A).a.type
+    typing.List[ForwardRef('A')]
+    >>> attr.fields(A).b.type
+    'B'
+    >>> attr.resolve_types(A, globals(), locals())
+    <class 'A'>
+    >>> attr.fields(A).a.type
+    typing.List[A]
+    >>> attr.fields(A).b.type
+    <class 'B'>
+
 .. warning::
 
    ``attrs`` itself doesn't have any features that work on top of type metadata *yet*.
diff --git a/src/attr/__init__.py b/src/attr/__init__.py
index 24327d0..a37ee4d 100644
--- a/src/attr/__init__.py
+++ b/src/attr/__init__.py
@@ -14,6 +14,7 @@ from ._make import (
     fields,
     fields_dict,
     make_class,
+    resolve_types,
     validate,
 )
 from ._version_info import VersionInfo
@@ -61,6 +62,7 @@ __all__ = [
     "has",
     "ib",
     "make_class",
+    "resolve_types",
     "s",
     "set_run_validators",
     "setters",
diff --git a/src/attr/__init__.pyi b/src/attr/__init__.pyi
index 6cbf48b..5f3b553 100644
--- a/src/attr/__init__.pyi
+++ b/src/attr/__init__.pyi
@@ -235,6 +235,11 @@ class _Fields(Tuple[Attribute[Any], ...]):
 def fields(cls: type) -> _Fields: ...
 def fields_dict(cls: type) -> Dict[str, Attribute[Any]]: ...
 def validate(inst: Any) -> None: ...
+def resolve_types(
+    cls: _C,
+    globalns: Optional[Dict[str, Any]] = ...,
+    localns: Optional[Dict[str, Any]] = ...,
+) -> _C: ...
 
 # 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',
diff --git a/src/attr/_make.py b/src/attr/_make.py
index 68cdf43..1c50968 100644
--- a/src/attr/_make.py
+++ b/src/attr/_make.py
@@ -1707,6 +1707,45 @@ def fields_dict(cls):
     return ordered_dict(((a.name, a) for a in attrs))
 
 
+def resolve_types(cls, globalns=None, localns=None):
+    """
+    Resolve any strings and forward annotations in type annotations.
+
+    With no arguments, names will be looked up in the module in which the class
+    was created.  If this is incorrect, e.g. if the name only exists inside a
+    method, you may pass globalns or localns to specify other dictionaries in
+    which to look up these names. See the docs of `typing.get_type_hints` for
+    more details.
+
+    :param type cls: Class to resolve.
+    :param globalns: Dictionary containing global variables, if needed.
+    :param localns: Dictionary containing local variables, if needed.
+
+    :raise TypeError: If *cls* is not a class.
+    :raise attr.exceptions.NotAnAttrsClassError: If *cls* is not an ``attrs``
+        class.
+    :raise NameError: If types cannot be resolved because of missing variables.
+
+    ..  versionadded:: 19.4.0
+    """
+    try:
+        # Since calling get_type_hints is expensive we cache whether we've
+        # done it already.
+        cls.__attrs_types_resolved__
+    except AttributeError:
+        import typing
+
+        hints = typing.get_type_hints(cls, globalns=globalns, localns=localns)
+        for field in fields(cls):
+            if field.name in hints:
+                # Since fields have been frozen we must work around it.
+                _obj_setattr(field, "type", hints[field.name])
+        cls.__attrs_types_resolved__ = True
+
+    # Return the class so you can use it as a decorator too.
+    return cls
+
+
 def validate(inst):
     """
     Validate all attributes on *inst* that have a validator.


Test patch:
diff --git a/tests/test_annotations.py b/tests/test_annotations.py
index 43b4d0f..f85b5d9 100644
--- a/tests/test_annotations.py
+++ b/tests/test_annotations.py
@@ -297,3 +297,107 @@ class TestAnnotations:
         @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()
+
+        assert "int" == attr.fields(C).x.type
+        assert str is attr.fields(C).y.type
+        assert None is attr.fields(C).z.type
+
+        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.parametrize("slots", [True, False])
+    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]"
+
+        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
+
+        # 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
+
+    @pytest.mark.parametrize("slots", [True, False])
+    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
+
+    @pytest.mark.parametrize("slots", [True, False])
+    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"]  # noqa: will resolve below
+
+        assert "A" == attr.fields(A).a.type
+        assert typing.Optional["A"] == attr.fields(A).b.type
+
+        attr.resolve_types(A, globals(), locals())
+
+        assert A == attr.fields(A).a.type
+        assert typing.Optional[A] == attr.fields(A).b.type
+
+    @pytest.mark.parametrize("slots", [True, False])
+    def test_forward_reference(self, slots):
+        """
+        Forward references can be resolved.
+        """
+
+        @attr.s(slots=slots, auto_attribs=True)
+        class A:
+            a: typing.List["B"]  # noqa: will resolve below
+
+        @attr.s(slots=slots, auto_attribs=True)
+        class B:
+            a: A
+
+        assert typing.List["B"] == attr.fields(A).a.type
+        assert A == attr.fields(B).a.type
+
+        attr.resolve_types(A, globals(), locals())
+
+        assert typing.List[B] == attr.fields(A).a.type
+        assert A == attr.fields(B).a.type