Fri, 16 Apr 2021 18:03:43 +0200
Code Style Checker: reworked the type annotations checker.
--- a/eric6.epj Thu Apr 15 18:11:24 2021 +0200 +++ b/eric6.epj Fri Apr 16 18:03:43 2021 +0200 @@ -2578,7 +2578,9 @@ "eric6/Plugins/CheckerPlugins/CodeStyleChecker/Simplify/ast_unparse.py", "eric6/Plugins/CheckerPlugins/CodeStyleChecker/Simplify/SimplifyChecker.py", "eric6/Plugins/CheckerPlugins/CodeStyleChecker/Simplify/translations.py", - "eric6/Plugins/CheckerPlugins/CodeStyleChecker/Simplify/SimplifyNodeVisitor.py" + "eric6/Plugins/CheckerPlugins/CodeStyleChecker/Simplify/SimplifyNodeVisitor.py", + "eric6/Plugins/CheckerPlugins/CodeStyleChecker/Annotations/AnnotationsFunctionVisitor.py", + "eric6/Plugins/CheckerPlugins/CodeStyleChecker/Annotations/AnnotationsEnums.py" ], "SPELLEXCLUDES": "Dictionaries/excludes.dic", "SPELLLANGUAGE": "en_US",
--- 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
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/eric6/Plugins/CheckerPlugins/CodeStyleChecker/Annotations/AnnotationsEnums.py Fri Apr 16 18:03:43 2021 +0200 @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2021 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing some enums for function type annotations. +""" + +# +# adapted from flake8-annotations v2.6.2 +# + +from enum import Enum, auto + + +class FunctionType(Enum): + """ + Class representing the various function types. + """ + PUBLIC = auto() + PROTECTED = auto() # Leading single underscore + PRIVATE = auto() # Leading double underscore + SPECIAL = auto() # Leading & trailing double underscore + + +class ClassDecoratorType(Enum): + """ + Class representing the various class method decorators. + """ + CLASSMETHOD = auto() + STATICMETHOD = auto() + + +class AnnotationType(Enum): + """ + Class representing the kind of missing type annotation. + """ + POSONLYARGS = auto() + ARGS = auto() + VARARG = auto() + KWONLYARGS = auto() + KWARG = auto() + RETURN = auto()
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/eric6/Plugins/CheckerPlugins/CodeStyleChecker/Annotations/AnnotationsFunctionVisitor.py Fri Apr 16 18:03:43 2021 +0200 @@ -0,0 +1,650 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2021 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing a node visitor for function type annotations. +""" + +# +# The visitor and associated classes are adapted from flake8-annotations v2.6.2 +# + +import ast +import itertools + +from .AnnotationsEnums import AnnotationType, ClassDecoratorType, FunctionType + +# The order of AST_ARG_TYPES must match Python's grammar +AST_ARG_TYPES = ("posonlyargs", "args", "vararg", "kwonlyargs", "kwarg") + + +class Argument: + """ + Class representing a function argument. + """ + def __init__(self, argname, lineno, col_offset, annotationType, + hasTypeAnnotation=False, has3107Annotation=False, + hasTypeComment=False): + """ + Constructor + + @param argname name of the argument + @type str + @param lineno line number + @type int + @param col_offset column number + @type int + @param annotationType type of annotation + @type AnnotationType + @param hasTypeAnnotation flag indicating the presence of a type + annotation (defaults to False) + @type bool (optional) + @param has3107Annotation flag indicating the presence of a PEP 3107 + annotation (defaults to False) + @type bool (optional) + @param hasTypeComment flag indicating the presence of a type comment + (defaults to False) + @type bool (optional) + """ + self.argname = argname + self.lineno = lineno + self.col_offset = col_offset + self.annotationType = annotationType + self.hasTypeAnnotation = hasTypeAnnotation + self.has3107Annotation = has3107Annotation + self.hasTypeComment = hasTypeComment + + @classmethod + def fromNode(cls, node, annotationTypeName): + """ + Class method to create an Argument object based on the given node. + + @param node reference to the node to be converted + @type ast.arguments + @param annotationTypeName name of the annotation type + @type str + @return Argument object + @rtype Argument + """ + annotationType = AnnotationType[annotationTypeName] + newArg = cls(node.arg, node.lineno, node.col_offset, annotationType) + + newArg.hasTypeAnnotation = False + if node.annotation: + newArg.hasTypeAnnotation = True + newArg.has3107Annotation = True + + if node.type_comment: + newArg.hasTypeAnnotation = True + newArg.hasTypeComment = True + + return newArg + + +class Function: + """ + Class representing a function. + """ + def __init__(self, name, lineno, col_offset, + functionType=FunctionType.PUBLIC, isClassMethod=False, + classDecoratorType=None, isReturnAnnotated=False, + hasTypeComment=False, hasOnlyNoneReturns=True, + isNested=False, decoratorList=None, args=None): + """ + Constructor + + @param name name of the function + @type str + @param lineno line number + @type int + @param col_offset column number + @type int + @param functionType type of the function (defaults to + FunctionType.PUBLIC) + @type FunctionType (optional) + @param isClassMethod flag indicating a class method (defaults to False) + @type bool (optional) + @param classDecoratorType type of a function decorator + (defaults to None) + @type ClassDecoratorType or None (optional) + @param isReturnAnnotated flag indicating the presence of a return + type annotation (defaults to False) + @type bool (optional) + @param hasTypeComment flag indicating the presence of a type comment + (defaults to False) + @type bool (optional) + @param hasOnlyNoneReturns flag indicating only None return values + (defaults to True) + @type bool (optional) + @param isNested flag indicating a nested function (defaults to False) + @type bool (optional) + @param decoratorList list of decorator nodes (defaults to None) + @type list of ast.Attribute, ast.Call or ast.Name (optional) + @param args list of arguments (defaults to None) + @type list of Argument (optional) + """ + self.name = name + self.lineno = lineno + self.col_offset = col_offset + self.functionType = functionType + self.isClassMethod = isClassMethod + self.classDecoratorType = classDecoratorType + self.isReturnAnnotated = isReturnAnnotated + self.hasTypeComment = hasTypeComment + self.hasOnlyNoneReturns = hasOnlyNoneReturns + self.isNested = isNested + self.decoratorList = decoratorList + self.args = args + + def isFullyAnnotated(self): + """ + Public method to check, if the function definition is fully type + annotated. + + Note: self.args will always include an Argument object for return. + + @return flag indicating a fully annotated function definition + @rtype bool + """ + return all(arg.hasTypeAnnotation for arg in self.args) + + def isDynamicallyTyped(self): + """ + Public method to check, if a function definition is dynamically typed + (i.e. completely lacking hints). + + @return flag indicating a dynamically typed function definition + @rtype bool + """ + return not any(arg.hasTypeAnnotation for arg in self.args) + + def getMissedAnnotations(self): + """ + Public method to provide a list of arguments with missing type + annotations. + + @return list of arguments with missing type annotations + @rtype list of Argument + """ + return [arg for arg in self.args if not arg.hasTypeAnnotation] + + def getAnnotatedArguments(self): + """ + Public method to get list of arguments with type annotations. + + @return list of arguments with type annotations. + @rtype list of Argument + """ + return [arg for arg in self.args if arg.hasTypeAnnotation] + + def hasDecorator(self, checkDecorators): + """ + Public method to check whether the function node is decorated by any of + the provided decorators. + + Decorator matching is done against the provided `checkDecorators` set. + Decorators are assumed to be either a module attribute (e.g. + `@typing.overload`) or name (e.g. `@overload`). For the case of a + module attribute, only the attribute is checked against + `overload_decorators`. + + Note: Deeper decorator imports (e.g. `a.b.overload`) are not explicitly + supported. + + @param checkDecorators set of decorators to check against + @type set of str + @return flag indicating the presence of any decorators + @rtype bool + """ + for decorator in self.decoratorList: + # Drop to a helper to allow for simpler handling of callable + # decorators + return self.__decoratorChecker(decorator, checkDecorators) + else: + return False + + def __decoratorChecker(self, decorator, checkDecorators): + """ + Private method to check the provided decorator for a match against the + provided set of check names. + + Decorators are assumed to be of the following form: + * `a.name` or `a.name()` + * `name` or `name()` + + Note: Deeper imports (e.g. `a.b.name`) are not explicitly supported. + + @param decorator decorator node to check + @type ast.Attribute, ast.Call or ast.Name + @param checkDecorators set of decorators to check against + @type set of str + @return flag indicating the presence of any decorators + @rtype bool + """ + if isinstance(decorator, ast.Name): + # e.g. `@overload`, where `decorator.id` will be the name + if decorator.id in checkDecorators: + return True + elif isinstance(decorator, ast.Attribute): + # e.g. `@typing.overload`, where `decorator.attr` will be the name + if decorator.attr in checkDecorators: + return True + elif isinstance(decorator, ast.Call): + # e.g. `@overload()` or `@typing.overload()`, where + # `decorator.func` will be `ast.Name` or `ast.Attribute`, + # which we can check recursively + return self.__decoratorChecker(decorator.func, checkDecorators) + + return None + + @classmethod + def fromNode(cls, node, lines, **kwargs): + """ + Class method to create a Function object from ast.FunctionDef or + ast.AsyncFunctionDef nodes. + + Accept the source code, as a list of strings, in order to get the + column where the function definition ends. + + With exceptions, input kwargs are passed straight through to Function's + __init__. The following kwargs will be overridden: + * function_type + * class_decorator_type + * args + + @param node reference to the function definition node + @type ast.AsyncFunctionDef or ast.FunctionDef + @param lines list of source code lines + @type list of str + @keyparam **kwargs keyword arguments + @type dict + @return created Function object + @rtype Function + """ + # Extract function types from function name + kwargs["functionType"] = cls.getFunctionType(node.name) + + # Identify type of class method, if applicable + if kwargs.get("isClassMethod", False): + kwargs["classDecoratorType"] = cls.getClassDecoratorType(node) + + # Store raw decorator list for use by property methods + kwargs["decoratorList"] = node.decorator_list + + newFunction = cls(node.name, node.lineno, node.col_offset, **kwargs) + + # Iterate over arguments by type & add + newFunction.args = [] + for argType in AST_ARG_TYPES: + args = node.args.__getattribute__(argType) + if args: + if not isinstance(args, list): + args = [args] + + newFunction.args.extend( + [Argument.fromNode(arg, argType.upper()) + for arg in args] + ) + + # Create an Argument object for the return hint + defEndLineno, defEndColOffset = cls.colonSeeker(node, lines) + returnArg = Argument("return", defEndLineno, defEndColOffset, + AnnotationType.RETURN) + if node.returns: + returnArg.hasTypeAnnotation = True + returnArg.has3107Annotation = True + newFunction.isReturnAnnotated = True + + newFunction.args.append(returnArg) + + # Type comments in-line with input arguments are handled by the + # Argument class. If a function-level type comment is present, attempt + # to parse for any missed type hints. + if node.type_comment: + newFunction.hasTypeComment = True + newFunction = cls.tryTypeComment(newFunction, node) + + # Check for the presence of non-`None` returns using the special-case + # return node visitor. + returnVisitor = ReturnVisitor(node) + returnVisitor.visit(node) + newFunction.hasOnlyNoneReturns = returnVisitor.hasOnlyNoneReturns + + return newFunction + + @staticmethod + def colonSeeker(node, lines): + """ + Static method to find the line & column indices of the function + definition's closing colon. + + @param node reference to the function definition node + @type ast.AsyncFunctionDef or ast.FunctionDef + @param lines list of source code lines + @type list of str + @return line and column offset of the colon + @rtype tuple of (int, int) + """ + # Special case single line function definitions + if node.lineno == node.body[0].lineno: + return Function._singleLineColonSeeker( + node, lines[node.lineno - 1]) + + defEndLineno = node.body[0].lineno - 1 + + # Use str.rfind() to account for annotations on the same line, + # definition closure should be the last : on the line + defEndColOffset = lines[defEndLineno - 1].rfind(":") + + return defEndLineno, defEndColOffset + + @staticmethod + def _singleLineColonSeeker(node, line): + """ + Static method to find the line & column indices of a single line + function definition. + + @param node reference to the function definition node + @type ast.AsyncFunctionDef or ast.FunctionDef + @param line source code line + @type str + @return line and column offset of the colon + @rtype tuple of (int, int) + """ + colStart = node.col_offset + colEnd = node.body[0].col_offset + defEndColOffset = line.rfind(":", colStart, colEnd) + + return node.lineno, defEndColOffset + + @staticmethod + def tryTypeComment(funcObj, node): + """ + Static method to infer type hints from a function-level type comment. + + If a function is type commented it is assumed to have a return + annotation, otherwise Python will fail to parse the hint. + + @param funcObj reference to the Function object + @type Function + @param node reference to the function definition node + @type ast.AsyncFunctionDef or ast.FunctionDef + @return reference to the modified Function object + @rtype Function + """ + hintTree = ast.parse(node.type_comment, "<func_type>", "func_type") + hintTree = Function._maybeInjectClassArgument(hintTree, funcObj) + + for arg, hintComment in itertools.zip_longest( + funcObj.args, hintTree.argtypes + ): + if isinstance(hintComment, ast.Ellipsis): + continue + + if arg and hintComment: + arg.hasTypeAnnotation = True + arg.hasTypeComment = True + + # Return arg is always last + funcObj.args[-1].hasTypeAnnotation = True + funcObj.args[-1].hasTypeComment = True + funcObj.isReturnAnnotated = True + + return funcObj + + @staticmethod + def _maybeInjectClassArgument(hintTree, funcObj): + """ + Static method to inject `self` or `cls` args into a type comment to + align with PEP 3107-style annotations. + + Because PEP 484 does not describe a method to provide partial function- + level type comments, there is a potential for ambiguity in the context + of both class methods and classmethods when aligning type comments to + method arguments. + + These two class methods, for example, should lint equivalently: + + def bar(self, a): + # type: (int) -> int + ... + + def bar(self, a: int) -> int + ... + + When this example type comment is parsed by `ast` and then matched with + the method's arguments, it associates the `int` hint to `self` rather + than `a`, so a dummy hint needs to be provided in situations where + `self` or `class` are not hinted in the type comment in order to + achieve equivalent linting results to PEP-3107 style annotations. + + A dummy `ast.Ellipses` constant is injected if the following criteria + are met: + 1. The function node is either a class method or classmethod + 2. The number of hinted args is at least 1 less than the number + of function args + + @param hintTree parsed type hint node + @type ast.FunctionType + @param funcObj reference to the Function object + @type Function + @return reference to the hint node + @rtype ast.FunctionType + """ + if not funcObj.isClassMethod: + # Short circuit + return hintTree + + if ( + funcObj.classDecoratorType != ClassDecoratorType.STATICMETHOD and + len(hintTree.argtypes) < (len(funcObj.args) - 1) + ): + # Subtract 1 to skip return arg + hintTree.argtypes = [ast.Ellipsis()] + hintTree.argtypes + + return hintTree + + @staticmethod + def getFunctionType(functionName): + """ + Static method to determine the function's FunctionType from its name. + + MethodType is determined by the following priority: + 1. Special: function name prefixed & suffixed by "__" + 2. Private: function name prefixed by "__" + 3. Protected: function name prefixed by "_" + 4. Public: everything else + + @param functionName function name to be checked + @type str + @return type of function + @rtype FunctionType + """ + if functionName.startswith("__") and functionName.endswith("__"): + return FunctionType.SPECIAL + elif functionName.startswith("__"): + return FunctionType.PRIVATE + elif functionName.startswith("_"): + return FunctionType.PROTECTED + else: + return FunctionType.PUBLIC + + @staticmethod + def getClassDecoratorType(functionNode): + """ + Static method to get the class method's decorator type from its + function node. + + Only @classmethod and @staticmethod decorators are identified; all + other decorators are ignored + + If @classmethod or @staticmethod decorators are not present, this + function will return None. + + @param functionNode reference to the function definition node + @type ast.AsyncFunctionDef or ast.FunctionDef + @return class decorator type + @rtype ClassDecoratorType or None + """ + # @classmethod and @staticmethod will show up as ast.Name objects, + # where callable decorators will show up as ast.Call, which we can + # ignore + decorators = [ + decorator.id + for decorator in functionNode.decorator_list + if isinstance(decorator, ast.Name) + ] + + if "classmethod" in decorators: + return ClassDecoratorType.CLASSMETHOD + elif "staticmethod" in decorators: + return ClassDecoratorType.STATICMETHOD + else: + return None + + +class FunctionVisitor(ast.NodeVisitor): + """ + Class implementing a node visitor to check function annotations. + """ + def __init__(self, lines): + """ + Constructor + + @param lines source code lines of the function + @type list of str + """ + self.lines = lines + self.functionDefinitions = [] + self.__context = [] + + def switchContext(self, node): + """ + Public method implementing a context switcher as a generic function + visitor in order to track function context. + + Without keeping track of context, it's challenging to reliably + differentiate class methods from "regular" functions, especially in the + case of nested classes. + + @param node reference to the function definition node to be analyzed + @type ast.AsyncFunctionDef or ast.FunctionDef + """ + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + # Check for non-empty context first to prevent IndexErrors for + # non-nested nodes + if self.__context: + if isinstance(self.__context[-1], ast.ClassDef): + # Check if current context is a ClassDef node & pass the + # appropriate flag + self.functionDefinitions.append( + Function.fromNode(node, self.lines, isClassMethod=True) + ) + elif isinstance( + self.__context[-1], + (ast.FunctionDef, ast.AsyncFunctionDef) + ): + # Check for nested function & pass the appropriate flag + self.functionDefinitions.append( + Function.fromNode(node, self.lines, isNested=True) + ) + else: + self.functionDefinitions.append( + Function.fromNode(node, self.lines)) + + self.__context.append(node) + self.generic_visit(node) + self.__context.pop() + + visit_FunctionDef = switchContext + visit_AsyncFunctionDef = switchContext + visit_ClassDef = switchContext + + +class ReturnVisitor(ast.NodeVisitor): + """ + Class implementing a node visitor to check the return statements of a + function node. + + If the function node being visited has an explicit return statement of + anything other than `None`, the `instance.hasOnlyNoneReturns` flag will + be set to `False`. + + If the function node being visited has no return statement, or contains + only return statement(s) that explicitly return `None`, the + `instance.hasOnlyNoneReturns` flag will be set to `True`. + + Due to the generic visiting being done, we need to keep track of the + context in which a non-`None` return node is found. These functions are + added to a set that is checked to see whether nor not the parent node is + present. + """ + def __init__(self, parentNode): + """ + Constructor + + @param parentNode reference to the function definition node to be + analyzed + @type ast.AsyncFunctionDef or ast.FunctionDef + """ + self.parentNode = parentNode + self.__context = [] + self.__nonNoneReturnNodes = set() + + @property + def hasOnlyNoneReturns(self): + """ + Public method indicating, that the parent node isn't in the visited + nodes that don't return `None`. + + @return flag indicating, that the parent node isn't in the visited + nodes that don't return `None` + @rtype bool + """ + return self.parentNode not in self.__nonNoneReturnNodes + + def visit_Return(self, node): + """ + Public method to check each Return node to see if it returns anything + other than `None`. + + If the node being visited returns anything other than `None`, its + parent context is added to the set of non-returning child nodes of + the parent node. + + @param node reference to the AST Return node + @type ast.Return + """ + if node.value is not None: + # In the event of an explicit `None` return (`return None`), the + # node body will be an instance of either `ast.Constant` (3.8+) or + # `ast.NameConstant`, which we need to check to see if it's + # actually `None` + if ( + isinstance(node.value, (ast.Constant, ast.NameConstant)) and + node.value.value is None + ): + return + + self.__nonNoneReturnNodes.add(self.__context[-1]) + + def switchContext(self, node): + """ + Public method implementing a context switcher as a generic function + visitor in order to track function context. + + Without keeping track of context, it's challenging to reliably + differentiate class methods from "regular" functions, especially in the + case of nested classes. + + @param node reference to the function definition node to be analyzed + @type ast.AsyncFunctionDef or ast.FunctionDef + """ + self.__context.append(node) + self.generic_visit(node) + self.__context.pop() + + visit_FunctionDef = switchContext + visit_AsyncFunctionDef = switchContext
--- a/eric6/Plugins/CheckerPlugins/CodeStyleChecker/Annotations/translations.py Thu Apr 15 18:11:24 2021 +0200 +++ b/eric6/Plugins/CheckerPlugins/CodeStyleChecker/Annotations/translations.py Fri Apr 16 18:03:43 2021 +0200 @@ -45,6 +45,9 @@ "A206": QCoreApplication.translate( "AnnotationsChecker", "missing return type annotation for classmethod"), + "A301": QCoreApplication.translate( + "AnnotationsChecker", + "PEP 484 disallows both type annotations and type comments"), "A881": QCoreApplication.translate( "AnnotationsChecker", @@ -53,6 +56,9 @@ "A891": QCoreApplication.translate( "AnnotationsChecker", "type annotation is too complex ({0} > {1})"), + "A892": QCoreApplication.translate( + "AnnotationsChecker", + "type annotation is too long ({0} > {1})"), } _annotationsMessagesSampleArgs = { @@ -61,4 +67,5 @@ "A003": ["kwargs"], "A881": [60], "A891": [5, 3], + "A892": [10, 7], }
--- a/eric6/Plugins/CheckerPlugins/CodeStyleChecker/CodeStyleChecker.py Thu Apr 15 18:11:24 2021 +0200 +++ b/eric6/Plugins/CheckerPlugins/CodeStyleChecker/CodeStyleChecker.py Fri Apr 16 18:03:43 2021 +0200 @@ -424,8 +424,9 @@ errors += complexityChecker.errors # check function annotations - if sys.version_info >= (3, 5, 0): - # annotations are supported from Python 3.5 on + if sys.version_info >= (3, 8, 0): + # annotations with type comments are supported from + # Python 3.8 on from Annotations.AnnotationsChecker import AnnotationsChecker annotationsChecker = AnnotationsChecker( source, filename, tree, select, ignore, [], repeatMessages,
--- a/eric6/Preferences/ShortcutsFile.py Thu Apr 15 18:11:24 2021 +0200 +++ b/eric6/Preferences/ShortcutsFile.py Fri Apr 16 18:03:43 2021 +0200 @@ -26,7 +26,7 @@ """ Class representing the shortcuts JSON file. """ - def __init__(self, parent: QObject = None): + def __init__(self: "ShortcutsFile", parent: QObject = None) -> None: """ Constructor @@ -35,8 +35,8 @@ """ super().__init__(parent) - def __addActionsToDict(self, category: str, actions: list, - actionsDict: dict): + def __addActionsToDict(self: "ShortcutsFile", category: str, actions: list, + actionsDict: dict) -> None: """ Private method to add a list of actions to the actions dictionary. @@ -58,7 +58,8 @@ act.alternateShortcut().toString() ) - def writeFile(self, filename: str, helpViewer: HelpViewer = None) -> bool: + def writeFile(self: "ShortcutsFile", filename: str, + helpViewer: HelpViewer = None) -> bool: """ Public method to write the shortcuts data to a shortcuts JSON file. @@ -176,7 +177,7 @@ return True - def readFile(self, filename: str) -> bool: + def readFile(self: "ShortcutsFile", filename: str) -> bool: """ Public method to read the shortcuts data from a shortcuts JSON file.