Repository: jendrikseipp/vulture

Issue:
Ignore type checking imports
Code like this is common when type annotations in comments are used in Python:

```
from typing import TYPE_CHECKING
if TYPE_CHECKING:
	from typing import Optional, Iterator
	from abc import xyz
```
These will usually show up as `unused import` as they are only referenced from comments.

The code should probably be parsed with a `TYPE_CHECKING=False` in mind.

Discussion:
jingw: Now that I look around, I see flake8 complains about bad type comments by default, without any obvious option to disable that. I'll go ahead and make the proposed change.

Originally, I decided to mirror the logic in https://bugs.python.org/issue35766 (the PR that added type_comments to ast.parse)
> By default, `ast.parse()` should not return type comments, since this would reject some perfectly good Python code (with sonething looking like a type comment in a place where the grammar doesn’t allow it). But passing an new flag will cause the tokenizer to process type comments and the returned tree will contain them.

That said, I imagine vulture does not need to be as careful as the standard library with regards to backwards compatibility.
jingw: Done! I've made it automatically gate on Python 3.8+

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9bae76c..deb0d13 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,8 @@
 * Consider all files under `test` or `tests` directories test files
   (Jendrik Seipp).
 * Ignore `logging.Logger.propagate` attribute (Jendrik Seipp).
+* Parse mypy / PEP 484 / PEP 526 `# type: ...` comments if on Python 3.8+.
+  (jingw, #220)
 
 # 1.6 (2020-07-28)
 
diff --git a/vulture/core.py b/vulture/core.py
index bb5925f..b53c065 100644
--- a/vulture/core.py
+++ b/vulture/core.py
@@ -203,15 +203,23 @@ class Vulture(ast.NodeVisitor):
         self.code = code.splitlines()
         self.noqa_lines = noqa.parse_noqa(self.code)
         self.filename = filename
-        try:
-            node = ast.parse(code, filename=self.filename)
-        except SyntaxError as err:
-            text = f' at "{err.text.strip()}"' if err.text else ""
+
+        def handle_syntax_error(e):
+            text = f' at "{e.text.strip()}"' if e.text else ""
             print(
-                f"{utils.format_path(filename)}:{err.lineno}: {err.msg}{text}",
+                f"{utils.format_path(filename)}:{e.lineno}: {e.msg}{text}",
                 file=sys.stderr,
             )
             self.found_dead_code_or_error = True
+
+        try:
+            node = (
+                ast.parse(code, filename=self.filename, type_comments=True)
+                if sys.version_info >= (3, 8)  # type_comments requires 3.8+
+                else ast.parse(code, filename=self.filename)
+            )
+        except SyntaxError as err:
+            handle_syntax_error(err)
         except ValueError as err:
             # ValueError is raised if source contains null bytes.
             print(
@@ -220,7 +228,11 @@ class Vulture(ast.NodeVisitor):
             )
             self.found_dead_code_or_error = True
         else:
-            self.visit(node)
+            # When parsing type comments, visiting can throw SyntaxError.
+            try:
+                self.visit(node)
+            except SyntaxError as err:
+                handle_syntax_error(err)
 
     def scavenge(self, paths, exclude=None):
         def prepare_pattern(pattern):
@@ -603,6 +615,20 @@ class Vulture(ast.NodeVisitor):
             self._log(lineno, ast.dump(node), line)
         if visitor:
             visitor(node)
+
+        # There isn't a clean subset of node types that might have type
+        # comments, so just check all of them.
+        type_comment = getattr(node, "type_comment", None)
+        if type_comment is not None:
+            mode = (
+                "func_type"
+                if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
+                else "eval"
+            )
+            self.visit(
+                ast.parse(type_comment, filename="<type_comment>", mode=mode)
+            )
+
         return self.generic_visit(node)
 
     def _handle_ast_list(self, ast_list):


Test patch:
diff --git a/tests/test_scavenging.py b/tests/test_scavenging.py
index 86f4558..22a4a88 100644
--- a/tests/test_scavenging.py
+++ b/tests/test_scavenging.py
@@ -1,3 +1,5 @@
+import sys
+
 from . import check, v
 
 assert v  # Silence pyflakes.
@@ -644,16 +646,46 @@ x: List[int] = [1]
 def test_type_hint_comments(v):
     v.scan(
         """\
-import typing
+from typing import Any, Dict, List, Text, Tuple
+
+
+def plain_function(arg):
+    # type: (Text) -> None
+    pass
+
+async def async_function(arg):
+    # type: (List[int]) -> None
+    pass
+
+some_var = {}  # type: Dict[str, str]
+
+class Thing:
+    def __init__(self):
+        self.some_attr = (1, 2)  # type: Tuple[int, int]
+
+for x in []:  # type: Any
+    print(x)
+"""
+    )
 
-if typing.TYPE_CHECKING:
-    from typing import List, Text
+    if sys.version_info < (3, 8):
+        check(v.unused_imports, ["Any", "Dict", "List", "Text", "Tuple"])
+    else:
+        check(v.unused_imports, [])
+        assert not v.found_dead_code_or_error
 
-def foo(foo_li):
-    # type: (List[Text]) -> None
-    for bar in foo_li:
-        bar.xyz()
+
+def test_invalid_type_comment(v):
+    v.scan(
+        """\
+def bad():
+    # type: bogus
+    pass
+bad()
 """
     )
 
-    check(v.unused_imports, ["List", "Text"])
+    if sys.version_info < (3, 8):
+        assert not v.found_dead_code_or_error
+    else:
+        assert v.found_dead_code_or_error