eric6/Plugins/CheckerPlugins/CodeStyleChecker/Annotations/AnnotationsChecker.py

changeset 8244
ed8cb108b27b
parent 8236
695777f04b25
child 8246
e4b95b0ebd31
--- a/eric6/Plugins/CheckerPlugins/CodeStyleChecker/Annotations/AnnotationsChecker.py	Thu Apr 15 18:11:24 2021 +0200
+++ b/eric6/Plugins/CheckerPlugins/CodeStyleChecker/Annotations/AnnotationsChecker.py	Fri Apr 16 18:03:43 2021 +0200
@@ -9,9 +9,13 @@
 
 import copy
 import ast
+import sys
+from functools import lru_cache
 
 import AstUtilities
 
+from .AnnotationsEnums import AnnotationType, ClassDecoratorType, FunctionType
+
 
 class AnnotationsChecker:
     """
@@ -27,11 +31,14 @@
         ## Return Annotations
         "A201", "A202", "A203", "A204", "A205", "A206",
         
+        ## Mixed kind of annotations
+        "A301",
+        
         ## Annotation Coverage
         "A881",
         
         ## Annotation Complexity
-        "A891",
+        "A891", "A892",
     ]
 
     def __init__(self, source, filename, tree, select, ignore, expected,
@@ -75,15 +82,33 @@
             (
                 self.__checkFunctionAnnotations,
                 ("A001", "A002", "A003", "A101", "A102",
-                 "A201", "A202", "A203", "A204", "A205", "A206",)
+                 "A201", "A202", "A203", "A204", "A205", "A206",
+                 "A301", )
             ),
             (self.__checkAnnotationsCoverage, ("A881",)),
-            (self.__checkAnnotationComplexity, ("A891",)),
+            (self.__checkAnnotationComplexity, ("A891", "A892")),
         ]
         
+        # TODO: the parameters to CodeStyleCheckerDialog
         self.__defaultArgs = {
+            # Annotations
+            "SuppressNoneReturning": False,
+            "SuppressDummyArgs": False,
+            "AllowUntypedDefs": False,
+            "AllowUntypedNested": False,
+            "MypyInitReturn": False,
+            "DispatchDecorators": [
+                "singledispatch",
+                "singledispatchmethod",
+            ],
+            "OverloadDecorators": ["overload"],
+            
+            # Annotation Coverage
             "MinimumCoverage": 75,      # % of type annotation coverage
+            
+            # Annotation Complexity
             "MaximumComplexity": 3,
+            "MaximumLength": 7,
         }
         
         self.__checkers = []
@@ -156,17 +181,243 @@
         for check in self.__checkers:
             check()
     
+    #######################################################################
+    ## Annotations
+    ##
+    ## adapted from: flake8-annotations v2.6.2
+    #######################################################################
+    
     def __checkFunctionAnnotations(self):
         """
         Private method to check for function annotation issues.
         """
+        suppressNoneReturning = self.__args.get(
+            "SuppressNoneReturning",
+            self.__defaultArgs["SuppressNoneReturning"])
+        suppressDummyArgs = self.__args.get(
+            "SuppressDummyArgs",
+            self.__defaultArgs["SuppressDummyArgs"])
+        allowUntypedDefs = self.__args.get(
+            "AllowUntypedDefs",
+            self.__defaultArgs["AllowUntypedDefs"])
+        allowUntypedNested = self.__args.get(
+            "AllowUntypedNested",
+            self.__defaultArgs["AllowUntypedNested"])
+        mypyInitReturn = self.__args.get(
+            "MypyInitReturn",
+            self.__defaultArgs["MypyInitReturn"])
+        
+        # Store decorator lists as sets for easier lookup
+        dispatchDecorators = set(self.__args.get(
+            "DispatchDecorators",
+            self.__defaultArgs["DispatchDecorators"]))
+        overloadDecorators = set(self.__args.get(
+            "OverloadDecorators",
+            self.__defaultArgs["OverloadDecorators"]))
+        
+        from .AnnotationsFunctionVisitor import FunctionVisitor
         visitor = FunctionVisitor(self.__source)
         visitor.visit(self.__tree)
-        for issue in visitor.issues:
-            node = issue[0]
-            reason = issue[1]
-            params = issue[2:]
-            self.__error(node.lineno - 1, node.col_offset, reason, *params)
+        
+        # Keep track of the last encountered function decorated by
+        # `typing.overload`, if any. Per the `typing` module documentation,
+        # a series of overload-decorated definitions must be followed by
+        # exactly one non-overload-decorated definition of the same function.
+        lastOverloadDecoratedFunctionName = None
+        
+        # Iterate over the arguments with missing type hints, by function.
+        for function in visitor.functionDefinitions:
+            if (
+                function.isDynamicallyTyped() and
+                (allowUntypedDefs or
+                 (function.isNested and allowUntypedNested))
+            ):
+                # Skip yielding errors from dynamically typed functions
+                # or nested functions
+                continue
+            
+            # Skip yielding errors for configured dispatch functions, such as
+            # (by default) `functools.singledispatch` and
+            # `functools.singledispatchmethod`
+            if function.hasDecorator(dispatchDecorators):
+                continue
+            
+            # Create sentinels to check for mixed hint styles
+            hasTypeComment = function.hasTypeComment
+            
+            has3107Annotation = False
+            # PEP 3107 annotations are captured by the return arg
+            
+            # Iterate over annotated args to detect mixing of type annotations
+            # and type comments. Emit this only once per function definition
+            for arg in function.getAnnotatedArguments():
+                if arg.hasTypeComment:
+                    hasTypeComment = True
+                
+                if arg.has3107Annotation:
+                    has3107Annotation = True
+                
+                if hasTypeComment and has3107Annotation:
+                    # Short-circuit check for mixing of type comments &
+                    # 3107-style annotations
+                    self.__error(function.lineno - 1, function.col_offset,
+                                 "A301")
+                    break
+            
+            # Before we iterate over the function's missing annotations, check
+            # to see if it's the closing function def in a series of
+            # `typing.overload` decorated functions.
+            if lastOverloadDecoratedFunctionName == function.name:
+                continue
+
+            # If it's not, and it is overload decorated, store it for the next
+            # iteration
+            if function.hasDecorator(overloadDecorators):
+                lastOverloadDecoratedFunctionName = function.name
+            
+            # Record explicit errors for arguments that are missing annotations
+            for arg in function.getMissedAnnotations():
+                if arg.argname == "return":
+                    # return annotations have multiple possible short-circuit
+                    # paths
+                    if (
+                        suppressNoneReturning and
+                        not arg.hasTypeAnnotation and
+                        function.hasOnlyNoneReturns
+                    ):
+                        # Skip recording return errors if the function has only
+                        # `None` returns. This includes the case of no returns.
+                        continue
+                    
+                    if (
+                        mypyInitReturn and
+                        function.isClassMethod and
+                        function.name == "__init__" and
+                        function.getAnnotatedArguments()
+                    ):
+                        # Skip recording return errors for `__init__` if at
+                        # least one argument is annotated
+                        continue
+                
+                # If the `suppressDummyArgs` flag is `True`, skip recording
+                # errors for any arguments named `_`
+                if arg.argname == "_" and suppressDummyArgs:
+                    continue
+
+                self.__classifyError(function, arg)
+    
+    def __classifyError(self, function, arg):
+        """
+        Private method to classify the missing type annotation based on the
+        Function & Argument metadata.
+        
+        For the currently defined rules & program flow, the assumption can be
+        made that an argument passed to this method will match a linting error,
+        and will only match a single linting error
+        
+        This function provides an initial classificaton, then passes relevant
+        attributes to cached helper function(s).
+        
+        @param function reference to the Function object
+        @type Function
+        @param arg reference to the Argument object
+        @type Argument
+        """
+        # Check for return type
+        # All return "arguments" have an explicitly defined name "return"
+        if arg.argname == "return":
+            errorCode = self.__returnErrorClassifier(
+                function.isClassMethod, function.classDecoratorType,
+                function.functionType
+            )
+        else:
+            # Otherwise, classify function argument error
+            isFirstArg = arg == function.args[0]
+            errorCode = self.__argumentErrorClassifier(
+                function.isClassMethod, isFirstArg,
+                function.classDecoratorType, arg.annotationType,
+            )
+        
+        if errorCode in ("A001", "A002", "A003"):
+            self.__error(arg.lineno - 1, arg.col_offset, errorCode,
+                         arg.argname)
+        else:
+            self.__error(arg.lineno - 1, arg.col_offset, errorCode)
+    
+    @lru_cache()
+    def __returnErrorClassifier(self, isClassMethod, classDecoratorType,
+                                functionType):
+        """
+        Private method to classify a return type annotation issue.
+        
+        @param isClassMethod flag indicating a classmethod type function
+        @type bool
+        @param classDecoratorType type of class decorator
+        @type ClassDecoratorType
+        @param functionType type of function
+        @type FunctionType
+        @return error code
+        @rtype str
+        """
+        # Decorated class methods (@classmethod, @staticmethod) have a higher
+        # priority than the rest
+        if isClassMethod:
+            if classDecoratorType == ClassDecoratorType.CLASSMETHOD:
+                return "A206"
+            elif classDecoratorType == ClassDecoratorType.STATICMETHOD:
+                return "A205"
+
+        if functionType == FunctionType.SPECIAL:
+            return "A204"
+        elif functionType == FunctionType.PRIVATE:
+            return "A203"
+        elif functionType == FunctionType.PROTECTED:
+            return "A202"
+        else:
+            return "A201"
+    
+    @lru_cache()
+    def __argumentErrorClassifier(self, isClassMethod, isFirstArg,
+                                  classDecoratorType, annotationType):
+        """
+        Private method to classify an argument type annotation issue.
+        
+        @param isClassMethod flag indicating a classmethod type function
+        @type bool
+        @param isFirstArg flag indicating the first argument
+        @type bool
+        @param classDecoratorType type of class decorator
+        @type enums.ClassDecoratorType
+        @param annotationType type of annotation
+        @type AnnotationType
+        @return error code
+        @rtype str
+        """
+        # Check for regular class methods and @classmethod, @staticmethod is
+        # deferred to final check
+        if isClassMethod and isFirstArg:
+            # The first function argument here would be an instance of self or
+            # class
+            if classDecoratorType == ClassDecoratorType.CLASSMETHOD:
+                return "A102"
+            elif classDecoratorType != ClassDecoratorType.STATICMETHOD:
+                # Regular class method
+                return "A101"
+
+        # Check for remaining codes
+        if annotationType == AnnotationType.KWARG:
+            return "A003"
+        elif annotationType == AnnotationType.VARARG:
+            return "A002"
+        else:
+            # Combine PosOnlyArgs, Args, and KwOnlyArgs
+            return "A001"
+    
+    #######################################################################
+    ## Annotations Coverage
+    ##
+    ## adapted from: flake8-annotations-coverage v0.0.5
+    #######################################################################
     
     def __checkAnnotationsCoverage(self):
         """
@@ -187,7 +438,7 @@
             return
         
         functionDefAnnotationsInfo = [
-            hasTypeAnnotations(f) for f in functionDefs
+            self.__hasTypeAnnotations(f) for f in functionDefs
         ]
         annotationsCoverage = int(
             len(list(filter(None, functionDefAnnotationsInfo))) /
@@ -196,12 +447,42 @@
         if annotationsCoverage < minAnnotationsCoverage:
             self.__error(0, 0, "A881", annotationsCoverage)
     
+    def __hasTypeAnnotations(self, funcNode):
+        """
+        Private method to check for type annotations.
+        
+        @param funcNode reference to the function definition node to be checked
+        @type ast.AsyncFunctionDef or ast.FunctionDef
+        @return flag indicating the presence of type annotations
+        @rtype bool
+        """
+        hasReturnAnnotation = funcNode.returns is not None
+        hasArgsAnnotations = any(a for a in funcNode.args.args
+                                 if a.annotation is not None)
+        hasKwargsAnnotations = (funcNode.args and
+                                funcNode.args.kwarg and
+                                funcNode.args.kwarg.annotation is not None)
+        hasKwonlyargsAnnotations = any(a for a in funcNode.args.kwonlyargs
+                                       if a.annotation is not None)
+        
+        return any((hasReturnAnnotation, hasArgsAnnotations,
+                    hasKwargsAnnotations, hasKwonlyargsAnnotations))
+    
+    #######################################################################
+    ## Annotations Complexity
+    ##
+    ## adapted from: flake8-annotations-complexity v0.0.6
+    #######################################################################
+    
     def __checkAnnotationComplexity(self):
         """
         Private method to check the type annotation complexity.
         """
         maxAnnotationComplexity = self.__args.get(
             "MaximumComplexity", self.__defaultArgs["MaximumComplexity"])
+        # TODO: include 'MaximumLength' in CodeStyleCheckerDialog
+        maxAnnotationLength = self.__args.get(
+            "MaximumLength", self.__defaultArgs["MaximumLength"])
         typeAnnotations = []
         
         functionDefs = [
@@ -216,217 +497,71 @@
         typeAnnotations += [a.annotation for a in ast.walk(self.__tree)
                             if isinstance(a, ast.AnnAssign) and a.annotation]
         for annotation in typeAnnotations:
-            complexity = getAnnotationComplexity(annotation)
+            complexity = self.__getAnnotationComplexity(annotation)
             if complexity > maxAnnotationComplexity:
                 self.__error(annotation.lineno - 1, annotation.col_offset,
                              "A891", complexity, maxAnnotationComplexity)
-
-
-class FunctionVisitor(ast.NodeVisitor):
-    """
-    Class implementing a node visitor to check function annotations.
-    
-    Note: this class is modeled after flake8-annotations checker.
-    """
-    def __init__(self, sourceLines):
-        """
-        Constructor
-        
-        @param sourceLines lines of source code
-        @type list of str
-        """
-        super().__init__()
-        
-        self.__sourceLines = sourceLines
-        
-        self.issues = []
+            
+            annotationLength = self.__getAnnotationLength(annotation)
+            if annotationLength > maxAnnotationLength:
+                self.__error(annotation.lineno - 1, annotation.col_offset,
+                             "A892", annotationLength, maxAnnotationLength)
     
-    def visit_FunctionDef(self, node):
-        """
-        Public method to handle a function or method definition.
-        
-        @param node reference to the node to be processed
-        @type ast.FunctionDef
-        """
-        self.__checkFunctionNode(node)
-        self.generic_visit(node)
-    
-    def visit_AsyncFunctionDef(self, node):
-        """
-        Public method to handle an async function or method definition.
-        
-        @param node reference to the node to be processed
-        @type ast.AsyncFunctionDef
-        """
-        self.__checkFunctionNode(node)
-        self.generic_visit(node)
-    
-    def visit_ClassDef(self, node):
-        """
-        Public method to handle class definitions.
-        
-        @param node reference to the node to be processed
-        @type ast.ClassDef
+    def __getAnnotationComplexity(self, annotationNode, defaultComplexity=1):
         """
-        methodNodes = [
-            childNode for childNode in node.body
-            if isinstance(childNode, (ast.FunctionDef, ast.AsyncFunctionDef))
-        ]
-        for methodNode in methodNodes:
-            self.__checkFunctionNode(methodNode, classMethod=True)
-    
-    def __checkFunctionNode(self, node, classMethod=False):
-        """
-        Private method to check an individual function definition node.
-        
-        @param node reference to the node to be processed
-        @type ast.FunctionDef or ast.AsyncFunctionDef
-        @param classMethod flag indicating a class method
-        @type bool
-        """
-        if node.name.startswith("__") and node.name.endswith("__"):
-            visibilityType = "special"
-        elif node.name.startswith("__"):
-            visibilityType = "private"
-        elif node.name.startswith("_"):
-            visibilityType = "protected"
-        else:
-            visibilityType = "public"
-        
-        if classMethod:
-            decorators = [
-                decorator.id for decorator in node.decorator_list
-                if isinstance(decorator, ast.Name)
-            ]
-            if "classmethod" in decorators:
-                classMethodType = "decorator"
-            elif "staticmethod" in decorators:
-                classMethodType = "staticmethod"
-            else:
-                classMethodType = ""
-        else:
-            classMethodType = "function"
-        
-        # check argument annotations
-        for argType in ("args", "vararg", "kwonlyargs", "kwarg"):
-            args = node.args.__getattribute__(argType)
-            if args:
-                if not isinstance(args, list):
-                    args = [args]
-                
-                for arg in args:
-                    if not arg.annotation:
-                        self.__classifyArgumentError(
-                            arg, argType, classMethodType)
+        Private method to determine the annotation complexity.
         
-        # check function return annotation
-        if not node.returns:
-            lineno = node.lineno
-            colOffset = self.__sourceLines[lineno - 1].rfind(":") + 1
-            self.__classifyReturnError(classMethodType, visibilityType,
-                                       lineno, colOffset)
-    
-    def __classifyReturnError(self, methodType, visibilityType, lineno,
-                              colOffset):
-        """
-        Private method to classify and record a return annotation issue.
-        
-        @param methodType type of method/function the argument belongs to
-        @type str
-        @param visibilityType visibility of the function
-        @type str
-        @param lineno line number
-        @type int
-        @param colOffset column number
+        @param annotationNode reference to the node to determine the annotation
+            complexity for
+        @type ast.AST
+        @param defaultComplexity default complexity value
         @type int
-        """
-        # create a dummy AST node to report line and column
-        node = ast.AST()
-        node.lineno = lineno
-        node.col_offset = colOffset
-        
-        # now classify the issue
-        if methodType == "classmethod":
-            self.issues.append((node, "A206"))
-        elif methodType == "staticmethod":
-            self.issues.append((node, "A205"))
-        elif visibilityType == "special":
-            self.issues.append((node, "A204"))
-        elif visibilityType == "private":
-            self.issues.append((node, "A203"))
-        elif visibilityType == "protected":
-            self.issues.append((node, "A202"))
-        else:
-            self.issues.append((node, "A201"))
-    
-    def __classifyArgumentError(self, argNode, argType, methodType):
-        """
-        Private method to classify and record an argument annotation issue.
-        
-        @param argNode reference to the argument node
-        @type ast.arguments
-        @param argType type of the argument node
-        @type str
-        @param methodType type of method/function the argument belongs to
-        @type str
+        @return annotation complexity
+        @rtype = int
         """
-        # check class method issues
-        if methodType != "function" and argNode.arg in ("cls", "self"):
-            if methodType == "classmethod":
-                self.issues.append((argNode, "A102"))
-                return
-            elif methodType != "staticmethod":
-                self.issues.append((argNode, "A101"))
-                return
-        
-        # check all other arguments
-        if argType == "kwarg":
-            self.issues.append((argNode, "A003", argNode.arg))
-        elif argType == "vararg":
-            self.issues.append((argNode, "A002", argNode.arg))
-        else:
-            # args and kwonlyargs
-            self.issues.append((argNode, "A001", argNode.arg))
-
-######################################################################
-## some utility functions below
-######################################################################
-
-
-def hasTypeAnnotations(funcNode):
-    """
-    Function to check for type annotations.
+        if AstUtilities.isString(annotationNode):
+            try:
+                annotationNode = ast.parse(annotationNode.s).body[0].value
+            except (SyntaxError, IndexError):
+                return defaultComplexity
+        if isinstance(annotationNode, ast.Subscript):
+            if sys.version_info >= (3, 9):
+                return (defaultComplexity +
+                        self.__getAnnotationComplexity(annotationNode.slice))
+            else:
+                return (
+                    defaultComplexity +
+                    self.__getAnnotationComplexity(annotationNode.slice.value)
+                )
+        if isinstance(annotationNode, ast.Tuple):
+            return max(
+                (self.__getAnnotationComplexity(n)
+                 for n in annotationNode.elts),
+                default=defaultComplexity
+            )
+        return defaultComplexity
     
-    @param funcNode reference to the function definition node to be checked
-    @type ast.AsyncFunctionDef or ast.FunctionDef
-    @return flag indicating the presence of type annotations
-    @rtype bool
-    """
-    hasReturnAnnotation = funcNode.returns is not None
-    hasArgsAnnotations = any(a for a in funcNode.args.args
-                             if a.annotation is not None)
-    hasKwargsAnnotations = (funcNode.args and
-                            funcNode.args.kwarg and
-                            funcNode.args.kwarg.annotation is not None)
-    hasKwonlyargsAnnotations = any(a for a in funcNode.args.kwonlyargs
-                                   if a.annotation is not None)
-    
-    return any((hasReturnAnnotation, hasArgsAnnotations, hasKwargsAnnotations,
-               hasKwonlyargsAnnotations))
-
-
-def getAnnotationComplexity(annotationNode):
-    """
-    Function to determine the annotation complexity.
-    
-    @param annotationNode reference to the node to determine the annotation
-        complexity for
-    @type ast.AST
-    @return annotation complexity
-    @rtype = int
-    """
-    if AstUtilities.isString(annotationNode):
-        annotationNode = ast.parse(annotationNode.s).body[0].value
-    if isinstance(annotationNode, ast.Tuple):
-        return max(getAnnotationComplexity(n) for n in annotationNode.elts)
-    return 1
+    def __getAnnotationLength(self, annotationNode):
+        """
+        Private method to determine the annotation length.
+        
+        @param annotationNode reference to the node to determine the annotation
+            length for
+        @type ast.AST
+        @return annotation length
+        @rtype = int
+        """
+        if AstUtilities.isString(annotationNode):
+            try:
+                annotationNode = ast.parse(annotationNode.s).body[0].value
+            except (SyntaxError, IndexError):
+                return 0
+        if isinstance(annotationNode, ast.Subscript):
+            try:
+                if sys.version_info >= (3, 9):
+                    return len(annotationNode.slice.elts)
+                else:
+                    return len(annotationNode.slice.value.elts)
+            except AttributeError:
+                return 0
+        return 0

eric ide

mercurial