Repository: PyCQA/pyflakes (python)

Issue:
Undefined name when using variable annotations
Please consider the following file:

```python
Type: object
def foo(x: Type) -> None: ...
```

When checking it with '2.1.1 Python 3.8.0 on Linux' (or Python 3.7.3) I get the following error:

```
foo.py:2: undefined name 'Type'
```

This is problematic when checking python stub files.

Pull request description:
Fix undefined name in annotations
Variable annotations without a value don't create a name, but they still
can be used as variable annotation themselves as long as the annotation
is quoted or "from __future__ import annotations" is used.

The implementation introduces a new binding "Annotation" for these kinds
of variable annotations. This can potentially be used for further
annotation-related checks in the future.

Annotation handling is extended to be able to detect both forms of postponed
annotations (quoted and future import) during checks.

Fixes #486

Patch:
diff --git a/.appveyor.yml b/.appveyor.yml
index 1738253..2d907d6 100644
--- a/.appveyor.yml
+++ b/.appveyor.yml
@@ -2,12 +2,12 @@
 install:
   - python -m pip install --upgrade tox virtualenv
 
-  - ps: (New-Object Net.WebClient).DownloadFile('https://bitbucket.org/pypy/pypy/downloads/pypy2.7-v7.1.1-win32.zip', "$env:appveyor_build_folder\pypy2.7-v7.1.1-win32.zip")
+  - ps: (New-Object Net.WebClient).DownloadFile('https://downloads.python.org/pypy/pypy2.7-v7.1.1-win32.zip', "$env:appveyor_build_folder\pypy2.7-v7.1.1-win32.zip")
   - ps: 7z x pypy2.7-v7.1.1-win32.zip | Out-Null
   - move pypy2.7-v7.1.1-win32 C:\
   - 'SET PATH=C:\pypy2.7-v7.1.1-win32\;%PATH%'
 
-  - ps: (New-Object Net.WebClient).DownloadFile('https://bitbucket.org/pypy/pypy/downloads/pypy3.6-v7.1.1-win32.zip', "$env:appveyor_build_folder\pypy3.6-v7.1.1-win32.zip")
+  - ps: (New-Object Net.WebClient).DownloadFile('https://downloads.python.org/pypy/pypy3.6-v7.1.1-win32.zip', "$env:appveyor_build_folder\pypy3.6-v7.1.1-win32.zip")
   - ps: 7z x pypy3.6-v7.1.1-win32.zip | Out-Null
   - move pypy3.6-v7.1.1-win32 C:\
   - 'SET PATH=C:\pypy3.6-v7.1.1-win32\;%PATH%'
diff --git a/.travis.yml b/.travis.yml
index e51b2e0..6babe1d 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -10,6 +10,8 @@ matrix:
     - python: pypy3
     - python: 3.7
       dist: xenial
+    - python: 3.8
+      dist: xenial
     - python: nightly
       dist: xenial
 # TODO: https://bugs.python.org/issue40334
diff --git a/README.rst b/README.rst
index ca802a8..fb13fdf 100644
--- a/README.rst
+++ b/README.rst
@@ -9,7 +9,7 @@ parsing the source file, not importing it, so it is safe to use on
 modules with side effects.  It's also much faster.
 
 It is `available on PyPI <https://pypi.org/project/pyflakes/>`_
-and it supports all active versions of Python: 2.7 and 3.4 to 3.7.
+and it supports all active versions of Python: 2.7 and 3.4 to 3.8.
 
 
 
diff --git a/pyflakes/checker.py b/pyflakes/checker.py
index 5af956c..16c783f 100644
--- a/pyflakes/checker.py
+++ b/pyflakes/checker.py
@@ -79,6 +79,10 @@ else:
     LOOP_TYPES = (ast.While, ast.For)
     FUNCTION_TYPES = (ast.FunctionDef,)
 
+if PY36_PLUS:
+    ANNASSIGN_TYPES = (ast.AnnAssign,)
+else:
+    ANNASSIGN_TYPES = ()
 
 if PY38_PLUS:
     def _is_singleton(node):  # type: (ast.AST) -> bool
@@ -528,6 +532,16 @@ class Assignment(Binding):
     """
 
 
+class Annotation(Binding):
+    """
+    Represents binding a name to a type without an associated value.
+
+    As long as this name is not assigned a value in another binding, it is considered
+    undefined for most purposes. One notable exception is using the name as a type
+    annotation.
+    """
+
+
 class FunctionDefinition(Definition):
     pass
 
@@ -542,7 +556,7 @@ class ExportBinding(Binding):
     can be determined statically, they will be treated as names for export and
     additional checking applied to them.
 
-    The only recognized C{__all__} assignment via list concatenation is in the
+    The only recognized C{__all__} assignment via list/tuple concatenation is in the
     following format:
 
         __all__ = ['a'] + ['b'] + ['c']
@@ -564,10 +578,10 @@ class ExportBinding(Binding):
 
         if isinstance(source.value, (ast.List, ast.Tuple)):
             _add_to_names(source.value)
-        # If concatenating lists
+        # If concatenating lists or tuples
         elif isinstance(source.value, ast.BinOp):
             currentValue = source.value
-            while isinstance(currentValue.right, ast.List):
+            while isinstance(currentValue.right, (ast.List, ast.Tuple)):
                 left = currentValue.left
                 right = currentValue.right
                 _add_to_names(right)
@@ -575,7 +589,7 @@ class ExportBinding(Binding):
                 if isinstance(left, ast.BinOp):
                     currentValue = left
                 # If just two lists are being added
-                elif isinstance(left, ast.List):
+                elif isinstance(left, (ast.List, ast.Tuple)):
                     _add_to_names(left)
                     # All lists accounted for - done
                     break
@@ -732,6 +746,12 @@ def is_typing_overload(value, scope_stack):
     )
 
 
+class AnnotationState:
+    NONE = 0
+    STRING = 1
+    BARE = 2
+
+
 def in_annotation(func):
     @functools.wraps(func)
     def in_annotation_func(self, *args, **kwargs):
@@ -740,6 +760,14 @@ def in_annotation(func):
     return in_annotation_func
 
 
+def in_string_annotation(func):
+    @functools.wraps(func)
+    def in_annotation_func(self, *args, **kwargs):
+        with self._enter_annotation(AnnotationState.STRING):
+            return func(self, *args, **kwargs)
+    return in_annotation_func
+
+
 def make_tokens(code):
     # PY3: tokenize.tokenize requires readline of bytes
     if not isinstance(code, bytes):
@@ -826,7 +854,7 @@ class Checker(object):
     nodeDepth = 0
     offset = None
     traceTree = False
-    _in_annotation = False
+    _in_annotation = AnnotationState.NONE
     _in_typing_literal = False
     _in_deferred = False
 
@@ -1146,8 +1174,11 @@ class Checker(object):
                     # iteration
                     continue
 
-            if (name == 'print' and
-                    isinstance(scope.get(name, None), Builtin)):
+            binding = scope.get(name, None)
+            if isinstance(binding, Annotation) and not self._in_postponed_annotation:
+                continue
+
+            if name == 'print' and isinstance(binding, Builtin):
                 parent = self.getParent(node)
                 if (isinstance(parent, ast.BinOp) and
                         isinstance(parent.op, ast.RShift)):
@@ -1222,7 +1253,9 @@ class Checker(object):
                     break
 
         parent_stmt = self.getParent(node)
-        if isinstance(parent_stmt, (FOR_TYPES, ast.comprehension)) or (
+        if isinstance(parent_stmt, ANNASSIGN_TYPES) and parent_stmt.value is None:
+            binding = Annotation(name, node)
+        elif isinstance(parent_stmt, (FOR_TYPES, ast.comprehension)) or (
                 parent_stmt != node._pyflakes_parent and
                 not self.isLiteralTupleUnpacking(parent_stmt)):
             binding = Binding(name, node)
@@ -1265,13 +1298,20 @@ class Checker(object):
                 self.report(messages.UndefinedName, node, name)
 
     @contextlib.contextmanager
-    def _enter_annotation(self):
-        orig, self._in_annotation = self._in_annotation, True
+    def _enter_annotation(self, ann_type=AnnotationState.BARE):
+        orig, self._in_annotation = self._in_annotation, ann_type
         try:
             yield
         finally:
             self._in_annotation = orig
 
+    @property
+    def _in_postponed_annotation(self):
+        return (
+            self._in_annotation == AnnotationState.STRING or
+            self.annotationsFutureEnabled
+        )
+
     def _handle_type_comments(self, node):
         for (lineno, col_offset), comment in self._type_comments.get(node, ()):
             comment = comment.split(':', 1)[1].strip()
@@ -1399,7 +1439,7 @@ class Checker(object):
         self.popScope()
         self.scopeStack = saved_stack
 
-    @in_annotation
+    @in_string_annotation
     def handleStringAnnotation(self, s, node, ref_lineno, ref_col_offset, err):
         try:
             tree = ast.parse(s)
@@ -1611,7 +1651,7 @@ class Checker(object):
             len(node.args) >= 1 and
             isinstance(node.args[0], ast.Str)
         ):
-            with self._enter_annotation():
+            with self._enter_annotation(AnnotationState.STRING):
                 self.handleNode(node.args[0], node)
 
         self.handleChildren(node)
@@ -2224,11 +2264,7 @@ class Checker(object):
             self.scope[node.name] = prev_definition
 
     def ANNASSIGN(self, node):
-        if node.value:
-            # Only bind the *targets* if the assignment has a value.
-            # Otherwise it's not really ast.Store and shouldn't silence
-            # UndefinedLocal warnings.
-            self.handleNode(node.target, node)
+        self.handleNode(node.target, node)
         self.handleAnnotation(node.annotation, node)
         if node.value:
             # If the assignment has value, handle the *value* now.
diff --git a/setup.py b/setup.py
index 5e2088e..e4d709d 100755
--- a/setup.py
+++ b/setup.py
@@ -58,6 +58,7 @@ setup(
         "Programming Language :: Python :: 3.5",
         "Programming Language :: Python :: 3.6",
         "Programming Language :: Python :: 3.7",
+        "Programming Language :: Python :: 3.8",
         "Programming Language :: Python :: Implementation :: CPython",
         "Programming Language :: Python :: Implementation :: PyPy",
         "Topic :: Software Development",
diff --git a/tox.ini b/tox.ini
index f2256c8..7365833 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,7 +1,7 @@
 [tox]
 skip_missing_interpreters = True
 envlist =
-    py27,py34,py35,py36,py37,pypy,pypy3
+    py27,py34,py35,py36,py37,py38,pypy,pypy3
 
 [testenv]
 deps = flake8==3.6.0


Test patch:
diff --git a/pyflakes/test/test_imports.py b/pyflakes/test/test_imports.py
index 13e7bef..d5be269 100644
--- a/pyflakes/test/test_imports.py
+++ b/pyflakes/test/test_imports.py
@@ -1084,7 +1084,7 @@ class TestSpecialAll(TestCase):
             __all__ += ['c', 'd']
         ''', m.UndefinedExport, m.UndefinedExport)
 
-    def test_concatenationAssignment(self):
+    def test_list_concatenation_assignment(self):
         """
         The C{__all__} variable is defined through list concatenation.
         """
@@ -1093,6 +1093,15 @@ class TestSpecialAll(TestCase):
         __all__ = ['a'] + ['b'] + ['c']
         ''', m.UndefinedExport, m.UndefinedExport, m.UndefinedExport, m.UnusedImport)
 
+    def test_tuple_concatenation_assignment(self):
+        """
+        The C{__all__} variable is defined through tuple concatenation.
+        """
+        self.flakes('''
+        import sys
+        __all__ = ('a',) + ('b',) + ('c',)
+        ''', m.UndefinedExport, m.UndefinedExport, m.UndefinedExport, m.UnusedImport)
+
     def test_all_with_attributes(self):
         self.flakes('''
         from foo import bar
diff --git a/pyflakes/test/test_type_annotations.py b/pyflakes/test/test_type_annotations.py
index ed28127..eff222b 100644
--- a/pyflakes/test/test_type_annotations.py
+++ b/pyflakes/test/test_type_annotations.py
@@ -263,6 +263,14 @@ class TestTypeAnnotations(TestCase):
         class A: pass
         ''')
         self.flakes('''
+        T: object
+        def f(t: T): pass
+        ''', m.UndefinedName)
+        self.flakes('''
+        T: object
+        def g(t: 'T'): pass
+        ''')
+        self.flakes('''
         a: 'A B'
         ''', m.ForwardAnnotationSyntaxError)
         self.flakes('''
@@ -275,6 +283,26 @@ class TestTypeAnnotations(TestCase):
         a: 'a: "A"'
         ''', m.ForwardAnnotationSyntaxError)
 
+    @skipIf(version_info < (3, 6), 'new in Python 3.6')
+    def test_unused_annotation(self):
+        # Unused annotations are fine in module and class scope
+        self.flakes('''
+        x: int
+        class Cls:
+            y: int
+        ''')
+        # TODO: this should print a UnusedVariable message
+        self.flakes('''
+        def f():
+            x: int
+        ''')
+        # This should only print one UnusedVariable message
+        self.flakes('''
+        def f():
+            x: int
+            x = 3
+        ''', m.UnusedVariable)
+
     @skipIf(version_info < (3, 5), 'new in Python 3.5')
     def test_annotated_async_def(self):
         self.flakes('''
@@ -300,6 +328,13 @@ class TestTypeAnnotations(TestCase):
         class B: pass
         ''', m.UndefinedName)
 
+        self.flakes('''
+        from __future__ import annotations
+        T: object
+        def f(t: T): pass
+        def g(t: 'T'): pass
+        ''')
+
     def test_typeCommentsMarkImportsAsUsed(self):
         self.flakes("""
         from mod import A, B, C, D, E, F, G
@@ -552,3 +587,47 @@ class TestTypeAnnotations(TestCase):
             def f() -> Optional['Queue[str]']:
                 return None
         """)
+
+    def test_idomiatic_typing_guards(self):
+        # typing.TYPE_CHECKING: python3.5.3+
+        self.flakes("""
+            from typing import TYPE_CHECKING
+
+            if TYPE_CHECKING:
+                from t import T
+
+            def f():  # type: () -> T
+                pass
+        """)
+        # False: the old, more-compatible approach
+        self.flakes("""
+            if False:
+                from t import T
+
+            def f():  # type: () -> T
+                pass
+        """)
+        # some choose to assign a constant and do it that way
+        self.flakes("""
+            MYPY = False
+
+            if MYPY:
+                from t import T
+
+            def f():  # type: () -> T
+                pass
+        """)
+
+    def test_typing_guard_for_protocol(self):
+        self.flakes("""
+            from typing import TYPE_CHECKING
+
+            if TYPE_CHECKING:
+                from typing import Protocol
+            else:
+                Protocol = object
+
+            class C(Protocol):
+                def f():  # type: () -> int
+                    pass
+        """)