|
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) |