src/eric7/CodeFormatting/IsortFormattingDialog.py

branch
eric7
changeset 9453
e5065dde905d
child 9455
5f138ee215a5
equal deleted inserted replaced
9452:325c6de4b1f5 9453:e5065dde905d
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 = ""

eric ide

mercurial