src/eric7/Plugins/CheckerPlugins/CodeStyleChecker/Annotations/AnnotationsChecker.py

branch
eric7
changeset 9209
b99e7fd55fd3
parent 8881
54e42bc2437a
child 9221
bf71ee032bb4
equal deleted inserted replaced
9208:3fc8dfeb6ebe 9209:b99e7fd55fd3
1 # -*- coding: utf-8 -*-
2
3 # Copyright (c) 2019 - 2022 Detlev Offenbach <detlev@die-offenbachs.de>
4 #
5
6 """
7 Module implementing a checker for function type annotations.
8 """
9
10 import copy
11 import ast
12 import sys
13 from functools import lru_cache
14
15 import AstUtilities
16
17 from .AnnotationsEnums import AnnotationType, ClassDecoratorType, FunctionType
18 from .AnnotationsCheckerDefaults import AnnotationsCheckerDefaultArgs
19
20
21 class AnnotationsChecker:
22 """
23 Class implementing a checker for function type annotations.
24 """
25 Codes = [
26 ## Function Annotations
27 "A001", "A002", "A003",
28
29 ## Method Annotations
30 "A101", "A102",
31
32 ## Return Annotations
33 "A201", "A202", "A203", "A204", "A205", "A206",
34
35 ## Mixed kind of annotations
36 "A301",
37
38 ## Annotations Future
39 "A871",
40
41 ## Annotation Coverage
42 "A881",
43
44 ## Annotation Complexity
45 "A891", "A892",
46 ]
47
48 def __init__(self, source, filename, tree, select, ignore, expected,
49 repeat, args):
50 """
51 Constructor
52
53 @param source source code to be checked
54 @type list of str
55 @param filename name of the source file
56 @type str
57 @param tree AST tree of the source code
58 @type ast.Module
59 @param select list of selected codes
60 @type list of str
61 @param ignore list of codes to be ignored
62 @type list of str
63 @param expected list of expected codes
64 @type list of str
65 @param repeat flag indicating to report each occurrence of a code
66 @type bool
67 @param args dictionary of arguments for the annotation checks
68 @type dict
69 """
70 self.__select = tuple(select)
71 self.__ignore = ('',) if select else tuple(ignore)
72 self.__expected = expected[:]
73 self.__repeat = repeat
74 self.__filename = filename
75 self.__source = source[:]
76 self.__tree = copy.deepcopy(tree)
77 self.__args = args
78
79 # statistics counters
80 self.counters = {}
81
82 # collection of detected errors
83 self.errors = []
84
85 checkersWithCodes = [
86 (
87 self.__checkFunctionAnnotations,
88 ("A001", "A002", "A003", "A101", "A102",
89 "A201", "A202", "A203", "A204", "A205", "A206",
90 "A301", )
91 ),
92 (self.__checkAnnotationsFuture, ("A871",)),
93 (self.__checkAnnotationsCoverage, ("A881",)),
94 (self.__checkAnnotationComplexity, ("A891", "A892")),
95 ]
96
97 self.__checkers = []
98 for checker, codes in checkersWithCodes:
99 if any(not (code and self.__ignoreCode(code))
100 for code in codes):
101 self.__checkers.append(checker)
102
103 def __ignoreCode(self, code):
104 """
105 Private method to check if the message code should be ignored.
106
107 @param code message code to check for
108 @type str
109 @return flag indicating to ignore the given code
110 @rtype bool
111 """
112 return (code.startswith(self.__ignore) and
113 not code.startswith(self.__select))
114
115 def __error(self, lineNumber, offset, code, *args):
116 """
117 Private method to record an issue.
118
119 @param lineNumber line number of the issue
120 @type int
121 @param offset position within line of the issue
122 @type int
123 @param code message code
124 @type str
125 @param args arguments for the message
126 @type list
127 """
128 if self.__ignoreCode(code):
129 return
130
131 if code in self.counters:
132 self.counters[code] += 1
133 else:
134 self.counters[code] = 1
135
136 # Don't care about expected codes
137 if code in self.__expected:
138 return
139
140 if code and (self.counters[code] == 1 or self.__repeat):
141 # record the issue with one based line number
142 self.errors.append(
143 {
144 "file": self.__filename,
145 "line": lineNumber + 1,
146 "offset": offset,
147 "code": code,
148 "args": args,
149 }
150 )
151
152 def run(self):
153 """
154 Public method to check the given source against annotation issues.
155 """
156 if not self.__filename:
157 # don't do anything, if essential data is missing
158 return
159
160 if not self.__checkers:
161 # don't do anything, if no codes were selected
162 return
163
164 for check in self.__checkers:
165 check()
166
167 #######################################################################
168 ## Annotations
169 ##
170 ## adapted from: flake8-annotations v2.7.0
171 #######################################################################
172
173 def __checkFunctionAnnotations(self):
174 """
175 Private method to check for function annotation issues.
176 """
177 suppressNoneReturning = self.__args.get(
178 "SuppressNoneReturning",
179 AnnotationsCheckerDefaultArgs["SuppressNoneReturning"])
180 suppressDummyArgs = self.__args.get(
181 "SuppressDummyArgs",
182 AnnotationsCheckerDefaultArgs["SuppressDummyArgs"])
183 allowUntypedDefs = self.__args.get(
184 "AllowUntypedDefs",
185 AnnotationsCheckerDefaultArgs["AllowUntypedDefs"])
186 allowUntypedNested = self.__args.get(
187 "AllowUntypedNested",
188 AnnotationsCheckerDefaultArgs["AllowUntypedNested"])
189 mypyInitReturn = self.__args.get(
190 "MypyInitReturn",
191 AnnotationsCheckerDefaultArgs["MypyInitReturn"])
192
193 # Store decorator lists as sets for easier lookup
194 dispatchDecorators = set(self.__args.get(
195 "DispatchDecorators",
196 AnnotationsCheckerDefaultArgs["DispatchDecorators"]))
197 overloadDecorators = set(self.__args.get(
198 "OverloadDecorators",
199 AnnotationsCheckerDefaultArgs["OverloadDecorators"]))
200
201 from .AnnotationsFunctionVisitor import FunctionVisitor
202 visitor = FunctionVisitor(self.__source)
203 visitor.visit(self.__tree)
204
205 # Keep track of the last encountered function decorated by
206 # `typing.overload`, if any. Per the `typing` module documentation,
207 # a series of overload-decorated definitions must be followed by
208 # exactly one non-overload-decorated definition of the same function.
209 lastOverloadDecoratedFunctionName = None
210
211 # Iterate over the arguments with missing type hints, by function.
212 for function in visitor.functionDefinitions:
213 if (
214 function.isDynamicallyTyped() and
215 (allowUntypedDefs or
216 (function.isNested and allowUntypedNested))
217 ):
218 # Skip recording errors from dynamically typed functions
219 # or nested functions
220 continue
221
222 # Skip recording errors for configured dispatch functions, such as
223 # (by default) `functools.singledispatch` and
224 # `functools.singledispatchmethod`
225 if function.hasDecorator(dispatchDecorators):
226 continue
227
228 # Create sentinels to check for mixed hint styles
229 hasTypeComment = function.hasTypeComment
230
231 has3107Annotation = False
232 # PEP 3107 annotations are captured by the return arg
233
234 # Iterate over annotated args to detect mixing of type annotations
235 # and type comments. Emit this only once per function definition
236 for arg in function.getAnnotatedArguments():
237 if arg.hasTypeComment:
238 hasTypeComment = True
239
240 if arg.has3107Annotation:
241 has3107Annotation = True
242
243 if hasTypeComment and has3107Annotation:
244 # Short-circuit check for mixing of type comments &
245 # 3107-style annotations
246 self.__error(function.lineno - 1, function.col_offset,
247 "A301")
248 break
249
250 # Before we iterate over the function's missing annotations, check
251 # to see if it's the closing function def in a series of
252 # `typing.overload` decorated functions.
253 if lastOverloadDecoratedFunctionName == function.name:
254 continue
255
256 # If it's not, and it is overload decorated, store it for the next
257 # iteration
258 if function.hasDecorator(overloadDecorators):
259 lastOverloadDecoratedFunctionName = function.name
260
261 # Record explicit errors for arguments that are missing annotations
262 for arg in function.getMissedAnnotations():
263 if arg.argname == "return":
264 # return annotations have multiple possible short-circuit
265 # paths
266 if (
267 suppressNoneReturning and
268 not arg.hasTypeAnnotation and
269 function.hasOnlyNoneReturns
270 ):
271 # Skip recording return errors if the function has only
272 # `None` returns. This includes the case of no returns.
273 continue
274
275 if (
276 mypyInitReturn and
277 function.isClassMethod and
278 function.name == "__init__" and
279 function.getAnnotatedArguments()
280 ):
281 # Skip recording return errors for `__init__` if at
282 # least one argument is annotated
283 continue
284
285 # If the `suppressDummyArgs` flag is `True`, skip recording
286 # errors for any arguments named `_`
287 if arg.argname == "_" and suppressDummyArgs:
288 continue
289
290 self.__classifyError(function, arg)
291
292 def __classifyError(self, function, arg):
293 """
294 Private method to classify the missing type annotation based on the
295 Function & Argument metadata.
296
297 For the currently defined rules & program flow, the assumption can be
298 made that an argument passed to this method will match a linting error,
299 and will only match a single linting error
300
301 This function provides an initial classificaton, then passes relevant
302 attributes to cached helper function(s).
303
304 @param function reference to the Function object
305 @type Function
306 @param arg reference to the Argument object
307 @type Argument
308 """
309 # Check for return type
310 # All return "arguments" have an explicitly defined name "return"
311 if arg.argname == "return":
312 errorCode = self.__returnErrorClassifier(
313 function.isClassMethod, function.classDecoratorType,
314 function.functionType
315 )
316 else:
317 # Otherwise, classify function argument error
318 isFirstArg = arg == function.args[0]
319 errorCode = self.__argumentErrorClassifier(
320 function.isClassMethod, isFirstArg,
321 function.classDecoratorType, arg.annotationType,
322 )
323
324 if errorCode in ("A001", "A002", "A003"):
325 self.__error(arg.lineno - 1, arg.col_offset, errorCode,
326 arg.argname)
327 else:
328 self.__error(arg.lineno - 1, arg.col_offset, errorCode)
329
330 @lru_cache()
331 def __returnErrorClassifier(self, isClassMethod, classDecoratorType,
332 functionType):
333 """
334 Private method to classify a return type annotation issue.
335
336 @param isClassMethod flag indicating a classmethod type function
337 @type bool
338 @param classDecoratorType type of class decorator
339 @type ClassDecoratorType
340 @param functionType type of function
341 @type FunctionType
342 @return error code
343 @rtype str
344 """
345 # Decorated class methods (@classmethod, @staticmethod) have a higher
346 # priority than the rest
347 if isClassMethod:
348 if classDecoratorType == ClassDecoratorType.CLASSMETHOD:
349 return "A206"
350 elif classDecoratorType == ClassDecoratorType.STATICMETHOD:
351 return "A205"
352
353 if functionType == FunctionType.SPECIAL:
354 return "A204"
355 elif functionType == FunctionType.PRIVATE:
356 return "A203"
357 elif functionType == FunctionType.PROTECTED:
358 return "A202"
359 else:
360 return "A201"
361
362 @lru_cache()
363 def __argumentErrorClassifier(self, isClassMethod, isFirstArg,
364 classDecoratorType, annotationType):
365 """
366 Private method to classify an argument type annotation issue.
367
368 @param isClassMethod flag indicating a classmethod type function
369 @type bool
370 @param isFirstArg flag indicating the first argument
371 @type bool
372 @param classDecoratorType type of class decorator
373 @type enums.ClassDecoratorType
374 @param annotationType type of annotation
375 @type AnnotationType
376 @return error code
377 @rtype str
378 """
379 # Check for regular class methods and @classmethod, @staticmethod is
380 # deferred to final check
381 if isClassMethod and isFirstArg:
382 # The first function argument here would be an instance of self or
383 # class
384 if classDecoratorType == ClassDecoratorType.CLASSMETHOD:
385 return "A102"
386 elif classDecoratorType != ClassDecoratorType.STATICMETHOD:
387 # Regular class method
388 return "A101"
389
390 # Check for remaining codes
391 if annotationType == AnnotationType.KWARG:
392 return "A003"
393 elif annotationType == AnnotationType.VARARG:
394 return "A002"
395 else:
396 # Combine PosOnlyArgs, Args, and KwOnlyArgs
397 return "A001"
398
399 #######################################################################
400 ## Annotations Coverage
401 ##
402 ## adapted from: flake8-annotations-coverage v0.0.5
403 #######################################################################
404
405 def __checkAnnotationsCoverage(self):
406 """
407 Private method to check for function annotation coverage.
408 """
409 minAnnotationsCoverage = self.__args.get(
410 "MinimumCoverage",
411 AnnotationsCheckerDefaultArgs["MinimumCoverage"])
412 if minAnnotationsCoverage == 0:
413 # 0 means it is switched off
414 return
415
416 functionDefs = [
417 f for f in ast.walk(self.__tree)
418 if isinstance(f, (ast.AsyncFunctionDef, ast.FunctionDef))
419 ]
420 if not functionDefs:
421 # no functions/methods at all
422 return
423
424 functionDefAnnotationsInfo = [
425 self.__hasTypeAnnotations(f) for f in functionDefs
426 ]
427 annotationsCoverage = int(
428 len(list(filter(None, functionDefAnnotationsInfo))) /
429 len(functionDefAnnotationsInfo) * 100
430 )
431 if annotationsCoverage < minAnnotationsCoverage:
432 self.__error(0, 0, "A881", annotationsCoverage)
433
434 def __hasTypeAnnotations(self, funcNode):
435 """
436 Private method to check for type annotations.
437
438 @param funcNode reference to the function definition node to be checked
439 @type ast.AsyncFunctionDef or ast.FunctionDef
440 @return flag indicating the presence of type annotations
441 @rtype bool
442 """
443 hasReturnAnnotation = funcNode.returns is not None
444 hasArgsAnnotations = any(a for a in funcNode.args.args
445 if a.annotation is not None)
446 hasKwargsAnnotations = (funcNode.args and
447 funcNode.args.kwarg and
448 funcNode.args.kwarg.annotation is not None)
449 hasKwonlyargsAnnotations = any(a for a in funcNode.args.kwonlyargs
450 if a.annotation is not None)
451
452 return any((hasReturnAnnotation, hasArgsAnnotations,
453 hasKwargsAnnotations, hasKwonlyargsAnnotations))
454
455 #######################################################################
456 ## Annotations Complexity
457 ##
458 ## adapted from: flake8-annotations-complexity v0.0.6
459 #######################################################################
460
461 def __checkAnnotationComplexity(self):
462 """
463 Private method to check the type annotation complexity.
464 """
465 maxAnnotationComplexity = self.__args.get(
466 "MaximumComplexity",
467 AnnotationsCheckerDefaultArgs["MaximumComplexity"])
468 maxAnnotationLength = self.__args.get(
469 "MaximumLength", AnnotationsCheckerDefaultArgs["MaximumLength"])
470 typeAnnotations = []
471
472 functionDefs = [
473 f for f in ast.walk(self.__tree)
474 if isinstance(f, (ast.AsyncFunctionDef, ast.FunctionDef))
475 ]
476 for functionDef in functionDefs:
477 typeAnnotations += list(filter(
478 None, [a.annotation for a in functionDef.args.args]))
479 if functionDef.returns:
480 typeAnnotations.append(functionDef.returns)
481 typeAnnotations += [a.annotation for a in ast.walk(self.__tree)
482 if isinstance(a, ast.AnnAssign) and a.annotation]
483 for annotation in typeAnnotations:
484 complexity = self.__getAnnotationComplexity(annotation)
485 if complexity > maxAnnotationComplexity:
486 self.__error(annotation.lineno - 1, annotation.col_offset,
487 "A891", complexity, maxAnnotationComplexity)
488
489 annotationLength = self.__getAnnotationLength(annotation)
490 if annotationLength > maxAnnotationLength:
491 self.__error(annotation.lineno - 1, annotation.col_offset,
492 "A892", annotationLength, maxAnnotationLength)
493
494 def __getAnnotationComplexity(self, annotationNode, defaultComplexity=1):
495 """
496 Private method to determine the annotation complexity.
497
498 @param annotationNode reference to the node to determine the annotation
499 complexity for
500 @type ast.AST
501 @param defaultComplexity default complexity value
502 @type int
503 @return annotation complexity
504 @rtype = int
505 """
506 if AstUtilities.isString(annotationNode):
507 try:
508 annotationNode = ast.parse(annotationNode.s).body[0].value
509 except (SyntaxError, IndexError):
510 return defaultComplexity
511 if isinstance(annotationNode, ast.Subscript):
512 if sys.version_info >= (3, 9):
513 return (defaultComplexity +
514 self.__getAnnotationComplexity(annotationNode.slice))
515 else:
516 return (
517 defaultComplexity +
518 self.__getAnnotationComplexity(annotationNode.slice.value)
519 )
520 if isinstance(annotationNode, ast.Tuple):
521 return max(
522 (self.__getAnnotationComplexity(n)
523 for n in annotationNode.elts),
524 default=defaultComplexity
525 )
526 return defaultComplexity
527
528 def __getAnnotationLength(self, annotationNode):
529 """
530 Private method to determine the annotation length.
531
532 @param annotationNode reference to the node to determine the annotation
533 length for
534 @type ast.AST
535 @return annotation length
536 @rtype = int
537 """
538 if AstUtilities.isString(annotationNode):
539 try:
540 annotationNode = ast.parse(annotationNode.s).body[0].value
541 except (SyntaxError, IndexError):
542 return 0
543 if isinstance(annotationNode, ast.Subscript):
544 try:
545 if sys.version_info >= (3, 9):
546 return len(annotationNode.slice.elts)
547 else:
548 return len(annotationNode.slice.value.elts)
549 except AttributeError:
550 return 0
551 return 0
552
553 #######################################################################
554 ## 'from __future__ import annotations' checck
555 ##
556 ## adapted from: flake8-future-annotations v0.0.4
557 #######################################################################
558
559 def __checkAnnotationsFuture(self):
560 """
561 Private method to check the use of __future__ and typing imports.
562 """
563 from .AnnotationsFutureVisitor import AnnotationsFutureVisitor
564 visitor = AnnotationsFutureVisitor()
565 visitor.visit(self.__tree)
566
567 if (
568 visitor.importsFutureAnnotations() or
569 not visitor.hasTypingImports()
570 ):
571 return
572
573 imports = ", ".join(visitor.getTypingImports())
574 self.__error(0, 0, "A871", imports)

eric ide

mercurial