--- a/eric6/Plugins/CheckerPlugins/CodeStyleChecker/Annotations/AnnotationsChecker.py Fri Apr 02 11:59:41 2021 +0200 +++ b/eric6/Plugins/CheckerPlugins/CodeStyleChecker/Annotations/AnnotationsChecker.py Sat May 01 14:27:20 2021 +0200 @@ -7,13 +7,18 @@ Module implementing a checker for function type annotations. """ +import copy +import ast import sys -import ast +from functools import lru_cache import AstUtilities +from .AnnotationsEnums import AnnotationType, ClassDecoratorType, FunctionType +from .AnnotationsCheckerDefaults import AnnotationsCheckerDefaultArgs -class AnnotationsChecker(object): + +class AnnotationsChecker: """ Class implementing a checker for function type annotations. """ @@ -27,18 +32,18 @@ ## Return Annotations "A201", "A202", "A203", "A204", "A205", "A206", + ## Mixed kind of annotations + "A301", + ## Annotation Coverage "A881", ## Annotation Complexity - "A891", - - ## Syntax Error - "A999", + "A891", "A892", ] - def __init__(self, source, filename, select, ignore, expected, repeat, - args): + def __init__(self, source, filename, tree, select, ignore, expected, + repeat, args): """ Constructor @@ -46,6 +51,8 @@ @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 @@ -63,6 +70,7 @@ self.__repeat = repeat self.__filename = filename self.__source = source[:] + self.__tree = copy.deepcopy(tree) self.__args = args # statistics counters @@ -75,17 +83,13 @@ ( 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")), ] - self.__defaultArgs = { - "MinimumCoverage": 75, # % of type annotation coverage - "MaximumComplexity": 3, - } - self.__checkers = [] for checker, codes in checkersWithCodes: if any(not (code and self.__ignoreCode(code)) @@ -141,29 +145,6 @@ } ) - def __reportInvalidSyntax(self): - """ - Private method to report a syntax error. - """ - exc_type, exc = sys.exc_info()[:2] - if len(exc.args) > 1: - offset = exc.args[1] - if len(offset) > 2: - offset = offset[1:3] - else: - offset = (1, 0) - self.__error(offset[0] - 1, offset[1] or 0, - 'A999', exc_type.__name__, exc.args[0]) - - def __generateTree(self): - """ - Private method to generate an AST for our source. - - @return generated AST - @rtype ast.Module - """ - return ast.parse("".join(self.__source), self.__filename) - def run(self): """ Public method to check the given source against annotation issues. @@ -176,33 +157,254 @@ # don't do anything, if no codes were selected return - try: - self.__tree = self.__generateTree() - except (SyntaxError, TypeError): - self.__reportInvalidSyntax() - 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", + AnnotationsCheckerDefaultArgs["SuppressNoneReturning"]) + suppressDummyArgs = self.__args.get( + "SuppressDummyArgs", + AnnotationsCheckerDefaultArgs["SuppressDummyArgs"]) + allowUntypedDefs = self.__args.get( + "AllowUntypedDefs", + AnnotationsCheckerDefaultArgs["AllowUntypedDefs"]) + allowUntypedNested = self.__args.get( + "AllowUntypedNested", + AnnotationsCheckerDefaultArgs["AllowUntypedNested"]) + mypyInitReturn = self.__args.get( + "MypyInitReturn", + AnnotationsCheckerDefaultArgs["MypyInitReturn"]) + + # Store decorator lists as sets for easier lookup + dispatchDecorators = set(self.__args.get( + "DispatchDecorators", + AnnotationsCheckerDefaultArgs["DispatchDecorators"])) + overloadDecorators = set(self.__args.get( + "OverloadDecorators", + AnnotationsCheckerDefaultArgs["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 recording errors from dynamically typed functions + # or nested functions + continue + + # Skip recording 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"]) + "MinimumCoverage", + AnnotationsCheckerDefaultArgs["MinimumCoverage"]) if minAnnotationsCoverage == 0: # 0 means it is switched off return @@ -216,7 +418,7 @@ return functionDefAnnotationsInfo = [ - hasTypeAnnotations(f) for f in functionDefs + self.__hasTypeAnnotations(f) for f in functionDefs ] annotationsCoverage = int( len(list(filter(None, functionDefAnnotationsInfo))) / @@ -225,12 +427,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"]) + "MaximumComplexity", + AnnotationsCheckerDefaultArgs["MaximumComplexity"]) + maxAnnotationLength = self.__args.get( + "MaximumLength", AnnotationsCheckerDefaultArgs["MaximumLength"]) typeAnnotations = [] functionDefs = [ @@ -245,220 +477,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 modelled after flake8-annotations checker. - """ - def __init__(self, sourceLines): - """ - Constructor - - @param sourceLines lines of source code - @type list of str - """ - super(FunctionVisitor, self).__init__() - - self.__sourceLines = sourceLines - - self.issues = [] - - 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 - """ - 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) - - # check function return annotation - if not node.returns: - lineno = node.lineno - colOffset = self.__sourceLines[lineno - 1].rfind(":") + 1 - self.__classifyReturnError(classMethodType, visibilityType, - lineno, colOffset) + + annotationLength = self.__getAnnotationLength(annotation) + if annotationLength > maxAnnotationLength: + self.__error(annotation.lineno - 1, annotation.col_offset, + "A892", annotationLength, maxAnnotationLength) - 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 - @type int + def __getAnnotationComplexity(self, annotationNode, defaultComplexity=1): """ - # 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 - """ - # check class method issues - if methodType != "function": - if argNode.arg in ("cls", "self"): - if methodType == "classmethod": - self.issues.append((argNode, "A102")) - return - elif methodType != "staticmethod": - self.issues.append((argNode, "A101")) - return + Private method to determine the annotation complexity. - # 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. + @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 - @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.Subscript): - return 1 + getAnnotationComplexity(annotationNode.slice.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