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

Fri, 16 Apr 2021 18:03:43 +0200

author
Detlev Offenbach <detlev@die-offenbachs.de>
date
Fri, 16 Apr 2021 18:03:43 +0200
changeset 8244
ed8cb108b27b
parent 8236
695777f04b25
child 8246
e4b95b0ebd31
permissions
-rw-r--r--

Code Style Checker: reworked the type annotations checker.

# -*- coding: utf-8 -*-

# Copyright (c) 2019 - 2021 Detlev Offenbach <detlev@die-offenbachs.de>
#

"""
Module implementing a checker for function type annotations.
"""

import copy
import ast
import sys
from functools import lru_cache

import AstUtilities

from .AnnotationsEnums import AnnotationType, ClassDecoratorType, FunctionType


class AnnotationsChecker:
    """
    Class implementing a checker for function type annotations.
    """
    Codes = [
        ## Function Annotations
        "A001", "A002", "A003",
        
        ## Method Annotations
        "A101", "A102",
        
        ## Return Annotations
        "A201", "A202", "A203", "A204", "A205", "A206",
        
        ## Mixed kind of annotations
        "A301",
        
        ## Annotation Coverage
        "A881",
        
        ## Annotation Complexity
        "A891", "A892",
    ]

    def __init__(self, source, filename, tree, select, ignore, expected,
                 repeat, args):
        """
        Constructor
        
        @param source source code to be checked
        @type list of str
        @param filename name of the source file
        @type str
        @param tree AST tree of the source code
        @type ast.Module
        @param select list of selected codes
        @type list of str
        @param ignore list of codes to be ignored
        @type list of str
        @param expected list of expected codes
        @type list of str
        @param repeat flag indicating to report each occurrence of a code
        @type bool
        @param args dictionary of arguments for the annotation checks
        @type dict
        """
        self.__select = tuple(select)
        self.__ignore = ('',) if select else tuple(ignore)
        self.__expected = expected[:]
        self.__repeat = repeat
        self.__filename = filename
        self.__source = source[:]
        self.__tree = copy.deepcopy(tree)
        self.__args = args

        # statistics counters
        self.counters = {}
        
        # collection of detected errors
        self.errors = []
        
        checkersWithCodes = [
            (
                self.__checkFunctionAnnotations,
                ("A001", "A002", "A003", "A101", "A102",
                 "A201", "A202", "A203", "A204", "A205", "A206",
                 "A301", )
            ),
            (self.__checkAnnotationsCoverage, ("A881",)),
            (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 = []
        for checker, codes in checkersWithCodes:
            if any(not (code and self.__ignoreCode(code))
                    for code in codes):
                self.__checkers.append(checker)
    
    def __ignoreCode(self, code):
        """
        Private method to check if the message code should be ignored.

        @param code message code to check for
        @type str
        @return flag indicating to ignore the given code
        @rtype bool
        """
        return (code.startswith(self.__ignore) and
                not code.startswith(self.__select))
    
    def __error(self, lineNumber, offset, code, *args):
        """
        Private method to record an issue.
        
        @param lineNumber line number of the issue
        @type int
        @param offset position within line of the issue
        @type int
        @param code message code
        @type str
        @param args arguments for the message
        @type list
        """
        if self.__ignoreCode(code):
            return
        
        if code in self.counters:
            self.counters[code] += 1
        else:
            self.counters[code] = 1
        
        # Don't care about expected codes
        if code in self.__expected:
            return
        
        if code and (self.counters[code] == 1 or self.__repeat):
            # record the issue with one based line number
            self.errors.append(
                {
                    "file": self.__filename,
                    "line": lineNumber + 1,
                    "offset": offset,
                    "code": code,
                    "args": args,
                }
            )
    
    def run(self):
        """
        Public method to check the given source against annotation issues.
        """
        if not self.__filename:
            # don't do anything, if essential data is missing
            return
        
        if not self.__checkers:
            # don't do anything, if no codes were selected
            return
        
        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)
        
        # 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):
        """
        Private method to check for function annotation coverage.
        """
        minAnnotationsCoverage = self.__args.get(
            "MinimumCoverage", self.__defaultArgs["MinimumCoverage"])
        if minAnnotationsCoverage == 0:
            # 0 means it is switched off
            return
        
        functionDefs = [
            f for f in ast.walk(self.__tree)
            if isinstance(f, (ast.AsyncFunctionDef, ast.FunctionDef))
        ]
        if not functionDefs:
            # no functions/methods at all
            return
        
        functionDefAnnotationsInfo = [
            self.__hasTypeAnnotations(f) for f in functionDefs
        ]
        annotationsCoverage = int(
            len(list(filter(None, functionDefAnnotationsInfo))) /
            len(functionDefAnnotationsInfo) * 100
        )
        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 = [
            f for f in ast.walk(self.__tree)
            if isinstance(f, (ast.AsyncFunctionDef, ast.FunctionDef))
        ]
        for functionDef in functionDefs:
            typeAnnotations += list(filter(
                None, [a.annotation for a in functionDef.args.args]))
            if functionDef.returns:
                typeAnnotations.append(functionDef.returns)
        typeAnnotations += [a.annotation for a in ast.walk(self.__tree)
                            if isinstance(a, ast.AnnAssign) and a.annotation]
        for annotation in typeAnnotations:
            complexity = self.__getAnnotationComplexity(annotation)
            if complexity > maxAnnotationComplexity:
                self.__error(annotation.lineno - 1, annotation.col_offset,
                             "A891", complexity, maxAnnotationComplexity)
            
            annotationLength = self.__getAnnotationLength(annotation)
            if annotationLength > maxAnnotationLength:
                self.__error(annotation.lineno - 1, annotation.col_offset,
                             "A892", annotationLength, maxAnnotationLength)
    
    def __getAnnotationComplexity(self, annotationNode, defaultComplexity=1):
        """
        Private method to determine the annotation complexity.
        
        @param annotationNode reference to the node to determine the annotation
            complexity for
        @type ast.AST
        @param defaultComplexity default complexity value
        @type int
        @return annotation complexity
        @rtype = int
        """
        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
    
    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