|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2022 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing a dialog showing the isort code formatting progress and the results. |
|
8 """ |
|
9 |
|
10 import copy |
|
11 import io |
|
12 import multiprocessing |
|
13 import pathlib |
|
14 |
|
15 from dataclasses import dataclass |
|
16 |
|
17 from isort.settings import Config |
|
18 from isort.api import check_file, sort_file |
|
19 |
|
20 from PyQt6.QtCore import pyqtSlot, Qt, QCoreApplication |
|
21 from PyQt6.QtWidgets import ( |
|
22 QAbstractButton, |
|
23 QDialog, |
|
24 QDialogButtonBox, |
|
25 QHeaderView, |
|
26 QTreeWidgetItem, |
|
27 ) |
|
28 |
|
29 from eric7 import Preferences |
|
30 from eric7.EricWidgets import EricMessageBox |
|
31 |
|
32 from .FormattingDiffWidget import FormattingDiffWidget |
|
33 from .IsortFormattingAction import IsortFormattingAction |
|
34 from .IsortUtilities import suppressStderr |
|
35 |
|
36 from .Ui_IsortFormattingDialog import Ui_IsortFormattingDialog |
|
37 |
|
38 |
|
39 class IsortFormattingDialog(QDialog, Ui_IsortFormattingDialog): |
|
40 """ |
|
41 Class implementing a dialog showing the isort code formatting progress and the |
|
42 results. |
|
43 """ |
|
44 |
|
45 DataTypeRole = Qt.ItemDataRole.UserRole |
|
46 DataRole = Qt.ItemDataRole.UserRole + 1 |
|
47 |
|
48 StatusColumn = 0 |
|
49 FileNameColumn = 1 |
|
50 |
|
51 def __init__( |
|
52 self, |
|
53 configuration, |
|
54 filesList, |
|
55 project=None, |
|
56 action=IsortFormattingAction.Sort, |
|
57 parent=None, |
|
58 ): |
|
59 """ |
|
60 Constructor |
|
61 |
|
62 @param configuration dictionary containing the configuration parameters |
|
63 @type dict |
|
64 @param filesList list of absolute file paths to be processed |
|
65 @type list of str |
|
66 @param project reference to the project object (defaults to None) |
|
67 @type Project (optional) |
|
68 @param action action to be performed (defaults to IsortFormattingAction.Sort) |
|
69 @type IsortFormattingAction (optional) |
|
70 @param parent reference to the parent widget (defaults to None) |
|
71 @type QWidget (optional) |
|
72 """ |
|
73 super().__init__(parent) |
|
74 self.setupUi(self) |
|
75 |
|
76 self.progressBar.setMaximum(len(filesList)) |
|
77 |
|
78 self.resultsList.header().setSortIndicator(1, Qt.SortOrder.AscendingOrder) |
|
79 |
|
80 self.__config = copy.deepcopy(configuration) |
|
81 self.__config["quiet"] = True # we don't want extra output |
|
82 self.__config["overwrite_in_place"] = True # we want to overwrite the files |
|
83 if "config_source" in self.__config: |
|
84 del self.__config["config_source"] |
|
85 self.__isortConfig = Config(**self.__config) |
|
86 self.__config["__action__"] = action # needed by the workers |
|
87 self.__project = project |
|
88 |
|
89 self.__filesList = filesList[:] |
|
90 |
|
91 self.__diffDialog = None |
|
92 |
|
93 self.__allFilter = self.tr("<all>") |
|
94 |
|
95 self.__sortImportsButton = self.buttonBox.addButton( |
|
96 self.tr("Sort Imports"), QDialogButtonBox.ButtonRole.ActionRole |
|
97 ) |
|
98 self.__sortImportsButton.setVisible(False) |
|
99 |
|
100 self.show() |
|
101 QCoreApplication.processEvents() |
|
102 |
|
103 self.__performAction() |
|
104 |
|
105 def __performAction(self): |
|
106 """ |
|
107 Private method to execute the requested formatting action. |
|
108 """ |
|
109 self.progressBar.setValue(0) |
|
110 self.progressBar.setVisible(True) |
|
111 |
|
112 self.statisticsGroup.setVisible(False) |
|
113 self.__statistics = IsortStatistics() |
|
114 |
|
115 self.__cancelled = False |
|
116 |
|
117 self.statusFilterComboBox.clear() |
|
118 self.resultsList.clear() |
|
119 |
|
120 self.buttonBox.button(QDialogButtonBox.StandardButton.Cancel).setEnabled(True) |
|
121 self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setEnabled(False) |
|
122 self.buttonBox.button(QDialogButtonBox.StandardButton.Cancel).setDefault(True) |
|
123 |
|
124 files = self.__filterFiles(self.__filesList) |
|
125 if len(files) > 1: |
|
126 self.__formatManyFiles(files) |
|
127 elif len(files) == 1: |
|
128 self.__formatOneFile(files[0]) |
|
129 |
|
130 def __filterFiles(self, filesList): |
|
131 """ |
|
132 Private method to filter the given list of files according the |
|
133 configuration parameters. |
|
134 |
|
135 @param filesList list of files |
|
136 @type list of str |
|
137 @return list of filtered files |
|
138 @rtype list of str |
|
139 """ |
|
140 files = [] |
|
141 for file in filesList: |
|
142 if not self.__isortConfig.is_supported_filetype( |
|
143 file |
|
144 ) or self.__isortConfig.is_skipped(pathlib.Path(file)): |
|
145 self.__handleIsortResult(file, "skipped") |
|
146 else: |
|
147 files.append(file) |
|
148 |
|
149 return files |
|
150 |
|
151 def __resort(self): |
|
152 """ |
|
153 Private method to resort the result list. |
|
154 """ |
|
155 self.resultsList.sortItems( |
|
156 self.resultsList.sortColumn(), |
|
157 self.resultsList.header().sortIndicatorOrder(), |
|
158 ) |
|
159 |
|
160 def __resizeColumns(self): |
|
161 """ |
|
162 Private method to resize the columns of the result list. |
|
163 """ |
|
164 self.resultsList.header().resizeSections( |
|
165 QHeaderView.ResizeMode.ResizeToContents |
|
166 ) |
|
167 self.resultsList.header().setStretchLastSection(True) |
|
168 |
|
169 def __populateStatusFilterCombo(self): |
|
170 """ |
|
171 Private method to populate the status filter combo box with allowed selections. |
|
172 """ |
|
173 allowedSelections = set() |
|
174 for row in range(self.resultsList.topLevelItemCount()): |
|
175 allowedSelections.add( |
|
176 self.resultsList.topLevelItem(row).text( |
|
177 IsortFormattingDialog.StatusColumn |
|
178 ) |
|
179 ) |
|
180 |
|
181 self.statusFilterComboBox.addItem(self.__allFilter) |
|
182 self.statusFilterComboBox.addItems(sorted(allowedSelections)) |
|
183 |
|
184 def __finish(self): |
|
185 """ |
|
186 Private method to perform some actions after the run was performed or canceled. |
|
187 """ |
|
188 self.__resort() |
|
189 self.__resizeColumns() |
|
190 |
|
191 self.buttonBox.button(QDialogButtonBox.StandardButton.Cancel).setEnabled(False) |
|
192 self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setEnabled(True) |
|
193 self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setDefault(True) |
|
194 |
|
195 self.progressBar.setVisible(False) |
|
196 |
|
197 self.__sortImportsButton.setVisible( |
|
198 self.__config["__action__"] is not IsortFormattingAction.Sort |
|
199 and self.__statistics.changeCount > 0 |
|
200 ) |
|
201 |
|
202 self.__updateStatistics() |
|
203 self.__populateStatusFilterCombo() |
|
204 |
|
205 def __updateStatistics(self): |
|
206 """ |
|
207 Private method to update the statistics about the recent formatting run and |
|
208 make them visible. |
|
209 """ |
|
210 self.reformattedLabel.setText( |
|
211 self.tr("Reformatted:") |
|
212 if self.__config["__action__"] is IsortFormattingAction.Sort |
|
213 else self.tr("Would Reformat:") |
|
214 ) |
|
215 |
|
216 total = self.progressBar.maximum() |
|
217 |
|
218 self.totalCountLabel.setText("{0:n}".format(total)) |
|
219 self.skippedCountLabel.setText("{0:n}".format(self.__statistics.skippedCount)) |
|
220 self.failuresCountLabel.setText("{0:n}".format(self.__statistics.failureCount)) |
|
221 self.processedCountLabel.setText( |
|
222 "{0:n}".format(self.__statistics.processedCount) |
|
223 ) |
|
224 self.reformattedCountLabel.setText( |
|
225 "{0:n}".format(self.__statistics.changeCount) |
|
226 ) |
|
227 self.unchangedCountLabel.setText("{0:n}".format(self.__statistics.sameCount)) |
|
228 |
|
229 self.statisticsGroup.setVisible(True) |
|
230 |
|
231 @pyqtSlot(QAbstractButton) |
|
232 def on_buttonBox_clicked(self, button): |
|
233 """ |
|
234 Private slot to handle button presses of the dialog buttons. |
|
235 |
|
236 @param button reference to the pressed button |
|
237 @type QAbstractButton |
|
238 """ |
|
239 if button == self.buttonBox.button(QDialogButtonBox.StandardButton.Cancel): |
|
240 self.__cancelled = True |
|
241 elif button == self.buttonBox.button(QDialogButtonBox.StandardButton.Close): |
|
242 self.accept() |
|
243 elif button is self.__sortImportsButton: |
|
244 self.__sortImportsButtonClicked() |
|
245 |
|
246 @pyqtSlot() |
|
247 def __sortImportsButtonClicked(self): |
|
248 """ |
|
249 Private slot handling the selection of the 'Sort Imports' button. |
|
250 """ |
|
251 self.__config["__action__"] = IsortFormattingAction.Sort |
|
252 |
|
253 self.__performAction() |
|
254 |
|
255 @pyqtSlot(QTreeWidgetItem, int) |
|
256 def on_resultsList_itemDoubleClicked(self, item, column): |
|
257 """ |
|
258 Private slot handling a double click of a result item. |
|
259 |
|
260 @param item reference to the double clicked item |
|
261 @type QTreeWidgetItem |
|
262 @param column column number that was double clicked |
|
263 @type int |
|
264 """ |
|
265 dataType = item.data(0, IsortFormattingDialog.DataTypeRole) |
|
266 if dataType == "error": |
|
267 EricMessageBox.critical( |
|
268 self, |
|
269 self.tr("Formatting Failure"), |
|
270 self.tr("<p>Formatting failed due to this error.</p><p>{0}</p>").format( |
|
271 item.data(0, IsortFormattingDialog.DataRole) |
|
272 ), |
|
273 ) |
|
274 elif dataType == "diff": |
|
275 if self.__diffDialog is None: |
|
276 self.__diffDialog = FormattingDiffWidget() |
|
277 self.__diffDialog.showDiff(item.data(0, IsortFormattingDialog.DataRole)) |
|
278 |
|
279 @pyqtSlot(str) |
|
280 def on_statusFilterComboBox_currentTextChanged(self, status): |
|
281 """ |
|
282 Private slot handling the selection of a status for items to be shown. |
|
283 |
|
284 @param status selected status |
|
285 @type str |
|
286 """ |
|
287 for row in range(self.resultsList.topLevelItemCount()): |
|
288 itm = self.resultsList.topLevelItem(row) |
|
289 itm.setHidden( |
|
290 status != self.__allFilter |
|
291 and itm.text(IsortFormattingDialog.StatusColumn) != status |
|
292 ) |
|
293 |
|
294 def closeEvent(self, evt): |
|
295 """ |
|
296 Protected slot implementing a close event handler. |
|
297 |
|
298 @param evt reference to the close event |
|
299 @type QCloseEvent |
|
300 """ |
|
301 if self.__diffDialog is not None: |
|
302 self.__diffDialog.close() |
|
303 evt.accept() |
|
304 |
|
305 def __handleIsortResult(self, filename, status, data=""): |
|
306 """ |
|
307 Private method to handle an isort formatting result. |
|
308 |
|
309 @param filename name of the processed file |
|
310 @type str |
|
311 @param status status of the performed action (one of 'changed', 'failed', |
|
312 'skipped' or 'unchanged') |
|
313 @type str |
|
314 @param data action data (error message or unified diff) (defaults to "") |
|
315 @type str (optional) |
|
316 """ |
|
317 isError = False |
|
318 |
|
319 if status == "changed": |
|
320 statusMsg = ( |
|
321 self.tr("would resort") |
|
322 if self.__config["__action__"] |
|
323 in (IsortFormattingAction.Check, IsortFormattingAction.Diff) |
|
324 else self.tr("resorted") |
|
325 ) |
|
326 self.__statistics.changeCount += 1 |
|
327 |
|
328 elif status == "unchanged": |
|
329 statusMsg = self.tr("unchanged") |
|
330 self.__statistics.sameCount += 1 |
|
331 |
|
332 elif status == "skipped": |
|
333 statusMsg = self.tr("skipped") |
|
334 self.__statistics.skippedCount += 1 |
|
335 |
|
336 elif status == "failed": |
|
337 statusMsg = self.tr("failed") |
|
338 self.__statistics.failureCount += 1 |
|
339 isError = True |
|
340 |
|
341 elif status == "unsupported": |
|
342 statusMsg = self.tr("error") |
|
343 data = self.tr("Unsupported 'isort' action ({0}) given.").format( |
|
344 self.__config["__action__"] |
|
345 ) |
|
346 self.__statistics.failureCount += 1 |
|
347 isError = True |
|
348 |
|
349 else: |
|
350 statusMsg = self.tr("invalid status ({0})").format(status) |
|
351 self.__statistics.failureCount += 1 |
|
352 isError = True |
|
353 |
|
354 if status != "skipped": |
|
355 self.__statistics.processedCount += 1 |
|
356 |
|
357 if self.__project: |
|
358 filename = self.__project.getRelativePath(filename) |
|
359 |
|
360 itm = QTreeWidgetItem(self.resultsList, [statusMsg, filename]) |
|
361 if data: |
|
362 itm.setData( |
|
363 0, IsortFormattingDialog.DataTypeRole, "error" if isError else "diff" |
|
364 ) |
|
365 itm.setData(0, IsortFormattingDialog.DataRole, data) |
|
366 |
|
367 self.progressBar.setValue(self.progressBar.value() + 1) |
|
368 |
|
369 QCoreApplication.processEvents() |
|
370 |
|
371 def __formatManyFiles(self, files): |
|
372 """ |
|
373 Private method to format the list of files according the configuration using |
|
374 multiple processes in parallel. |
|
375 |
|
376 @param files list of files to be processed |
|
377 @type list of str |
|
378 """ |
|
379 maxProcesses = Preferences.getUI("BackgroundServiceProcesses") |
|
380 if maxProcesses == 0: |
|
381 # determine based on CPU count |
|
382 try: |
|
383 NumberOfProcesses = multiprocessing.cpu_count() |
|
384 if NumberOfProcesses >= 1: |
|
385 NumberOfProcesses -= 1 |
|
386 except NotImplementedError: |
|
387 NumberOfProcesses = 1 |
|
388 else: |
|
389 NumberOfProcesses = maxProcesses |
|
390 |
|
391 # Create queues |
|
392 taskQueue = multiprocessing.Queue() |
|
393 doneQueue = multiprocessing.Queue() |
|
394 |
|
395 # Submit tasks (initially two times the number of processes) |
|
396 tasks = len(files) |
|
397 initialTasks = min(2 * NumberOfProcesses, tasks) |
|
398 for _ in range(initialTasks): |
|
399 file = files.pop(0) |
|
400 taskQueue.put((file, self.__config["__action__"])) |
|
401 |
|
402 # Start worker processes |
|
403 workers = [ |
|
404 multiprocessing.Process( |
|
405 target=self.formattingWorkerTask, |
|
406 args=(taskQueue, doneQueue, self.__isortConfig), |
|
407 ) |
|
408 for _ in range(NumberOfProcesses) |
|
409 ] |
|
410 for worker in workers: |
|
411 worker.start() |
|
412 |
|
413 # Get the results from the worker tasks |
|
414 for _ in range(tasks): |
|
415 result = doneQueue.get() |
|
416 self.__handleIsortResult(result.filename, result.status, data=result.data) |
|
417 |
|
418 if self.__cancelled: |
|
419 break |
|
420 |
|
421 if files: |
|
422 file = files.pop(0) |
|
423 taskQueue.put((file, self.__config["__action__"])) |
|
424 |
|
425 # Tell child processes to stop |
|
426 for _ in range(NumberOfProcesses): |
|
427 taskQueue.put("STOP") |
|
428 |
|
429 for worker in workers: |
|
430 worker.join() |
|
431 worker.close() |
|
432 |
|
433 taskQueue.close() |
|
434 doneQueue.close() |
|
435 |
|
436 self.__finish() |
|
437 |
|
438 @staticmethod |
|
439 def formattingWorkerTask(inputQueue, outputQueue, isortConfig): |
|
440 """ |
|
441 Static method acting as the parallel worker for the formatting task. |
|
442 |
|
443 @param inputQueue input queue |
|
444 @type multiprocessing.Queue |
|
445 @param outputQueue output queue |
|
446 @type multiprocessing.Queue |
|
447 @param isortConfig config object for isort |
|
448 @type isort.Config |
|
449 """ |
|
450 for file, action in iter(inputQueue.get, "STOP"): |
|
451 if action == IsortFormattingAction.Diff: |
|
452 result = IsortFormattingDialog.__isortCheckFile( |
|
453 file, |
|
454 isortConfig, |
|
455 withDiff=True, |
|
456 ) |
|
457 |
|
458 elif action == IsortFormattingAction.Sort: |
|
459 result = IsortFormattingDialog.__isortSortFile( |
|
460 file, |
|
461 isortConfig, |
|
462 ) |
|
463 |
|
464 else: |
|
465 result = IsortResult( |
|
466 status="unsupported", |
|
467 filename=file, |
|
468 ) |
|
469 |
|
470 outputQueue.put(result) |
|
471 |
|
472 def __formatOneFile(self, file): |
|
473 """ |
|
474 Private method to format the list of files according the configuration. |
|
475 |
|
476 @param file name of the file to be processed |
|
477 @type str |
|
478 """ |
|
479 if self.__config["__action__"] == IsortFormattingAction.Diff: |
|
480 result = IsortFormattingDialog.__isortCheckFile( |
|
481 file, |
|
482 self.__isortConfig, |
|
483 withDiff=True, |
|
484 ) |
|
485 |
|
486 elif self.__config["__action__"] == IsortFormattingAction.Sort: |
|
487 result = IsortFormattingDialog.__isortSortFile( |
|
488 file, |
|
489 self.__isortConfig, |
|
490 ) |
|
491 |
|
492 else: |
|
493 result = IsortResult( |
|
494 status="unsupported", |
|
495 filename=file, |
|
496 ) |
|
497 |
|
498 self.__handleIsortResult(result.filename, result.status, data=result.data) |
|
499 |
|
500 self.__finish() |
|
501 |
|
502 @staticmethod |
|
503 def __isortCheckFile(filename, isortConfig, withDiff=True): |
|
504 """ |
|
505 Static method to check, if a file's import statements need to be changed. |
|
506 |
|
507 @param filename name of the file to be processed |
|
508 @type str |
|
509 @param isortConfig config object for isort |
|
510 @type isort.Config |
|
511 @param withDiff flag indicating to return a unified diff, if the file needs to |
|
512 be changed (defaults to True) |
|
513 @type bool (optional) |
|
514 @return result object |
|
515 @rtype IsortResult |
|
516 """ |
|
517 diffIO = io.StringIO() if withDiff else False |
|
518 with suppressStderr(): |
|
519 ok = check_file(filename, show_diff=diffIO, config=isortConfig) |
|
520 if withDiff: |
|
521 data = "" if ok else diffIO.getvalue() |
|
522 diffIO.close() |
|
523 else: |
|
524 data = "" |
|
525 |
|
526 status = "unchanged" if ok else "changed" |
|
527 |
|
528 return IsortResult(status=status, filename=filename, data=data) |
|
529 |
|
530 @staticmethod |
|
531 def __isortSortFile(filename, isortConfig): |
|
532 """ |
|
533 Static method to sort the import statements of a file. |
|
534 |
|
535 @param filename name of the file to be processed |
|
536 @type str |
|
537 @param isortConfig config object for isort |
|
538 @type isort.Config |
|
539 @return result object |
|
540 @rtype IsortResult |
|
541 """ |
|
542 with suppressStderr(): |
|
543 ok = sort_file( |
|
544 filename, |
|
545 config=isortConfig, |
|
546 ask_to_apply=False, |
|
547 write_to_stdout=False, |
|
548 show_diff=False, |
|
549 ) |
|
550 |
|
551 status = "changed" if ok else "unchanged" |
|
552 |
|
553 return IsortResult(status=status, filename=filename) |
|
554 |
|
555 |
|
556 @dataclass |
|
557 class IsortStatistics: |
|
558 """ |
|
559 Class containing the isort reformatting statistic data. |
|
560 """ |
|
561 |
|
562 skippedCount: int = 0 |
|
563 changeCount: int = 0 |
|
564 sameCount: int = 0 |
|
565 failureCount: int = 0 |
|
566 processedCount: int = 0 |
|
567 |
|
568 |
|
569 @dataclass |
|
570 class IsortResult: |
|
571 """ |
|
572 Class containing the isort reformatting result data. |
|
573 """ |
|
574 |
|
575 status: str = "" |
|
576 filename: str = "" |
|
577 data: str = "" |