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