VultureChecker/vulture/core.py

changeset 69
3c2922b45a9f
parent 66
d8a3c6c3bd68
--- a/VultureChecker/vulture/core.py	Wed Dec 30 11:02:01 2020 +0100
+++ b/VultureChecker/vulture/core.py	Sun Apr 25 16:13:53 2021 +0200
@@ -1,91 +1,79 @@
-#! /usr/bin/env python
-# -*- coding: utf-8 -*-
-#
-# vulture - Find dead code.
-#
-# Copyright (c) 2012-2018 Jendrik Seipp (jendrikseipp@gmail.com)
-#
-# Permission is hereby granted, free of charge, to any person obtaining a
-# copy of this software and associated documentation files (the
-# "Software"), to deal in the Software without restriction, including
-# without limitation the rights to use, copy, modify, merge, publish,
-# distribute, sublicense, and/or sell copies of the Software, and to
-# permit persons to whom the Software is furnished to do so, subject to
-# the following conditions:
-#
-# The above copyright notice and this permission notice shall be included
-# in all copies or substantial portions of the Software.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
-# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
-# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
-# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
-# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
-# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
-# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-
-from __future__ import print_function
-
-import argparse
 import ast
 from fnmatch import fnmatch, fnmatchcase
-import os.path
+from pathlib import Path
 import pkgutil
 import re
 import string
 import sys
 
 from vulture import lines
+from vulture import noqa
 from vulture import utils
+from vulture.config import make_config
 
-__version__ = '1.0-eric6'
 
 DEFAULT_CONFIDENCE = 60
 
-# The ast module in Python 2 trips over "coding" cookies, so strip them.
-ENCODING_REGEX = re.compile(
-    r"^[ \t\v]*#.*?coding[:=][ \t]*([-_.a-zA-Z0-9]+).*?$", flags=re.M)
+IGNORED_VARIABLE_NAMES = {"object", "self"}
 
-IGNORED_VARIABLE_NAMES = set(['object', 'self'])
-# True and False are NameConstants since Python 3.4.
-if sys.version_info < (3, 4):
-    IGNORED_VARIABLE_NAMES |= set(['True', 'False'])
+ERROR_CODES = {
+    "attribute": "V101",
+    "class": "V102",
+    "function": "V103",
+    "import": "V104",
+    "method": "V105",
+    "property": "V106",
+    "variable": "V107",
+    "unreachable_code": "V201",
+}
 
 
 def _get_unused_items(defined_items, used_names):
-    unused_items = [item for item in set(defined_items)
-                    if item.name not in used_names]
+    unused_items = [
+        item for item in set(defined_items) if item.name not in used_names
+    ]
     unused_items.sort(key=lambda item: item.name.lower())
     return unused_items
 
 
 def _is_special_name(name):
-    return name.startswith('__') and name.endswith('__')
+    return name.startswith("__") and name.endswith("__")
 
 
-def _match(name, patterns):
-    return any(fnmatchcase(name, pattern) for pattern in patterns)
+def _match(name, patterns, case=True):
+    func = fnmatchcase if case else fnmatch
+    return any(func(name, pattern) for pattern in patterns)
 
 
 def _is_test_file(filename):
-    return any(
-        fnmatch(os.path.basename(filename), pattern)
-        for pattern in ['test*.py', '*_test.py', '*-test.py'])
+    return _match(
+        filename.resolve(),
+        ["*/test/*", "*/tests/*", "*/test*.py", "*[-_]test.py"],
+        case=False,
+    )
 
 
 def _ignore_class(filename, class_name):
-    return _is_test_file(filename) and 'Test' in class_name
+    return _is_test_file(filename) and "Test" in class_name
 
 
-def _ignore_import(_filename, import_name):
-    # Ignore star-imported names, since we can't detect whether they are used.
-    return import_name == '*'
+def _ignore_import(filename, import_name):
+    """
+    Ignore star-imported names since we can't detect whether they are used.
+    Ignore imports from __init__.py files since they're commonly used to
+    collect objects from a package.
+    """
+    return filename.name == "__init__.py" or import_name == "*"
 
 
 def _ignore_function(filename, function_name):
-    return (
-        _is_special_name(function_name) or
-        (function_name.startswith('test_') and _is_test_file(filename)))
+    return function_name.startswith("test_") and _is_test_file(filename)
+
+
+def _ignore_method(filename, method_name):
+    return _is_special_name(method_name) or (
+        method_name.startswith("test_") and _is_test_file(filename)
+    )
 
 
 def _ignore_variable(filename, varname):
@@ -94,25 +82,43 @@
     __x__ (special variable or method), but not __x.
     """
     return (
-        varname in IGNORED_VARIABLE_NAMES or
-        (varname.startswith('_') and not varname.startswith('__')) or
-        _is_special_name(varname))
+        varname in IGNORED_VARIABLE_NAMES
+        or (varname.startswith("_") and not varname.startswith("__"))
+        or _is_special_name(varname)
+    )
 
 
-class Item(object):
+class Item:
     """
     Hold the name, type and location of defined code.
     """
 
-    def __init__(self, name, typ, filename, first_lineno, last_lineno,
-                 message='',
-                 confidence=DEFAULT_CONFIDENCE):
+    __slots__ = (
+        "name",
+        "typ",
+        "filename",
+        "first_lineno",
+        "last_lineno",
+        "message",
+        "confidence",
+    )
+
+    def __init__(
+        self,
+        name,
+        typ,
+        filename,
+        first_lineno,
+        last_lineno,
+        message="",
+        confidence=DEFAULT_CONFIDENCE,
+    ):
         self.name = name
         self.typ = typ
         self.filename = filename
         self.first_lineno = first_lineno
         self.last_lineno = last_lineno
-        self.message = message or "unused {typ} '{name}'".format(**locals())
+        self.message = message or f"unused {typ} '{name}'"
         self.confidence = confidence
 
     @property
@@ -122,23 +128,29 @@
 
     def get_report(self, add_size=False):
         if add_size:
-            line_format = 'line' if self.size == 1 else 'lines'
-            size_report = ', {0:d} {1}'.format(self.size, line_format)
+            line_format = "line" if self.size == 1 else "lines"
+            size_report = f", {self.size:d} {line_format}"
         else:
-            size_report = ''
-        return "{0}:{1:d}: {2} ({3}% confidence{4})".format(
-            utils.format_path(self.filename), self.first_lineno,
-            self.message, self.confidence, size_report)
+            size_report = ""
+        return "{}:{:d}: {} ({}% confidence{})".format(
+            utils.format_path(self.filename),
+            self.first_lineno,
+            self.message,
+            self.confidence,
+            size_report,
+        )
 
     def get_whitelist_string(self):
         filename = utils.format_path(self.filename)
-        if self.typ == 'unreachable_code':
-            return ('# {} ({}:{})'.format(
-                self.message, filename, self.first_lineno))
+        if self.typ == "unreachable_code":
+            return f"# {self.message} ({filename}:{self.first_lineno})"
         else:
-            prefix = '_.' if self.typ in ['attribute', 'property'] else ''
+            prefix = ""
+            if self.typ in ["attribute", "method", "property"]:
+                prefix = "_."
             return "{}{}  # unused {} ({}:{:d})".format(
-                    prefix, self.name, self.typ, filename, self.first_lineno)
+                prefix, self.name, self.typ, filename, self.first_lineno
+            )
 
     def _tuple(self):
         return (self.filename, self.first_lineno, self.name)
@@ -156,94 +168,110 @@
 class Vulture(ast.NodeVisitor):
     """Find dead code."""
 
-    def __init__(self, verbose=False, ignore_names=None,
-                 ignore_decorators=None):
+    def __init__(
+        self, verbose=False, ignore_names=None, ignore_decorators=None
+    ):
         self.verbose = verbose
 
         def get_list(typ):
             return utils.LoggingList(typ, self.verbose)
 
-        def get_set(typ):
-            return utils.LoggingSet(typ, self.verbose)
+        self.defined_attrs = get_list("attribute")
+        self.defined_classes = get_list("class")
+        self.defined_funcs = get_list("function")
+        self.defined_imports = get_list("import")
+        self.defined_methods = get_list("method")
+        self.defined_props = get_list("property")
+        self.defined_vars = get_list("variable")
+        self.unreachable_code = get_list("unreachable_code")
 
-        self.defined_attrs = get_list('attribute')
-        self.defined_classes = get_list('class')
-        self.defined_funcs = get_list('function')
-        self.defined_imports = get_list('import')
-        self.defined_props = get_list('property')
-        self.defined_slots = get_list('slot')
-        # @pyqtSlot and @Slot support - eric6
-        self.defined_vars = get_list('variable')
-        self.unreachable_code = get_list('unreachable_code')
-
-        self.used_attrs = get_set('attribute')
-        self.used_names = get_set('name')
+        self.used_names = utils.LoggingSet("name", self.verbose)
 
         self.ignore_names = ignore_names or []
         self.ignore_decorators = ignore_decorators or []
 
-        self.filename = ''
+        self.filename = Path()
         self.code = []
         self.found_dead_code_or_error = False
 
-    def scan(self, code, filename=''):
-        code = ENCODING_REGEX.sub("", code, count=1)
+    def scan(self, code, filename=""):
+        filename = Path(filename)
         self.code = code.splitlines()
+        self.noqa_lines = noqa.parse_noqa(self.code)
         self.filename = filename
+
+        def handle_syntax_error(e):
+            text = f' at "{e.text.strip()}"' if e.text else ""
+            print(
+                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)
+            node = (
+                ast.parse(
+                    code, filename=str(self.filename), type_comments=True
+                )
+                if sys.version_info >= (3, 8)  # type_comments requires 3.8+
+                else ast.parse(code, filename=str(self.filename))
+            )
         except SyntaxError as err:
-            text = ' at "{0}"'.format(err.text.strip()) if err.text else ''
-            print('{0}:{1:d}: {2}{3}'.format(
-                utils.format_path(filename), err.lineno, err.msg, text),
-                file=sys.stderr)
-            self.found_dead_code_or_error = True
-        except (TypeError, ValueError) as err:
-            # Python < 3.5 raises TypeError and Python >= 3.5 raises
-            # ValueError if source contains null bytes.
-            print('{0}: invalid source code "{1}"'.format(
-                utils.format_path(filename), err), file=sys.stderr)
+            handle_syntax_error(err)
+        except ValueError as err:
+            # ValueError is raised if source contains null bytes.
+            print(
+                f'{utils.format_path(filename)}: invalid source code "{err}"',
+                file=sys.stderr,
+            )
             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):
-            if not any(char in pattern for char in ['*', '?', '[']):
-                pattern = '*{pattern}*'.format(**locals())
+            if not any(char in pattern for char in "*?["):
+                pattern = f"*{pattern}*"
             return pattern
 
         exclude = [prepare_pattern(pattern) for pattern in (exclude or [])]
 
-        def exclude_file(name):
-            return any(fnmatch(name, pattern) for pattern in exclude)
+        def exclude_path(path):
+            return _match(path, exclude, case=False)
+
+        paths = [Path(path) for path in paths]
 
         for module in utils.get_modules(paths):
-            if exclude_file(module):
-                self._log('Excluded:', module)
+            if exclude_path(module):
+                self._log("Excluded:", module)
                 continue
 
-            self._log('Scanning:', module)
+            self._log("Scanning:", module)
             try:
                 module_string = utils.read_file(module)
-            except utils.VultureInputException as err:
+            except utils.VultureInputException as err:  # noqa: F841
                 print(
-                    'Error: Could not read file {module} - {err}\n'
-                    'Try to change the encoding to UTF-8.'.format(**locals()),
-                    file=sys.stderr)
+                    f"Error: Could not read file {module} - {err}\n"
+                    f"Try to change the encoding to UTF-8.",
+                    file=sys.stderr,
+                )
                 self.found_dead_code_or_error = True
             else:
                 self.scan(module_string, filename=module)
 
-        unique_imports = set(item.name for item in self.defined_imports)
+        unique_imports = {item.name for item in self.defined_imports}
         for import_name in unique_imports:
-            path = os.path.join('whitelists', import_name) + '_whitelist.py'
-            if exclude_file(path):
-                self._log('Excluded whitelist:', path)
+            path = Path("whitelists") / (import_name + "_whitelist.py")
+            if exclude_path(path):
+                self._log("Excluded whitelist:", path)
             else:
                 try:
-                    module_data = pkgutil.get_data('vulture', path)
-                    self._log('Included whitelist:', path)
+                    module_data = pkgutil.get_data("vulture", str(path))
+                    self._log("Included whitelist:", path)
                 except OSError:
                     # Most imported modules don't have a whitelist.
                     continue
@@ -255,68 +283,77 @@
         Return ordered list of unused Item objects.
         """
         if not 0 <= min_confidence <= 100:
-            raise ValueError('min_confidence must be between 0 and 100.')
+            raise ValueError("min_confidence must be between 0 and 100.")
 
         def by_name(item):
-            return (item.filename.lower(), item.first_lineno)
+            return (str(item.filename).lower(), item.first_lineno)
 
         def by_size(item):
             return (item.size,) + by_name(item)
 
-        unused_code = (self.unused_attrs + self.unused_classes +
-                       self.unused_funcs + self.unused_imports +
-                       self.unused_props + self.unused_vars +
-                       self.unreachable_code)
+        unused_code = (
+            self.unused_attrs
+            + self.unused_classes
+            + self.unused_funcs
+            + self.unused_imports
+            + self.unused_methods
+            + self.unused_props
+            + self.unused_vars
+            + self.unreachable_code
+        )
 
-        confidently_unused = [obj for obj in unused_code
-                              if obj.confidence >= min_confidence]
+        confidently_unused = [
+            obj for obj in unused_code if obj.confidence >= min_confidence
+        ]
 
-        return sorted(confidently_unused,
-                      key=by_size if sort_by_size else by_name)
+        return sorted(
+            confidently_unused, key=by_size if sort_by_size else by_name
+        )
 
-    def report(self, min_confidence=0, sort_by_size=False,
-               make_whitelist=False):
+    def report(
+        self, min_confidence=0, sort_by_size=False, make_whitelist=False
+    ):
         """
         Print ordered list of Item objects to stdout.
         """
         for item in self.get_unused_code(
-                min_confidence=min_confidence, sort_by_size=sort_by_size):
-            print(item.get_whitelist_string() if make_whitelist
-                  else item.get_report(add_size=sort_by_size))
+            min_confidence=min_confidence, sort_by_size=sort_by_size
+        ):
+            print(
+                item.get_whitelist_string()
+                if make_whitelist
+                else item.get_report(add_size=sort_by_size)
+            )
             self.found_dead_code_or_error = True
         return self.found_dead_code_or_error
 
     @property
     def unused_classes(self):
-        return _get_unused_items(
-            self.defined_classes,
-            self.used_attrs | self.used_names)
+        return _get_unused_items(self.defined_classes, self.used_names)
 
     @property
     def unused_funcs(self):
-        return _get_unused_items(
-            self.defined_funcs,
-            self.used_attrs | self.used_names)
+        return _get_unused_items(self.defined_funcs, self.used_names)
 
     @property
     def unused_imports(self):
-        return _get_unused_items(
-            self.defined_imports,
-            self.used_names | self.used_attrs)
+        return _get_unused_items(self.defined_imports, self.used_names)
+
+    @property
+    def unused_methods(self):
+        return _get_unused_items(self.defined_methods, self.used_names)
 
     @property
     def unused_props(self):
-        return _get_unused_items(self.defined_props, self.used_attrs)
+        return _get_unused_items(self.defined_props, self.used_names)
 
     @property
     def unused_vars(self):
-        return _get_unused_items(
-            self.defined_vars,
-            self.used_attrs | self.used_names)
+        return _get_unused_items(self.defined_vars, self.used_names)
 
     @property
     def unused_attrs(self):
-        return _get_unused_items(self.defined_attrs, self.used_attrs)
+        return _get_unused_items(self.defined_attrs, self.used_names)
 
     def _log(self, *args):
         if self.verbose:
@@ -331,54 +368,107 @@
         for name_and_alias in node.names:
             # Store only top-level module name ("os.path" -> "os").
             # We can't easily detect when "os.path" is used.
-            name = name_and_alias.name.partition('.')[0]
+            name = name_and_alias.name.partition(".")[0]
             alias = name_and_alias.asname
             self._define(
-                self.defined_imports, alias or name, node,
-                confidence=90, ignore=_ignore_import)
+                self.defined_imports,
+                alias or name,
+                node,
+                confidence=90,
+                ignore=_ignore_import,
+            )
             if alias is not None:
                 self.used_names.add(name_and_alias.name)
 
     def _handle_conditional_node(self, node, name):
         if utils.condition_is_always_false(node.test):
             self._define(
-                self.unreachable_code, name, node,
-                last_node=node.body[-1],
-                message="unsatisfiable '{name}' condition".format(**locals()),
-                confidence=100)
-        else:
-            else_body = getattr(node, 'orelse')
-            if utils.condition_is_always_true(node.test) and else_body:
+                self.unreachable_code,
+                name,
+                node,
+                last_node=node.body
+                if isinstance(node, ast.IfExp)
+                else node.body[-1],
+                message=f"unsatisfiable '{name}' condition",
+                confidence=100,
+            )
+        elif utils.condition_is_always_true(node.test):
+            else_body = node.orelse
+            if name == "ternary":
                 self._define(
-                    self.unreachable_code, 'else', else_body[0],
+                    self.unreachable_code,
+                    name,
+                    else_body,
+                    message="unreachable 'else' expression",
+                    confidence=100,
+                )
+            elif else_body:
+                self._define(
+                    self.unreachable_code,
+                    "else",
+                    else_body[0],
                     last_node=else_body[-1],
                     message="unreachable 'else' block",
-                    confidence=100)
+                    confidence=100,
+                )
+            elif name == "if":
+                # Redundant if-condition without else block.
+                self._define(
+                    self.unreachable_code,
+                    name,
+                    node,
+                    message="redundant if-condition",
+                    confidence=100,
+                )
 
-    def _define(self, collection, name, first_node, last_node=None,
-                message='', confidence=DEFAULT_CONFIDENCE, ignore=None):
+    def _define(
+        self,
+        collection,
+        name,
+        first_node,
+        last_node=None,
+        message="",
+        confidence=DEFAULT_CONFIDENCE,
+        ignore=None,
+    ):
+        def ignored(lineno):
+            return (
+                (ignore and ignore(self.filename, name))
+                or _match(name, self.ignore_names)
+                or noqa.ignore_line(self.noqa_lines, lineno, ERROR_CODES[typ])
+            )
+
         last_node = last_node or first_node
         typ = collection.typ
-        if (ignore and ignore(self.filename, name)) or _match(
-                name, self.ignore_names):
-            self._log('Ignoring {typ} "{name}"'.format(**locals()))
+        first_lineno = lines.get_first_line_number(first_node)
+
+        if ignored(first_lineno):
+            self._log(f'Ignoring {typ} "{name}"')
         else:
-            first_lineno = first_node.lineno
             last_lineno = lines.get_last_line_number(last_node)
             collection.append(
-                Item(name, typ, self.filename, first_lineno, last_lineno,
-                     message=message, confidence=confidence))
+                Item(
+                    name,
+                    typ,
+                    self.filename,
+                    first_lineno,
+                    last_lineno,
+                    message=message,
+                    confidence=confidence,
+                )
+            )
 
     def _define_variable(self, name, node, confidence=DEFAULT_CONFIDENCE):
-        self._define(self.defined_vars, name, node, confidence=confidence,
-                     ignore=_ignore_variable)
+        self._define(
+            self.defined_vars,
+            name,
+            node,
+            confidence=confidence,
+            ignore=_ignore_variable,
+        )
 
     def visit_arg(self, node):
-        """Function argument.
-
-        ast.arg was added in Python 3.0.
-        ast.arg.lineno was added in Python 3.4.
-        """
+        """Function argument"""
         self._define_variable(node.arg, node, confidence=100)
 
     def visit_AsyncFunctionDef(self, node):
@@ -388,107 +478,170 @@
         if isinstance(node.ctx, ast.Store):
             self._define(self.defined_attrs, node.attr, node)
         elif isinstance(node.ctx, ast.Load):
-            self.used_attrs.add(node.attr)
+            self.used_names.add(node.attr)
+
+    def visit_BinOp(self, node):
+        """
+        Parse variable names in old format strings:
+
+        "%(my_var)s" % locals()
+        """
+        if (
+            isinstance(node.left, ast.Str)
+            and isinstance(node.op, ast.Mod)
+            and self._is_locals_call(node.right)
+        ):
+            self.used_names |= set(re.findall(r"%\((\w+)\)", node.left.s))
+
+    def visit_Call(self, node):
+        # Count getattr/hasattr(x, "some_attr", ...) as usage of some_attr.
+        if isinstance(node.func, ast.Name) and (
+            (node.func.id == "getattr" and 2 <= len(node.args) <= 3)
+            or (node.func.id == "hasattr" and len(node.args) == 2)
+        ):
+            attr_name_arg = node.args[1]
+            if isinstance(attr_name_arg, ast.Str):
+                self.used_names.add(attr_name_arg.s)
+
+        # Parse variable names in new format strings:
+        # "{my_var}".format(**locals())
+        if (
+            isinstance(node.func, ast.Attribute)
+            and isinstance(node.func.value, ast.Str)
+            and node.func.attr == "format"
+            and any(
+                kw.arg is None and self._is_locals_call(kw.value)
+                for kw in node.keywords
+            )
+        ):
+            self._handle_new_format_string(node.func.value.s)
+
+    def _handle_new_format_string(self, s):
+        def is_identifier(name):
+            return bool(re.match(r"[a-zA-Z_][a-zA-Z0-9_]*", name))
+
+        parser = string.Formatter()
+        try:
+            names = [name for _, name, _, _ in parser.parse(s) if name]
+        except ValueError:
+            # Invalid format string.
+            names = []
+
+        for field_name in names:
+            # Remove brackets and their contents: "a[0][b].c[d].e" -> "a.c.e",
+            # then split the resulting string: "a.b.c" -> ["a", "b", "c"]
+            vars = re.sub(r"\[\w*\]", "", field_name).split(".")
+            for var in vars:
+                if is_identifier(var):
+                    self.used_names.add(var)
+
+    @staticmethod
+    def _is_locals_call(node):
+        """Return True if the node is `locals()`."""
+        return (
+            isinstance(node, ast.Call)
+            and isinstance(node.func, ast.Name)
+            and node.func.id == "locals"
+            and not node.args
+            and not node.keywords
+        )
 
     def visit_ClassDef(self, node):
         for decorator in node.decorator_list:
-            if _match(utils.get_decorator_name(decorator),
-                      self.ignore_decorators):
-                self._log('Ignoring class "{}" (decorator whitelisted)'.format(
-                    node.name))
+            if _match(
+                utils.get_decorator_name(decorator), self.ignore_decorators
+            ):
+                self._log(
+                    f'Ignoring class "{node.name}" (decorator whitelisted)'
+                )
                 break
         else:
             self._define(
-                self.defined_classes, node.name, node, ignore=_ignore_class)
+                self.defined_classes, node.name, node, ignore=_ignore_class
+            )
 
     def visit_FunctionDef(self, node):
-        decorator_names = [utils.get_decorator_name(
-            decorator) for decorator in node.decorator_list]
-        typ = 'property' if '@property' in decorator_names else 'function'
-        if any(_match(name, self.ignore_decorators)
-               for name in decorator_names):
-            self._log('Ignoring {} "{}" (decorator whitelisted)'.format(
-                    typ, node.name))
-        elif typ == 'property':
+        decorator_names = [
+            utils.get_decorator_name(decorator)
+            for decorator in node.decorator_list
+        ]
+
+        first_arg = node.args.args[0].arg if node.args.args else None
+
+        if "@property" in decorator_names:
+            typ = "property"
+        elif (
+            "@staticmethod" in decorator_names
+            or "@classmethod" in decorator_names
+            or first_arg == "self"
+        ):
+            typ = "method"
+        else:
+            typ = "function"
+
+        if any(
+            _match(name, self.ignore_decorators) for name in decorator_names
+        ):
+            self._log(f'Ignoring {typ} "{node.name}" (decorator whitelisted)')
+        elif typ == "property":
             self._define(self.defined_props, node.name, node)
-        elif '@pyqtSlot' in decorator_names or '@Slot' in decorator_names:
-            # @pyqtSlot and @Slot support - eric6
-            self._define(self.defined_slots, node.name, node)
-        else:
-            # Function is not a property.
+        elif typ == "method":
             self._define(
-                self.defined_funcs, node.name, node,
-                ignore=_ignore_function)
-
-        # Detect *args and **kwargs parameters. Python 3 recognizes them
-        # in visit_Name. For Python 2 we use this workaround. We can't
-        # use visit_arguments, because its node has no lineno.
-        for param in [node.args.vararg, node.args.kwarg]:
-            if param and isinstance(param, str):
-                self._define_variable(param, node, confidence=100)
+                self.defined_methods, node.name, node, ignore=_ignore_method
+            )
+        else:
+            self._define(
+                self.defined_funcs, node.name, node, ignore=_ignore_function
+            )
 
     def visit_If(self, node):
-        self._handle_conditional_node(node, 'if')
+        self._handle_conditional_node(node, "if")
+
+    def visit_IfExp(self, node):
+        self._handle_conditional_node(node, "ternary")
 
     def visit_Import(self, node):
         self._add_aliases(node)
 
     def visit_ImportFrom(self, node):
-        if node.module != '__future__':
+        if node.module != "__future__":
             self._add_aliases(node)
 
     def visit_Name(self, node):
-        if (isinstance(node.ctx, ast.Load) and
-                node.id not in IGNORED_VARIABLE_NAMES):
+        if (
+            isinstance(node.ctx, ast.Load)
+            and node.id not in IGNORED_VARIABLE_NAMES
+        ):
             self.used_names.add(node.id)
         elif isinstance(node.ctx, (ast.Param, ast.Store)):
             self._define_variable(node.id, node)
 
-    def visit_Str(self, node):
-        """
-        Parse variable names in format strings:
-
-        '%(my_var)s' % locals()
-        '{my_var}'.format(**locals())
-
-        """
-        # Old format strings.
-        self.used_names |= set(re.findall(r'\%\((\w+)\)', node.s))
-
-        def is_identifier(s):
-            return bool(re.match(r'[a-zA-Z_][a-zA-Z0-9_]*', s))
-
-        # New format strings.
-        parser = string.Formatter()
-        try:
-            names = [name for _, name, _, _ in parser.parse(node.s) if name]
-        except ValueError:
-            # Invalid format string.
-            names = []
-
-        for field_name in names:
-            # Remove brackets and contents: "a[0][b].c[d].e" -> "a.c.e".
-            # "a.b.c" -> name = "a", attributes = ["b", "c"]
-            name_and_attrs = re.sub(r'\[\w*\]', '', field_name).split('.')
-            name = name_and_attrs[0]
-            if is_identifier(name):
-                self.used_names.add(name)
-            for attr in name_and_attrs[1:]:
-                if is_identifier(attr):
-                    self.used_attrs.add(attr)
-
     def visit_While(self, node):
-        self._handle_conditional_node(node, 'while')
+        self._handle_conditional_node(node, "while")
 
     def visit(self, node):
-        method = 'visit_' + node.__class__.__name__
+        method = "visit_" + node.__class__.__name__
         visitor = getattr(self, method, None)
         if self.verbose:
-            lineno = getattr(node, 'lineno', 1)
-            line = self.code[lineno - 1] if self.code else ''
+            lineno = getattr(node, "lineno", 1)
+            line = self.code[lineno - 1] if self.code else ""
             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):
@@ -496,8 +649,9 @@
         Find unreachable nodes in the given sequence of ast nodes.
         """
         for index, node in enumerate(ast_list):
-            if isinstance(node, (ast.Break, ast.Continue, ast.Raise,
-                                 ast.Return)):
+            if isinstance(
+                node, (ast.Break, ast.Continue, ast.Raise, ast.Return)
+            ):
                 try:
                     first_unreachable_node = ast_list[index + 1]
                 except IndexError:
@@ -508,9 +662,9 @@
                     class_name,
                     first_unreachable_node,
                     last_node=ast_list[-1],
-                    message="unreachable code after '{class_name}'".format(
-                        **locals()),
-                    confidence=100)
+                    message=f"unreachable code after '{class_name}'",
+                    confidence=100,
+                )
                 return
 
     def generic_visit(self, node):
@@ -525,54 +679,18 @@
                 self.visit(value)
 
 
-def _parse_args():
-    def csv(exclude):
-        return exclude.split(',')
-
-    usage = "%(prog)s [options] PATH [PATH ...]"
-    version = "vulture {0}".format(__version__)
-    glob_help = 'Patterns may contain glob wildcards (*, ?, [abc], [!abc]).'
-    parser = argparse.ArgumentParser(prog='vulture', usage=usage)
-    parser.add_argument(
-        'paths', nargs='+', metavar='PATH',
-        help='Paths may be Python files or directories. For each directory'
-        ' Vulture analyzes all contained *.py files.')
-    parser.add_argument(
-        '--exclude', metavar='PATTERNS', type=csv,
-        help='Comma-separated list of paths to ignore (e.g.,'
-        ' "*settings.py,docs/*.py"). {glob_help} A PATTERN without glob'
-        ' wildcards is treated as *PATTERN*.'.format(**locals()))
-    parser.add_argument(
-        '--ignore-decorators', metavar='PATTERNS', type=csv,
-        help='Comma-separated list of decorators. Functions and classes using'
-        ' these decorators are ignored (e.g., "@app.route,@require_*").'
-        ' {glob_help}'.format(**locals()))
-    parser.add_argument(
-        '--ignore-names', metavar='PATTERNS', type=csv, default=None,
-        help='Comma-separated list of names to ignore (e.g., "visit_*,do_*").'
-        ' {glob_help}'.format(**locals()))
-    parser.add_argument(
-        '--make-whitelist', action='store_true',
-        help='Report unused code in a format that can be added to a'
-        ' whitelist module.')
-    parser.add_argument(
-        '--min-confidence', type=int, default=0,
-        help='Minimum confidence (between 0 and 100) for code to be'
-        ' reported as unused.')
-    parser.add_argument(
-        "--sort-by-size", action="store_true",
-        help='Sort unused functions and classes by their lines of code.')
-    parser.add_argument('-v', '--verbose', action='store_true')
-    parser.add_argument('--version', action='version', version=version)
-    return parser.parse_args()
-
-
 def main():
-    args = _parse_args()
-    vulture = Vulture(verbose=args.verbose, ignore_names=args.ignore_names,
-                      ignore_decorators=args.ignore_decorators)
-    vulture.scavenge(args.paths, exclude=args.exclude)
-    sys.exit(vulture.report(
-        min_confidence=args.min_confidence,
-        sort_by_size=args.sort_by_size,
-        make_whitelist=args.make_whitelist))
+    config = make_config()
+    vulture = Vulture(
+        verbose=config["verbose"],
+        ignore_names=config["ignore_names"],
+        ignore_decorators=config["ignore_decorators"],
+    )
+    vulture.scavenge(config["paths"], exclude=config["exclude"])
+    sys.exit(
+        vulture.report(
+            min_confidence=config["min_confidence"],
+            sort_by_size=config["sort_by_size"],
+            make_whitelist=config["make_whitelist"],
+        )
+    )

eric ide

mercurial