|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2011 - 2022 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing the code style checker. |
|
8 """ |
|
9 |
|
10 import queue |
|
11 import ast |
|
12 import sys |
|
13 import multiprocessing |
|
14 import contextlib |
|
15 |
|
16 import pycodestyle |
|
17 |
|
18 from Complexity.ComplexityChecker import ComplexityChecker |
|
19 from DocStyle.DocStyleChecker import DocStyleChecker |
|
20 from Imports.ImportsChecker import ImportsChecker |
|
21 from Miscellaneous.MiscellaneousChecker import MiscellaneousChecker |
|
22 from Naming.NamingStyleChecker import NamingStyleChecker |
|
23 from PathLib.PathlibChecker import PathlibChecker |
|
24 from Security.SecurityChecker import SecurityChecker |
|
25 from Simplify.SimplifyChecker import SimplifyChecker |
|
26 |
|
27 # register the name checker |
|
28 pycodestyle.register_check(NamingStyleChecker, NamingStyleChecker.Codes) |
|
29 |
|
30 |
|
31 def initService(): |
|
32 """ |
|
33 Initialize the service and return the entry point. |
|
34 |
|
35 @return the entry point for the background client (function) |
|
36 """ |
|
37 return codeStyleCheck |
|
38 |
|
39 |
|
40 def initBatchService(): |
|
41 """ |
|
42 Initialize the batch service and return the entry point. |
|
43 |
|
44 @return the entry point for the background client (function) |
|
45 """ |
|
46 return codeStyleBatchCheck |
|
47 |
|
48 |
|
49 class CodeStyleCheckerReport(pycodestyle.BaseReport): |
|
50 """ |
|
51 Class implementing a special report to be used with our dialog. |
|
52 """ |
|
53 def __init__(self, options): |
|
54 """ |
|
55 Constructor |
|
56 |
|
57 @param options options for the report (optparse.Values) |
|
58 """ |
|
59 super().__init__(options) |
|
60 |
|
61 self.__repeat = options.repeat |
|
62 self.errors = [] |
|
63 |
|
64 def error_args(self, line_number, offset, code, check, *args): |
|
65 """ |
|
66 Public method to collect the error messages. |
|
67 |
|
68 @param line_number line number of the issue (integer) |
|
69 @param offset position within line of the issue (integer) |
|
70 @param code message code (string) |
|
71 @param check reference to the checker function (function) |
|
72 @param args arguments for the message (list) |
|
73 @return error code (string) |
|
74 """ |
|
75 code = super().error_args( |
|
76 line_number, offset, code, check, *args) |
|
77 if code and (self.counters[code] == 1 or self.__repeat): |
|
78 self.errors.append( |
|
79 { |
|
80 "file": self.filename, |
|
81 "line": line_number, |
|
82 "offset": offset, |
|
83 "code": code, |
|
84 "args": args, |
|
85 } |
|
86 ) |
|
87 return code |
|
88 |
|
89 |
|
90 def extractLineFlags(line, startComment="#", endComment="", flagsLine=False): |
|
91 """ |
|
92 Function to extract flags starting and ending with '__' from a line |
|
93 comment. |
|
94 |
|
95 @param line line to extract flags from (string) |
|
96 @param startComment string identifying the start of the comment (string) |
|
97 @param endComment string identifying the end of a comment (string) |
|
98 @param flagsLine flag indicating to check for a flags only line (bool) |
|
99 @return list containing the extracted flags (list of strings) |
|
100 """ |
|
101 flags = [] |
|
102 |
|
103 if not flagsLine or ( |
|
104 flagsLine and line.strip().startswith(startComment)): |
|
105 pos = line.rfind(startComment) |
|
106 if pos >= 0: |
|
107 comment = line[pos + len(startComment):].strip() |
|
108 if endComment: |
|
109 endPos = line.rfind(endComment) |
|
110 if endPos >= 0: |
|
111 comment = comment[:endPos] |
|
112 flags = [f.strip() for f in comment.split() |
|
113 if (f.startswith("__") and f.endswith("__"))] |
|
114 flags += [f.strip().lower() for f in comment.split() |
|
115 if f in ("noqa", "NOQA", |
|
116 "nosec", "NOSEC", |
|
117 "secok", "SECOK")] |
|
118 return flags |
|
119 |
|
120 |
|
121 def ignoreCode(code, lineFlags): |
|
122 """ |
|
123 Function to check, if the given code should be ignored as per line flags. |
|
124 |
|
125 @param code error code to be checked |
|
126 @type str |
|
127 @param lineFlags list of line flags to check against |
|
128 @type list of str |
|
129 @return flag indicating to ignore the code |
|
130 @rtype bool |
|
131 """ |
|
132 if lineFlags: |
|
133 |
|
134 if ( |
|
135 "__IGNORE_WARNING__" in lineFlags or |
|
136 "noqa" in lineFlags or |
|
137 "nosec" in lineFlags |
|
138 ): |
|
139 # ignore all warning codes |
|
140 return True |
|
141 |
|
142 for flag in lineFlags: |
|
143 # check individual warning code |
|
144 if flag.startswith("__IGNORE_WARNING_"): |
|
145 ignoredCode = flag[2:-2].rsplit("_", 1)[-1] |
|
146 if code.startswith(ignoredCode): |
|
147 return True |
|
148 |
|
149 return False |
|
150 |
|
151 |
|
152 def securityOk(code, lineFlags): |
|
153 """ |
|
154 Function to check, if the given code is an acknowledged security report. |
|
155 |
|
156 @param code error code to be checked |
|
157 @type str |
|
158 @param lineFlags list of line flags to check against |
|
159 @type list of str |
|
160 @return flag indicating an acknowledged security report |
|
161 @rtype bool |
|
162 """ |
|
163 if lineFlags: |
|
164 return "secok" in lineFlags |
|
165 |
|
166 return False |
|
167 |
|
168 |
|
169 def codeStyleCheck(filename, source, args): |
|
170 """ |
|
171 Do the code style check and/or fix found errors. |
|
172 |
|
173 @param filename source filename |
|
174 @type str |
|
175 @param source string containing the code to check |
|
176 @type str |
|
177 @param args arguments used by the codeStyleCheck function (list of |
|
178 excludeMessages, includeMessages, repeatMessages, fixCodes, |
|
179 noFixCodes, fixIssues, maxLineLength, maxDocLineLength, blankLines, |
|
180 hangClosing, docType, codeComplexityArgs, miscellaneousArgs, |
|
181 annotationArgs, securityArgs, importsArgs, errors, eol, encoding, |
|
182 backup) |
|
183 @type list of (str, str, bool, str, str, bool, int, list of (int, int), |
|
184 bool, str, dict, dict, dict, dict, list of str, str, str, bool) |
|
185 @return tuple of statistics (dict) and list of results (tuple for each |
|
186 found violation of style (lineno, position, text, ignored, fixed, |
|
187 autofixing, fixedMsg)) |
|
188 @rtype tuple of (dict, list of tuples of (int, int, str, bool, bool, bool, |
|
189 str)) |
|
190 """ |
|
191 return __checkCodeStyle(filename, source, args) |
|
192 |
|
193 |
|
194 def codeStyleBatchCheck(argumentsList, send, fx, cancelled, maxProcesses=0): |
|
195 """ |
|
196 Module function to check code style for a batch of files. |
|
197 |
|
198 @param argumentsList list of arguments tuples as given for codeStyleCheck |
|
199 @type list |
|
200 @param send reference to send function |
|
201 @type func |
|
202 @param fx registered service name |
|
203 @type str |
|
204 @param cancelled reference to function checking for a cancellation |
|
205 @type func |
|
206 @param maxProcesses number of processes to be used |
|
207 @type int |
|
208 """ |
|
209 if maxProcesses == 0: |
|
210 # determine based on CPU count |
|
211 try: |
|
212 NumberOfProcesses = multiprocessing.cpu_count() |
|
213 if NumberOfProcesses >= 1: |
|
214 NumberOfProcesses -= 1 |
|
215 except NotImplementedError: |
|
216 NumberOfProcesses = 1 |
|
217 else: |
|
218 NumberOfProcesses = maxProcesses |
|
219 |
|
220 # Create queues |
|
221 taskQueue = multiprocessing.Queue() |
|
222 doneQueue = multiprocessing.Queue() |
|
223 |
|
224 # Submit tasks (initially two time number of processes |
|
225 initialTasks = 2 * NumberOfProcesses |
|
226 for task in argumentsList[:initialTasks]: |
|
227 taskQueue.put(task) |
|
228 |
|
229 # Start worker processes |
|
230 workers = [ |
|
231 multiprocessing.Process( |
|
232 target=workerTask, args=(taskQueue, doneQueue) |
|
233 ) for _ in range(NumberOfProcesses) |
|
234 ] |
|
235 for worker in workers: |
|
236 worker.start() |
|
237 |
|
238 # Get and send results |
|
239 endIndex = len(argumentsList) - initialTasks |
|
240 for i in range(len(argumentsList)): |
|
241 resultSent = False |
|
242 wasCancelled = False |
|
243 |
|
244 while not resultSent: |
|
245 try: |
|
246 # get result (waiting max. 3 seconds and send it to frontend |
|
247 filename, result = doneQueue.get(timeout=3) |
|
248 send(fx, filename, result) |
|
249 resultSent = True |
|
250 except queue.Empty: |
|
251 # ignore empty queue, just carry on |
|
252 if cancelled(): |
|
253 wasCancelled = True |
|
254 break |
|
255 |
|
256 if wasCancelled or cancelled(): |
|
257 # just exit the loop ignoring the results of queued tasks |
|
258 break |
|
259 |
|
260 if i < endIndex: |
|
261 taskQueue.put(argumentsList[i + initialTasks]) |
|
262 |
|
263 # Tell child processes to stop |
|
264 for _ in range(NumberOfProcesses): |
|
265 taskQueue.put('STOP') |
|
266 |
|
267 for worker in workers: |
|
268 worker.join() |
|
269 worker.close() |
|
270 |
|
271 |
|
272 def workerTask(inputQueue, outputQueue): |
|
273 """ |
|
274 Module function acting as the parallel worker for the style check. |
|
275 |
|
276 @param inputQueue input queue (multiprocessing.Queue) |
|
277 @param outputQueue output queue (multiprocessing.Queue) |
|
278 """ |
|
279 for filename, source, args in iter(inputQueue.get, 'STOP'): |
|
280 result = __checkCodeStyle(filename, source, args) |
|
281 outputQueue.put((filename, result)) |
|
282 |
|
283 |
|
284 def __checkSyntax(filename, source): |
|
285 """ |
|
286 Private module function to perform a syntax check. |
|
287 |
|
288 @param filename source filename |
|
289 @type str |
|
290 @param source string containing the code to check |
|
291 @type str |
|
292 @return tuple containing the error dictionary with syntax error details, |
|
293 a statistics dictionary and None or a tuple containing two None and |
|
294 the generated AST tree |
|
295 @rtype tuple of (dict, dict, None) or tuple of (None, None, ast.Module) |
|
296 """ |
|
297 src = "".join(source) |
|
298 |
|
299 try: |
|
300 tree = ( |
|
301 ast.parse(src, filename, 'exec', type_comments=True) |
|
302 # need the 'type_comments' parameter to include type annotations |
|
303 if sys.version_info >= (3, 8) else |
|
304 ast.parse(src, filename, 'exec') |
|
305 ) |
|
306 return None, None, tree |
|
307 except (SyntaxError, TypeError): |
|
308 exc_type, exc = sys.exc_info()[:2] |
|
309 if len(exc.args) > 1: |
|
310 offset = exc.args[1] |
|
311 if len(offset) > 2: |
|
312 offset = offset[1:3] |
|
313 else: |
|
314 offset = (1, 0) |
|
315 return ( |
|
316 { |
|
317 "file": filename, |
|
318 "line": offset[0], |
|
319 "offset": offset[1], |
|
320 "code": "E901", |
|
321 "args": [exc_type.__name__, exc.args[0]], |
|
322 }, { |
|
323 "E901": 1, |
|
324 }, |
|
325 None |
|
326 ) |
|
327 |
|
328 |
|
329 def __checkCodeStyle(filename, source, args): |
|
330 """ |
|
331 Private module function to perform the code style check and/or fix |
|
332 found errors. |
|
333 |
|
334 @param filename source filename |
|
335 @type str |
|
336 @param source string containing the code to check |
|
337 @type str |
|
338 @param args arguments used by the codeStyleCheck function (list of |
|
339 excludeMessages, includeMessages, repeatMessages, fixCodes, |
|
340 noFixCodes, fixIssues, maxLineLength, maxDocLineLength, blankLines, |
|
341 hangClosing, docType, codeComplexityArgs, miscellaneousArgs, |
|
342 annotationArgs, securityArgs, importsArgs, errors, eol, encoding, |
|
343 backup) |
|
344 @type list of (str, str, bool, str, str, bool, int, list of (int, int), |
|
345 bool, str, dict, dict, dict, dict, list of str, str, str, bool) |
|
346 @return tuple of statistics data and list of result dictionaries with |
|
347 keys: |
|
348 <ul> |
|
349 <li>file: file name</li> |
|
350 <li>line: line_number</li> |
|
351 <li>offset: offset within line</li> |
|
352 <li>code: message code</li> |
|
353 <li>args: list of arguments to format the message</li> |
|
354 <li>ignored: flag indicating this issue was ignored</li> |
|
355 <li>fixed: flag indicating this issue was fixed</li> |
|
356 <li>autofixing: flag indicating that a fix can be done</li> |
|
357 <li>fixcode: message code for the fix</li> |
|
358 <li>fixargs: list of arguments to format the fix message</li> |
|
359 </ul> |
|
360 @rtype tuple of (dict, list of dict) |
|
361 """ |
|
362 (excludeMessages, includeMessages, repeatMessages, fixCodes, noFixCodes, |
|
363 fixIssues, maxLineLength, maxDocLineLength, blankLines, hangClosing, |
|
364 docType, codeComplexityArgs, miscellaneousArgs, annotationArgs, |
|
365 securityArgs, importsArgs, errors, eol, encoding, backup) = args |
|
366 |
|
367 stats = {} |
|
368 |
|
369 if fixIssues: |
|
370 from CodeStyleFixer import CodeStyleFixer |
|
371 fixer = CodeStyleFixer( |
|
372 filename, source, fixCodes, noFixCodes, |
|
373 maxLineLength, blankLines, True, eol, backup) |
|
374 # always fix in place |
|
375 else: |
|
376 fixer = None |
|
377 |
|
378 if not errors: |
|
379 if includeMessages: |
|
380 select = [s.strip() for s in |
|
381 includeMessages.split(',') if s.strip()] |
|
382 else: |
|
383 select = [] |
|
384 if excludeMessages: |
|
385 ignore = [i.strip() for i in |
|
386 excludeMessages.split(',') if i.strip()] |
|
387 else: |
|
388 ignore = [] |
|
389 |
|
390 syntaxError, syntaxStats, tree = __checkSyntax(filename, source) |
|
391 |
|
392 # perform the checks only, if syntax is ok and AST tree was generated |
|
393 if tree: |
|
394 # check coding style |
|
395 pycodestyle.BLANK_LINES_CONFIG = { |
|
396 # Top level class and function. |
|
397 'top_level': blankLines[0], |
|
398 # Methods and nested class and function. |
|
399 'method': blankLines[1], |
|
400 } |
|
401 styleGuide = pycodestyle.StyleGuide( |
|
402 reporter=CodeStyleCheckerReport, |
|
403 repeat=repeatMessages, |
|
404 select=select, |
|
405 ignore=ignore, |
|
406 max_line_length=maxLineLength, |
|
407 max_doc_length=maxDocLineLength, |
|
408 hang_closing=hangClosing, |
|
409 ) |
|
410 report = styleGuide.check_files([filename]) |
|
411 stats.update(report.counters) |
|
412 errors = report.errors |
|
413 |
|
414 # check documentation style |
|
415 docStyleChecker = DocStyleChecker( |
|
416 source, filename, select, ignore, [], repeatMessages, |
|
417 maxLineLength=maxDocLineLength, docType=docType) |
|
418 docStyleChecker.run() |
|
419 stats.update(docStyleChecker.counters) |
|
420 errors += docStyleChecker.errors |
|
421 |
|
422 # miscellaneous additional checks |
|
423 miscellaneousChecker = MiscellaneousChecker( |
|
424 source, filename, tree, select, ignore, [], repeatMessages, |
|
425 miscellaneousArgs) |
|
426 miscellaneousChecker.run() |
|
427 stats.update(miscellaneousChecker.counters) |
|
428 errors += miscellaneousChecker.errors |
|
429 |
|
430 # check code complexity |
|
431 complexityChecker = ComplexityChecker( |
|
432 source, filename, tree, select, ignore, codeComplexityArgs) |
|
433 complexityChecker.run() |
|
434 stats.update(complexityChecker.counters) |
|
435 errors += complexityChecker.errors |
|
436 |
|
437 # check function annotations |
|
438 if sys.version_info >= (3, 8, 0): |
|
439 # annotations with type comments are supported from |
|
440 # Python 3.8 on |
|
441 from Annotations.AnnotationsChecker import AnnotationsChecker |
|
442 annotationsChecker = AnnotationsChecker( |
|
443 source, filename, tree, select, ignore, [], repeatMessages, |
|
444 annotationArgs) |
|
445 annotationsChecker.run() |
|
446 stats.update(annotationsChecker.counters) |
|
447 errors += annotationsChecker.errors |
|
448 |
|
449 # check for security issues |
|
450 securityChecker = SecurityChecker( |
|
451 source, filename, tree, select, ignore, [], repeatMessages, |
|
452 securityArgs) |
|
453 securityChecker.run() |
|
454 stats.update(securityChecker.counters) |
|
455 errors += securityChecker.errors |
|
456 |
|
457 # check for pathlib usage |
|
458 pathlibChecker = PathlibChecker( |
|
459 source, filename, tree, select, ignore, [], repeatMessages) |
|
460 pathlibChecker.run() |
|
461 stats.update(pathlibChecker.counters) |
|
462 errors += pathlibChecker.errors |
|
463 |
|
464 # check for code simplifications |
|
465 simplifyChecker = SimplifyChecker( |
|
466 source, filename, tree, select, ignore, [], repeatMessages) |
|
467 simplifyChecker.run() |
|
468 stats.update(simplifyChecker.counters) |
|
469 errors += simplifyChecker.errors |
|
470 |
|
471 # check import statements |
|
472 importsChecker = ImportsChecker( |
|
473 source, filename, tree, select, ignore, [], repeatMessages, |
|
474 importsArgs) |
|
475 importsChecker.run() |
|
476 stats.update(importsChecker.counters) |
|
477 errors += importsChecker.errors |
|
478 |
|
479 elif syntaxError: |
|
480 errors = [syntaxError] |
|
481 stats.update(syntaxStats) |
|
482 |
|
483 errorsDict = {} |
|
484 for error in errors: |
|
485 if error["line"] > len(source): |
|
486 error["line"] = len(source) |
|
487 # inverse processing of messages and fixes |
|
488 errorLine = errorsDict.setdefault(error["line"], []) |
|
489 errorLine.append((error["offset"], error)) |
|
490 deferredFixes = {} |
|
491 results = [] |
|
492 for lineno, errorsList in errorsDict.items(): |
|
493 errorsList.sort(key=lambda x: x[0], reverse=True) |
|
494 for _, error in errorsList: |
|
495 error.update({ |
|
496 "ignored": False, |
|
497 "fixed": False, |
|
498 "autofixing": False, |
|
499 "fixcode": "", |
|
500 "fixargs": [], |
|
501 "securityOk": False, |
|
502 }) |
|
503 |
|
504 if source: |
|
505 code = error["code"] |
|
506 lineFlags = extractLineFlags(source[lineno - 1].strip()) |
|
507 with contextlib.suppress(IndexError): |
|
508 lineFlags += extractLineFlags(source[lineno].strip(), |
|
509 flagsLine=True) |
|
510 |
|
511 if securityOk(code, lineFlags): |
|
512 error["securityOk"] = True |
|
513 |
|
514 if ignoreCode(code, lineFlags): |
|
515 error["ignored"] = True |
|
516 else: |
|
517 if fixer: |
|
518 res, fixcode, fixargs, id_ = fixer.fixIssue( |
|
519 lineno, error["offset"], code) |
|
520 if res == -1: |
|
521 deferredFixes[id_] = error |
|
522 else: |
|
523 error.update({ |
|
524 "fixed": res == 1, |
|
525 "autofixing": True, |
|
526 "fixcode": fixcode, |
|
527 "fixargs": fixargs, |
|
528 }) |
|
529 |
|
530 results.append(error) |
|
531 |
|
532 if fixer: |
|
533 deferredResults = fixer.finalize() |
|
534 for id_ in deferredResults: |
|
535 fixed, fixcode, fixargs = deferredResults[id_] |
|
536 error = deferredFixes[id_] |
|
537 error.update({ |
|
538 "ignored": False, |
|
539 "fixed": fixed == 1, |
|
540 "autofixing": True, |
|
541 "fixcode": fixcode, |
|
542 "fixargs": fixargs, |
|
543 }) |
|
544 |
|
545 saveError = fixer.saveFile(encoding) |
|
546 if saveError: |
|
547 for error in results: |
|
548 error.update({ |
|
549 "fixcode": saveError[0], |
|
550 "fixargs": saveError[1], |
|
551 }) |
|
552 |
|
553 return stats, results |