|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2021 - 2023 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing a checker for import statements. |
|
8 """ |
|
9 |
|
10 import ast |
|
11 import copy |
|
12 import re |
|
13 |
|
14 |
|
15 class NameOrderChecker: |
|
16 """ |
|
17 Class implementing a checker for name ordering. |
|
18 |
|
19 Note: Name ordering is checked for import statements, the '__all__' statement |
|
20 and exception names of exception handlers. |
|
21 """ |
|
22 |
|
23 Codes = [ |
|
24 ## Imports order |
|
25 "NO101", |
|
26 "NO102", |
|
27 "NO103", |
|
28 "NO104", |
|
29 "NO105", |
|
30 ] |
|
31 |
|
32 def __init__(self, source, filename, tree, select, ignore, expected, repeat, args): |
|
33 """ |
|
34 Constructor |
|
35 |
|
36 @param source source code to be checked |
|
37 @type list of str |
|
38 @param filename name of the source file |
|
39 @type str |
|
40 @param tree AST tree of the source code |
|
41 @type ast.Module |
|
42 @param select list of selected codes |
|
43 @type list of str |
|
44 @param ignore list of codes to be ignored |
|
45 @type list of str |
|
46 @param expected list of expected codes |
|
47 @type list of str |
|
48 @param repeat flag indicating to report each occurrence of a code |
|
49 @type bool |
|
50 @param args dictionary of arguments for the various checks |
|
51 @type dict |
|
52 """ |
|
53 self.__select = tuple(select) |
|
54 self.__ignore = ("",) if select else tuple(ignore) |
|
55 self.__expected = expected[:] |
|
56 self.__repeat = repeat |
|
57 self.__filename = filename |
|
58 self.__source = source[:] |
|
59 self.__tree = copy.deepcopy(tree) |
|
60 self.__args = args |
|
61 |
|
62 # parameters for import sorting |
|
63 if args["SortOrder"] == "native": |
|
64 self.__sortingFunction = sorted |
|
65 else: |
|
66 # naturally is the default sort order |
|
67 self.__sortingFunction = self.__naturally |
|
68 self.__sortCaseSensitive = args["SortCaseSensitive"] |
|
69 |
|
70 # statistics counters |
|
71 self.counters = {} |
|
72 |
|
73 # collection of detected errors |
|
74 self.errors = [] |
|
75 |
|
76 checkersWithCodes = [ |
|
77 (self.__checkNameOrder, ("NO101", "NO102", "NO103", "NO104", "NO105")), |
|
78 ] |
|
79 |
|
80 self.__checkers = [] |
|
81 for checker, codes in checkersWithCodes: |
|
82 if any(not (code and self.__ignoreCode(code)) for code in codes): |
|
83 self.__checkers.append(checker) |
|
84 |
|
85 def __ignoreCode(self, code): |
|
86 """ |
|
87 Private method to check if the message code should be ignored. |
|
88 |
|
89 @param code message code to check for |
|
90 @type str |
|
91 @return flag indicating to ignore the given code |
|
92 @rtype bool |
|
93 """ |
|
94 return code.startswith(self.__ignore) and not code.startswith(self.__select) |
|
95 |
|
96 def __error(self, lineNumber, offset, code, *args): |
|
97 """ |
|
98 Private method to record an issue. |
|
99 |
|
100 @param lineNumber line number of the issue |
|
101 @type int |
|
102 @param offset position within line of the issue |
|
103 @type int |
|
104 @param code message code |
|
105 @type str |
|
106 @param args arguments for the message |
|
107 @type list |
|
108 """ |
|
109 if self.__ignoreCode(code): |
|
110 return |
|
111 |
|
112 if code in self.counters: |
|
113 self.counters[code] += 1 |
|
114 else: |
|
115 self.counters[code] = 1 |
|
116 |
|
117 # Don't care about expected codes |
|
118 if code in self.__expected: |
|
119 return |
|
120 |
|
121 if code and (self.counters[code] == 1 or self.__repeat): |
|
122 # record the issue with one based line number |
|
123 self.errors.append( |
|
124 { |
|
125 "file": self.__filename, |
|
126 "line": lineNumber + 1, |
|
127 "offset": offset, |
|
128 "code": code, |
|
129 "args": args, |
|
130 } |
|
131 ) |
|
132 |
|
133 def run(self): |
|
134 """ |
|
135 Public method to check the given source against miscellaneous |
|
136 conditions. |
|
137 """ |
|
138 if not self.__filename: |
|
139 # don't do anything, if essential data is missing |
|
140 return |
|
141 |
|
142 if not self.__checkers: |
|
143 # don't do anything, if no codes were selected |
|
144 return |
|
145 |
|
146 for check in self.__checkers: |
|
147 check() |
|
148 |
|
149 ####################################################################### |
|
150 ## Name Order |
|
151 ## |
|
152 ## adapted from: flake8-alphabetize v0.0.21 |
|
153 ####################################################################### |
|
154 |
|
155 def __checkNameOrder(self): |
|
156 """ |
|
157 Private method to check the order of import statements. |
|
158 """ |
|
159 from .ImportNode import ImportNode |
|
160 |
|
161 errors = [] |
|
162 imports = [] |
|
163 importNodes, aListNode, eListNodes = self.__findNodes(self.__tree) |
|
164 |
|
165 # check for an error in '__all__' |
|
166 allError = self.__findErrorInAll(aListNode) |
|
167 if allError is not None: |
|
168 errors.append(allError) |
|
169 |
|
170 errors.extend(self.__findExceptionListErrors(eListNodes)) |
|
171 |
|
172 for importNode in importNodes: |
|
173 if isinstance(importNode, ast.Import) and len(importNode.names) > 1: |
|
174 # skip suck imports because its already handled by pycodestyle |
|
175 continue |
|
176 |
|
177 imports.append( |
|
178 ImportNode( |
|
179 self.__args.get("ApplicationPackageNames", []), |
|
180 importNode, |
|
181 self, |
|
182 self.__args.get("SortIgnoringStyle", False), |
|
183 self.__args.get("SortFromFirst", False), |
|
184 ) |
|
185 ) |
|
186 |
|
187 lenImports = len(imports) |
|
188 if lenImports > 0: |
|
189 p = imports[0] |
|
190 if p.error is not None: |
|
191 errors.append(p.error) |
|
192 |
|
193 if lenImports > 1: |
|
194 for n in imports[1:]: |
|
195 if n.error is not None: |
|
196 errors.append(n.error) |
|
197 |
|
198 if n == p: |
|
199 if self.__args.get("CombinedAsImports", False) or ( |
|
200 not n.asImport and not p.asImport |
|
201 ): |
|
202 errors.append((n.node, "NO103", str(p), str(n))) |
|
203 elif n < p: |
|
204 errors.append((n.node, "NO101", str(n), str(p))) |
|
205 |
|
206 p = n |
|
207 |
|
208 for error in errors: |
|
209 if not self.__ignoreCode(error[1]): |
|
210 node = error[0] |
|
211 reason = error[1] |
|
212 args = error[2:] |
|
213 self.__error(node.lineno - 1, node.col_offset, reason, *args) |
|
214 |
|
215 def __findExceptionListNodes(self, tree): |
|
216 """ |
|
217 Private method to find all exception types handled by given tree. |
|
218 |
|
219 @param tree reference to the ast node tree to be parsed |
|
220 @type ast.AST |
|
221 @return list of exception types |
|
222 @rtype list of ast.Name |
|
223 """ |
|
224 nodes = [] |
|
225 |
|
226 for node in ast.walk(tree): |
|
227 if isinstance(node, ast.ExceptHandler): |
|
228 nodeType = node.type |
|
229 if isinstance(nodeType, (ast.List, ast.Tuple)): |
|
230 nodes.append(nodeType) |
|
231 |
|
232 return nodes |
|
233 |
|
234 def __findNodes(self, tree): |
|
235 """ |
|
236 Private method to find all import and import from nodes of the given |
|
237 tree. |
|
238 |
|
239 @param tree reference to the ast node tree to be parsed |
|
240 @type ast.AST |
|
241 @return tuple containing a list of import nodes, the '__all__' node and |
|
242 exception nodes |
|
243 @rtype tuple of (ast.Import | ast.ImportFrom, ast.List | ast.Tuple, |
|
244 ast.List | ast.Tuple) |
|
245 """ |
|
246 importNodes = [] |
|
247 aListNode = None |
|
248 eListNodes = self.__findExceptionListNodes(tree) |
|
249 |
|
250 if isinstance(tree, ast.Module): |
|
251 body = tree.body |
|
252 |
|
253 for n in body: |
|
254 if isinstance(n, (ast.Import, ast.ImportFrom)): |
|
255 importNodes.append(n) |
|
256 |
|
257 elif isinstance(n, ast.Assign): |
|
258 for t in n.targets: |
|
259 if isinstance(t, ast.Name) and t.id == "__all__": |
|
260 value = n.value |
|
261 |
|
262 if isinstance(value, (ast.List, ast.Tuple)): |
|
263 aListNode = value |
|
264 |
|
265 return importNodes, aListNode, eListNodes |
|
266 |
|
267 def __findErrorInAll(self, node): |
|
268 """ |
|
269 Private method to check the '__all__' node for errors. |
|
270 |
|
271 @param node reference to the '__all__' node |
|
272 @type ast.List or ast.Tuple |
|
273 @return tuple containing a reference to the node an error code and the error |
|
274 arguments |
|
275 @rtype tuple of (ast.List | ast.Tuple, str, str) |
|
276 """ |
|
277 if node is not None: |
|
278 actualList = [] |
|
279 for el in node.elts: |
|
280 if isinstance(el, ast.Constant): |
|
281 actualList.append(el.value) |
|
282 elif isinstance(el, ast.Str): |
|
283 actualList.append(el.s) |
|
284 else: |
|
285 # Can't handle anything that isn't a string literal |
|
286 return None |
|
287 |
|
288 expectedList = self.sorted( |
|
289 actualList, |
|
290 key=lambda k: self.moduleKey(k, subImports=True), |
|
291 ) |
|
292 if expectedList != actualList: |
|
293 return (node, "NO104", ", ".join(expectedList)) |
|
294 |
|
295 return None |
|
296 |
|
297 def __findExceptionListStr(self, node): |
|
298 """ |
|
299 Private method to get the exception name out of an exception handler type node. |
|
300 |
|
301 @param node node to be treated |
|
302 @type ast.Name or ast.Attribute |
|
303 @return string containing the exception name |
|
304 @rtype str |
|
305 """ |
|
306 if isinstance(node, ast.Name): |
|
307 return node.id |
|
308 elif isinstance(node, ast.Attribute): |
|
309 return f"{self.__findExceptionListStr(node.value)}.{node.attr}" |
|
310 |
|
311 return "" |
|
312 |
|
313 def __findExceptionListErrors(self, nodes): |
|
314 """ |
|
315 Private method to check the exception node for errors. |
|
316 |
|
317 @param nodes list of exception nodes |
|
318 @type list of ast.List or ast.Tuple |
|
319 @return DESCRIPTION |
|
320 @rtype TYPE |
|
321 """ |
|
322 errors = [] |
|
323 |
|
324 for node in nodes: |
|
325 actualList = [self.__findExceptionListStr(elt) for elt in node.elts] |
|
326 |
|
327 expectedList = self.sorted(actualList) |
|
328 if expectedList != actualList: |
|
329 errors.append((node, "NO105", ", ".join(expectedList))) |
|
330 |
|
331 return errors |
|
332 |
|
333 def sorted(self, toSort, key=None, reverse=False): |
|
334 """ |
|
335 Public method to sort the given list of names. |
|
336 |
|
337 @param toSort list of names to be sorted |
|
338 @type list of str |
|
339 @param key function to generate keys (defaults to None) |
|
340 @type function (optional) |
|
341 @param reverse flag indicating a reverse sort (defaults to False) |
|
342 @type bool (optional) |
|
343 @return sorted list of names |
|
344 @rtype list of str |
|
345 """ |
|
346 return self.__sortingFunction(toSort, key=key, reverse=reverse) |
|
347 |
|
348 def __naturally(self, toSort, key=None, reverse=False): |
|
349 """ |
|
350 Private method to sort the given list of names naturally. |
|
351 |
|
352 Note: Natural sorting maintains the sort order of numbers (i.e. |
|
353 [Q1, Q10, Q2] is sorted as [Q1, Q2, Q10] while the Python |
|
354 standard sort would yield [Q1, Q10, Q2]. |
|
355 |
|
356 @param toSort list of names to be sorted |
|
357 @type list of str |
|
358 @param key function to generate keys (defaults to None) |
|
359 @type function (optional) |
|
360 @param reverse flag indicating a reverse sort (defaults to False) |
|
361 @type bool (optional) |
|
362 @return sorted list of names |
|
363 @rtype list of str |
|
364 """ |
|
365 if key is None: |
|
366 keyCallback = self.__naturalKeys |
|
367 else: |
|
368 |
|
369 def keyCallback(text): |
|
370 return self.__naturalKeys(key(text)) |
|
371 |
|
372 return sorted(toSort, key=keyCallback, reverse=reverse) |
|
373 |
|
374 def __atoi(self, text): |
|
375 """ |
|
376 Private method to convert the given text to an integer number. |
|
377 |
|
378 @param text text to be converted |
|
379 @type str |
|
380 @return integer number |
|
381 @rtype int |
|
382 """ |
|
383 return int(text) if text.isdigit() else text |
|
384 |
|
385 def __naturalKeys(self, text): |
|
386 """ |
|
387 Private method to generate keys for natural sorting. |
|
388 |
|
389 @param text text to generate a key for |
|
390 @type str |
|
391 @return key for natural sorting |
|
392 @rtype list of str or int |
|
393 """ |
|
394 return [self.__atoi(c) for c in re.split(r"(\d+)", text)] |
|
395 |
|
396 def moduleKey(self, moduleName, subImports=False): |
|
397 """ |
|
398 Public method to generate a key for the given module name. |
|
399 |
|
400 @param moduleName module name |
|
401 @type str |
|
402 @param subImports flag indicating a sub import like in |
|
403 'from foo import bar, baz' (defaults to False) |
|
404 @type bool (optional) |
|
405 @return generated key |
|
406 @rtype str |
|
407 """ |
|
408 prefix = "" |
|
409 |
|
410 if subImports: |
|
411 if moduleName.isupper() and len(moduleName) > 1: |
|
412 prefix = "A" |
|
413 elif moduleName[0:1].isupper(): |
|
414 prefix = "B" |
|
415 else: |
|
416 prefix = "C" |
|
417 if not self.__sortCaseSensitive: |
|
418 moduleName = moduleName.lower() |
|
419 |
|
420 return f"{prefix}{moduleName}" |