Repository: python-attrs/attrs (python)

Issue:
Deferred type annotations are evaluated in the wrong execution context
To reproduce, run the following program:
```python
import attr
from typing import List, get_type_hints

@attr.s
class C:
    x = attr.ib(type='List[int]')

print(get_type_hints(C.__init__))
```

#### Expected result
```
{'return': <class 'NoneType'>, 'x': typing.List[int]}
```

#### Actual result
```
Traceback (most recent call last):
  File "example.py", line 8, in <module>
    print(get_type_hints(C.__init__))
  File "C:\Python37\lib\typing.py", line 1001, in get_type_hints
    value = _eval_type(value, globalns, localns)
  File "C:\Python37\lib\typing.py", line 260, in _eval_type
    return t._evaluate(globalns, localns)
  File "C:\Python37\lib\typing.py", line 464, in _evaluate
    eval(self.__forward_code__, globalns, localns),
  File "<string>", line 1, in <module>
NameError: name 'List' is not defined
```

This annotation should be evaluated in the context of the module where it's defined, where `List` is in globals.

#### Version info
```
attrs 19.3.0
Python 3.7.3 (v3.7.3:ef4ec6ed12, Mar 25 2019, 22:22:05) [MSC v.1916 64 bit (AMD64)] on win32
```

Pull request description:
Fix issue with get_type_hints(cls.__init__) and refactor
This fixes #593 

I factored out the call to eval(compile... and added a generic `_make_method` method to remove repeated code.

# Pull Request Check List

This is just a friendly reminder about the most common mistakes.  Please make sure that you tick all boxes.  But please read our [contribution guide](https://www.attrs.org/en/latest/contributing.html) at least once, it will save you unnecessary review cycles!

If an item doesn't apply to your pull request, **check it anyway** to make it apparent that there's nothing left to do.  If your pull request is a documentation fix or a trivial typo, feel free to delete the whole thing.

- [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/strategies.py).
- [x] Changes or additions to public APIs are reflected in our type stubs (files ending in ``.pyi``).
    - [ ] ...and used in the stub test file `tests/typing_example.py`.
- [x] Updated **documentation** for changed code.
    - [ ] New functions/classes have to be added to `docs/api.rst` by hand.
    - [ ] Changes to the signature of `@attr.s()` have to be added by hand too.
    - [ ] Changed/added classes/methods/functions have appropriate `versionadded`, `versionchanged`, or `deprecated` [directives](http://www.sphinx-doc.org/en/stable/markup/para.html#directive-versionadded).  Find the appropriate next version in our [``__init__.py``](https://github.com/python-attrs/attrs/blob/master/src/attr/__init__.py) file.
- [ ] Documentation in `.rst` files is written using [semantic newlines](https://rhodesmill.org/brandon/2012/one-sentence-per-line/).
- [ ] 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/src/attr/_make.py b/src/attr/_make.py
index 8bc8634..76b1c62 100644
--- a/src/attr/_make.py
+++ b/src/attr/_make.py
@@ -286,6 +286,36 @@ def attrib(
     )
 
 
+def _compile_and_eval(script, globs, locs=None, filename=""):
+    """
+    "Exec" the script with the given global (globs) and local (locs) variables.
+    """
+    bytecode = compile(script, filename, "exec")
+    eval(bytecode, globs, locs)
+
+
+def _make_method(name, script, filename, globs=None):
+    """
+    Create the method with the script given and return the method object.
+    """
+    locs = {}
+    if globs is None:
+        globs = {}
+
+    _compile_and_eval(script, globs, locs, filename)
+
+    # In order of debuggers like PDB being able to step through the code,
+    # we add a fake linecache entry.
+    linecache.cache[filename] = (
+        len(script),
+        None,
+        script.splitlines(True),
+        filename,
+    )
+
+    return locs[name]
+
+
 def _make_attr_tuple_class(cls_name, attr_names):
     """
     Create a tuple subclass to hold `Attribute`s for an `attrs` class.
@@ -309,8 +339,7 @@ def _make_attr_tuple_class(cls_name, attr_names):
     else:
         attr_class_template.append("    pass")
     globs = {"_attrs_itemgetter": itemgetter, "_attrs_property": property}
-    eval(compile("\n".join(attr_class_template), "", "exec"), globs)
-
+    _compile_and_eval("\n".join(attr_class_template), globs)
     return globs[attr_class_name]
 
 
@@ -1591,21 +1620,7 @@ def _make_hash(cls, attrs, frozen, cache_hash):
         append_hash_computation_lines("return ", tab)
 
     script = "\n".join(method_lines)
-    globs = {}
-    locs = {}
-    bytecode = compile(script, unique_filename, "exec")
-    eval(bytecode, globs, locs)
-
-    # In order of debuggers like PDB being able to step through the code,
-    # we add a fake linecache entry.
-    linecache.cache[unique_filename] = (
-        len(script),
-        None,
-        script.splitlines(True),
-        unique_filename,
-    )
-
-    return locs["__hash__"]
+    return _make_method("__hash__", script, unique_filename)
 
 
 def _add_hash(cls, attrs):
@@ -1661,20 +1676,7 @@ def _make_eq(cls, attrs):
         lines.append("    return True")
 
     script = "\n".join(lines)
-    globs = {}
-    locs = {}
-    bytecode = compile(script, unique_filename, "exec")
-    eval(bytecode, globs, locs)
-
-    # In order of debuggers like PDB being able to step through the code,
-    # we add a fake linecache entry.
-    linecache.cache[unique_filename] = (
-        len(script),
-        None,
-        script.splitlines(True),
-        unique_filename,
-    )
-    return locs["__eq__"]
+    return _make_method("__eq__", script, unique_filename)
 
 
 def _make_order(cls, attrs):
@@ -1949,8 +1951,10 @@ def _make_init(
         has_global_on_setattr,
         attrs_init,
     )
-    locs = {}
-    bytecode = compile(script, unique_filename, "exec")
+    if cls.__module__ in sys.modules:
+        # This makes typing.get_type_hints(CLS.__init__) resolve string types.
+        globs.update(sys.modules[cls.__module__].__dict__)
+
     globs.update({"NOTHING": NOTHING, "attr_dict": attr_dict})
 
     if needs_cached_setattr:
@@ -1958,18 +1962,12 @@ def _make_init(
         # setattr hooks.
         globs["_cached_setattr"] = _obj_setattr
 
-    eval(bytecode, globs, locs)
-
-    # In order of debuggers like PDB being able to step through the code,
-    # we add a fake linecache entry.
-    linecache.cache[unique_filename] = (
-        len(script),
-        None,
-        script.splitlines(True),
+    init = _make_method(
+        "__attrs_init__" if attrs_init else "__init__",
+        script,
         unique_filename,
+        globs,
     )
-
-    init = locs["__attrs_init__"] if attrs_init else locs["__init__"]
     init.__annotations__ = annotations
 
     return init


Test patch:
diff --git a/tests/test_annotations.py b/tests/test_annotations.py
index 3e19aa1..0b27099 100644
--- a/tests/test_annotations.py
+++ b/tests/test_annotations.py
@@ -578,3 +578,32 @@ class TestAnnotations:
 
         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 typing.get_type_hints(C.__init__) == {
+            "return": type(None),
+            "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__)