|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2021 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing a node visitor checking for code that could be simplified. |
|
8 """ |
|
9 |
|
10 import ast |
|
11 import collections |
|
12 |
|
13 try: |
|
14 from ast import unparse |
|
15 except ImportError: |
|
16 # Python < 3.9 |
|
17 from .ast_unparse import unparse |
|
18 |
|
19 ###################################################################### |
|
20 ## The following code is derived from the flake8-simplify package. |
|
21 ## |
|
22 ## Original License: |
|
23 ## |
|
24 ## MIT License |
|
25 ## |
|
26 ## Copyright (c) 2020 Martin Thoma |
|
27 ## |
|
28 ## Permission is hereby granted, free of charge, to any person obtaining a copy |
|
29 ## of this software and associated documentation files (the "Software"), to |
|
30 ## deal in the Software without restriction, including without limitation the |
|
31 ## rights to use, copy, modify, merge, publish, distribute, sublicense, and/or |
|
32 ## sell copies of the Software, and to permit persons to whom the Software is |
|
33 ## furnished to do so, subject to the following conditions: |
|
34 ## |
|
35 ## The above copyright notice and this permission notice shall be included in |
|
36 ## all copies or substantial portions of the Software. |
|
37 ## |
|
38 ## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
|
39 ## IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
|
40 ## FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
|
41 ## AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
|
42 ## LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING |
|
43 ## FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS |
|
44 ## IN THE SOFTWARE. |
|
45 ###################################################################### |
|
46 |
|
47 BOOL_CONST_TYPES = (ast.Constant, ast.NameConstant) |
|
48 AST_CONST_TYPES = (ast.Constant, ast.NameConstant, ast.Str, ast.Num) |
|
49 STR_TYPES = (ast.Constant, ast.Str) |
|
50 |
|
51 |
|
52 class SimplifyNodeVisitor(ast.NodeVisitor): |
|
53 """ |
|
54 Class to traverse the AST node tree and check for code that can be |
|
55 simplified. |
|
56 """ |
|
57 def __init__(self, errorCallback): |
|
58 """ |
|
59 Constructor |
|
60 |
|
61 @param errorCallback callback function to register an error |
|
62 @type func |
|
63 """ |
|
64 super(SimplifyNodeVisitor, self).__init__() |
|
65 |
|
66 self.__error = errorCallback |
|
67 |
|
68 def visit_Expr(self, node): |
|
69 """ |
|
70 Public method to process an Expr node. |
|
71 |
|
72 @param node reference to the Expr node |
|
73 @type ast.Expr |
|
74 """ |
|
75 self.__check112(node) |
|
76 |
|
77 self.generic_visit(node) |
|
78 |
|
79 def visit_BoolOp(self, node): |
|
80 """ |
|
81 Public method to process a BoolOp node. |
|
82 |
|
83 @param node reference to the BoolOp node |
|
84 @type ast.BoolOp |
|
85 """ |
|
86 self.__check101(node) |
|
87 self.__check109(node) |
|
88 |
|
89 self.generic_visit(node) |
|
90 |
|
91 def visit_If(self, node): |
|
92 """ |
|
93 Public method to process an If node. |
|
94 |
|
95 @param node reference to the If node |
|
96 @type ast.If |
|
97 """ |
|
98 self.__check102(node) |
|
99 self.__check103(node) |
|
100 self.__check106(node) |
|
101 self.__check108(node) |
|
102 |
|
103 self.generic_visit(node) |
|
104 |
|
105 def visit_For(self, node): |
|
106 """ |
|
107 Public method to process a For node. |
|
108 |
|
109 @param node reference to the For node |
|
110 @type ast.For |
|
111 """ |
|
112 self.__check104(node) |
|
113 self.__check110_111(node) |
|
114 |
|
115 self.generic_visit(node) |
|
116 |
|
117 def visit_Try(self, node): |
|
118 """ |
|
119 Public method to process a Try node. |
|
120 |
|
121 @param node reference to the Try node |
|
122 @type ast.Try |
|
123 """ |
|
124 self.__check105(node) |
|
125 self.__check107(node) |
|
126 |
|
127 self.generic_visit(node) |
|
128 |
|
129 ############################################################# |
|
130 ## Helper methods for the various checkers below |
|
131 ############################################################# |
|
132 |
|
133 def __getDuplicatedIsinstanceCall(self, node): |
|
134 """ |
|
135 Private method to get a list of isinstance arguments which could |
|
136 be combined. |
|
137 |
|
138 @param node reference to the AST node to be inspected |
|
139 @type ast.BoolOp |
|
140 @return list of variable names of duplicated isinstance calls |
|
141 @rtype list of str |
|
142 """ |
|
143 counter = collections.defaultdict(int) |
|
144 |
|
145 for call in node.values: |
|
146 # Ensure this is a call of the built-in isinstance() function. |
|
147 if not isinstance(call, ast.Call) or len(call.args) != 2: |
|
148 continue |
|
149 functionName = unparse(call.func) |
|
150 if functionName != "isinstance": |
|
151 continue |
|
152 |
|
153 arg0Name = unparse(call.args[0]) |
|
154 counter[arg0Name] += 1 |
|
155 |
|
156 return [name for name, count in counter.items() if count > 1] |
|
157 |
|
158 ############################################################# |
|
159 ## Methods to check for possible code simplifications below |
|
160 ############################################################# |
|
161 |
|
162 def __check101(self, node): |
|
163 """ |
|
164 Private method to check for duplicate isinstance() calls. |
|
165 |
|
166 @param node reference to the AST node to be checked |
|
167 @type ast.BoolOp |
|
168 """ |
|
169 if isinstance(node.op, ast.Or): |
|
170 for variable in self.__getDuplicatedIsinstanceCall(node): |
|
171 self.__error(node.lineno - 1, node.col_offset, "Y101", |
|
172 variable) |
|
173 |
|
174 def __check102(self, node): |
|
175 """ |
|
176 Private method to check for nested if statements without else blocks. |
|
177 |
|
178 @param node reference to the AST node to be checked |
|
179 @type ast.If |
|
180 """ |
|
181 # ## Pattern 1 |
|
182 # if a: <--- |
|
183 # if b: <--- |
|
184 # c |
|
185 isPattern1 = ( |
|
186 node.orelse == [] and |
|
187 len(node.body) == 1 and |
|
188 isinstance(node.body[0], ast.If) and |
|
189 node.body[0].orelse == [] |
|
190 ) |
|
191 # ## Pattern 2 |
|
192 # if a: < irrelvant for here |
|
193 # pass |
|
194 # elif b: <--- this is treated like a nested block |
|
195 # if c: <--- |
|
196 # d |
|
197 if isPattern1: |
|
198 self.__error(node.lineno - 1, node.col_offset, "Y102") |
|
199 |
|
200 def __check103(self, node): |
|
201 """ |
|
202 Private method to check for calls that wrap a condition to return |
|
203 a bool. |
|
204 |
|
205 @param node reference to the AST node to be checked |
|
206 @type ast.If |
|
207 """ |
|
208 # if cond: |
|
209 # return True |
|
210 # else: |
|
211 # return False |
|
212 if not ( |
|
213 len(node.body) != 1 or |
|
214 not isinstance(node.body[0], ast.Return) or |
|
215 not isinstance(node.body[0].value, BOOL_CONST_TYPES) or |
|
216 not ( |
|
217 node.body[0].value.value is True or |
|
218 node.body[0].value.value is False |
|
219 ) or |
|
220 len(node.orelse) != 1 or |
|
221 not isinstance(node.orelse[0], ast.Return) or |
|
222 not isinstance(node.orelse[0].value, BOOL_CONST_TYPES) or |
|
223 not ( |
|
224 node.orelse[0].value.value is True or |
|
225 node.orelse[0].value.value is False |
|
226 ) |
|
227 ): |
|
228 condition = unparse(node.test) |
|
229 self.__error(node.lineno - 1, node.col_offset, "Y103", condition) |
|
230 |
|
231 def __check104(self, node): |
|
232 """ |
|
233 Private method to check for "iterate and yield" patterns. |
|
234 |
|
235 @param node reference to the AST node to be checked |
|
236 @type ast.For |
|
237 """ |
|
238 # for item in iterable: |
|
239 # yield item |
|
240 if not ( |
|
241 len(node.body) != 1 or |
|
242 not isinstance(node.body[0], ast.Expr) or |
|
243 not isinstance(node.body[0].value, ast.Yield) or |
|
244 not isinstance(node.target, ast.Name) or |
|
245 not isinstance(node.body[0].value.value, ast.Name) or |
|
246 node.target.id != node.body[0].value.value.id or |
|
247 node.orelse != [] |
|
248 ): |
|
249 iterable = unparse(node.iter) |
|
250 self.__error(node.lineno - 1, node.col_offset, "Y104", iterable) |
|
251 |
|
252 def __check105(self, node): |
|
253 """ |
|
254 Private method to check for "try-except-pass" patterns. |
|
255 |
|
256 @param node reference to the AST node to be checked |
|
257 @type ast.Try |
|
258 """ |
|
259 # try: |
|
260 # foo() |
|
261 # except ValueError: |
|
262 # pass |
|
263 if not ( |
|
264 len(node.body) != 1 or |
|
265 len(node.handlers) != 1 or |
|
266 not isinstance(node.handlers[0], ast.ExceptHandler) or |
|
267 len(node.handlers[0].body) != 1 or |
|
268 not isinstance(node.handlers[0].body[0], ast.Pass) or |
|
269 node.orelse != [] |
|
270 ): |
|
271 if node.handlers[0].type is None: |
|
272 exception = "Exception" |
|
273 else: |
|
274 exception = unparse(node.handlers[0].type) |
|
275 self.__error(node.lineno - 1, node.col_offset, "Y105", exception) |
|
276 |
|
277 def __check106(self, node): |
|
278 """ |
|
279 Private method to check for calls where an exception is raised in else. |
|
280 |
|
281 @param node reference to the AST node to be checked |
|
282 @type ast.If |
|
283 """ |
|
284 # if cond: |
|
285 # return True |
|
286 # else: |
|
287 # raise Exception |
|
288 just_one = ( |
|
289 len(node.body) == 1 and |
|
290 len(node.orelse) >= 1 and |
|
291 isinstance(node.orelse[-1], ast.Raise) and |
|
292 not isinstance(node.body[-1], ast.Raise) |
|
293 ) |
|
294 many = ( |
|
295 len(node.body) > 2 * len(node.orelse) and |
|
296 len(node.orelse) >= 1 and |
|
297 isinstance(node.orelse[-1], ast.Raise) and |
|
298 not isinstance(node.body[-1], ast.Raise) |
|
299 ) |
|
300 if just_one or many: |
|
301 self.__error(node.lineno - 1, node.col_offset, "Y106") |
|
302 |
|
303 def __check107(self, node): |
|
304 """ |
|
305 Private method to check for calls where try/except and finally have |
|
306 'return'. |
|
307 |
|
308 @param node reference to the AST node to be checked |
|
309 @type ast.Try |
|
310 """ |
|
311 # def foo(): |
|
312 # try: |
|
313 # 1 / 0 |
|
314 # return "1" |
|
315 # except: |
|
316 # return "2" |
|
317 # finally: |
|
318 # return "3" |
|
319 tryHasReturn = False |
|
320 for stmt in node.body: |
|
321 if isinstance(stmt, ast.Return): |
|
322 tryHasReturn = True |
|
323 break |
|
324 |
|
325 exceptHasReturn = False |
|
326 for stmt2 in node.handlers: |
|
327 if isinstance(stmt2, ast.Return): |
|
328 exceptHasReturn = True |
|
329 break |
|
330 |
|
331 finallyHasReturn = False |
|
332 finallyReturn = None |
|
333 for stmt in node.finalbody: |
|
334 if isinstance(stmt, ast.Return): |
|
335 finallyHasReturn = True |
|
336 finallyReturn = stmt |
|
337 break |
|
338 |
|
339 if (tryHasReturn or exceptHasReturn) and finallyHasReturn: |
|
340 if finallyReturn is not None: |
|
341 self.__error(finallyReturn.lineno - 1, |
|
342 finallyReturn.col_offset, "Y107") |
|
343 |
|
344 def __check108(self, node): |
|
345 """ |
|
346 Private method to check for if-elses which could be a ternary |
|
347 operator assignment. |
|
348 |
|
349 @param node reference to the AST node to be checked |
|
350 @type ast.If |
|
351 """ |
|
352 # if a: |
|
353 # b = c |
|
354 # else: |
|
355 # b = d |
|
356 if ( |
|
357 len(node.body) == 1 and |
|
358 len(node.orelse) == 1 and |
|
359 isinstance(node.body[0], ast.Assign) and |
|
360 isinstance(node.orelse[0], ast.Assign) and |
|
361 len(node.body[0].targets) == 1 and |
|
362 len(node.orelse[0].targets) == 1 and |
|
363 isinstance(node.body[0].targets[0], ast.Name) and |
|
364 isinstance(node.orelse[0].targets[0], ast.Name) and |
|
365 node.body[0].targets[0].id == node.orelse[0].targets[0].id |
|
366 ): |
|
367 assign = unparse(node.body[0].targets[0]) |
|
368 body = unparse(node.body[0].value) |
|
369 cond = unparse(node.test) |
|
370 orelse = unparse(node.orelse[0].value) |
|
371 self.__error(node.lineno - 1, node.col_offset, "Y108", |
|
372 assign, body, cond, orelse) |
|
373 |
|
374 def __check109(self, node): |
|
375 """ |
|
376 Private method to check for multiple equalities with the same value |
|
377 are combined via "or". |
|
378 |
|
379 @param node reference to the AST node to be checked |
|
380 @type ast.BoolOp |
|
381 """ |
|
382 # if a == b or a == c: |
|
383 # d |
|
384 if isinstance(node.op, ast.Or): |
|
385 equalities = [ |
|
386 value |
|
387 for value in node.values |
|
388 if isinstance(value, ast.Compare) and |
|
389 len(value.ops) == 1 and |
|
390 isinstance(value.ops[0], ast.Eq) |
|
391 ] |
|
392 ids = [] # (name, compared_to) |
|
393 for eq in equalities: |
|
394 if isinstance(eq.left, ast.Name): |
|
395 ids.append((eq.left, eq.comparators[0])) |
|
396 if ( |
|
397 len(eq.comparators) == 1 and |
|
398 isinstance(eq.comparators[0], ast.Name) |
|
399 ): |
|
400 ids.append((eq.comparators[0], eq.left)) |
|
401 |
|
402 id2count = {} |
|
403 for identifier, comparedTo in ids: |
|
404 if identifier.id not in id2count: |
|
405 id2count[identifier.id] = [] |
|
406 id2count[identifier.id].append(comparedTo) |
|
407 for value, values in id2count.items(): |
|
408 if len(values) == 1: |
|
409 continue |
|
410 |
|
411 self.__error(node.lineno - 1, node.col_offset, "Y109", |
|
412 value, unparse(ast.List(elts=values)), |
|
413 unparse(node)) |
|
414 |
|
415 def __check110_111(self, node): |
|
416 """ |
|
417 Private method to check if any / all could be used. |
|
418 |
|
419 @param node reference to the AST node to be checked |
|
420 @type ast.For |
|
421 """ |
|
422 # for x in iterable: |
|
423 # if check(x): |
|
424 # return True |
|
425 # return False |
|
426 # |
|
427 # for x in iterable: |
|
428 # if check(x): |
|
429 # return False |
|
430 # return True |
|
431 if ( |
|
432 len(node.body) == 1 |
|
433 and isinstance(node.body[0], ast.If) |
|
434 and len(node.body[0].body) == 1 |
|
435 and isinstance(node.body[0].body[0], ast.Return) |
|
436 and isinstance(node.body[0].body[0].value, BOOL_CONST_TYPES) |
|
437 and hasattr(node.body[0].body[0].value, "value") |
|
438 ): |
|
439 check = unparse(node.body[0].test) |
|
440 target = unparse(node.target) |
|
441 iterable = unparse(node.iter) |
|
442 if node.body[0].body[0].value.value is True: |
|
443 self.__error(node.lineno - 1, node.col_offset, "Y110", |
|
444 check, target, iterable) |
|
445 elif node.body[0].body[0].value.value is False: |
|
446 check = "not " + check |
|
447 if check.startswith("not not"): |
|
448 check = check[len("not not "):] |
|
449 self.__error(node.lineno - 1, node.col_offset, "Y111", |
|
450 check, target, iterable) |
|
451 |
|
452 def __check112(self, node): |
|
453 """ |
|
454 Public method to check for non-capitalized calls to environment |
|
455 variables. |
|
456 |
|
457 @param node reference to the AST node to be checked |
|
458 @type ast.Expr |
|
459 """ |
|
460 # os.environ["foo"] |
|
461 # os.environ.get("bar") |
|
462 isIndexCall = ( |
|
463 isinstance(node.value, ast.Subscript) |
|
464 and isinstance(node.value.value, ast.Attribute) |
|
465 and isinstance(node.value.value.value, ast.Name) |
|
466 and node.value.value.value.id == "os" |
|
467 and node.value.value.attr == "environ" |
|
468 and ( |
|
469 ( |
|
470 isinstance(node.value.slice, ast.Index) |
|
471 and isinstance(node.value.slice.value, STR_TYPES) |
|
472 ) |
|
473 or isinstance(node.value.slice, ast.Constant) |
|
474 ) |
|
475 ) |
|
476 if isIndexCall: |
|
477 subscript = node.value |
|
478 slice_ = subscript.slice |
|
479 if isinstance(slice_, ast.Index): |
|
480 # Python < 3.9 |
|
481 stringPart = slice_.value # type: ignore |
|
482 if isinstance(stringPart, ast.Str): |
|
483 envName = stringPart.s # Python 3.6 / 3.7 fallback |
|
484 else: |
|
485 envName = stringPart.value |
|
486 elif isinstance(slice_, ast.Constant): |
|
487 # Python 3.9 |
|
488 envName = slice_.value |
|
489 |
|
490 # Check if this has a change |
|
491 hasChange = envName != envName.upper() |
|
492 |
|
493 isGetCall = ( |
|
494 isinstance(node.value, ast.Call) |
|
495 and isinstance(node.value.func, ast.Attribute) |
|
496 and isinstance(node.value.func.value, ast.Attribute) |
|
497 and isinstance(node.value.func.value.value, ast.Name) |
|
498 and node.value.func.value.value.id == "os" |
|
499 and node.value.func.value.attr == "environ" |
|
500 and node.value.func.attr == "get" |
|
501 and len(node.value.args) in [1, 2] |
|
502 and isinstance(node.value.args[0], STR_TYPES) |
|
503 ) |
|
504 if isGetCall: |
|
505 call = node.value |
|
506 stringPart = call.args[0] |
|
507 if isinstance(stringPart, ast.Str): |
|
508 envName = stringPart.s # Python 3.6 / 3.7 fallback |
|
509 else: |
|
510 envName = stringPart.value |
|
511 # Check if this has a change |
|
512 hasChange = envName != envName.upper() |
|
513 if not (isIndexCall or isGetCall) or not hasChange: |
|
514 return |
|
515 if isIndexCall: |
|
516 original = unparse(node) |
|
517 expected = f"os.environ['{envName.upper()}']" |
|
518 elif isGetCall: |
|
519 original = unparse(node) |
|
520 if len(node.value.args) == 1: |
|
521 expected = f"os.environ.get('{envName.upper()}')" |
|
522 else: |
|
523 defaultValue = unparse(node.value.args[1]) |
|
524 expected = ( |
|
525 f"os.environ.get('{envName.upper()}', '{defaultValue}')" |
|
526 ) |
|
527 else: |
|
528 return |
|
529 |
|
530 self.__error(node.lineno - 1, node.col_offset, "Y112", expected, |
|
531 original) |
|
532 |
|
533 # |
|
534 # eflag: noqa = M891 |