PyrightChecker/PyrightCheckerDialog.py

changeset 1
191e9ec72893
child 11
55bc88e0aea0
equal deleted inserted replaced
0:1b1bf094c013 1:191e9ec72893
1 # -*- coding: utf-8 -*-
2
3 # Copyright (c) 2023 Detlev Offenbach <detlev@die-offenbachs.de>
4 #
5
6 """
7 Module implementing the pyright type checker dialog.
8 """
9
10 import contextlib
11 import copy
12 import json
13 import os
14
15 import tomlkit
16
17 from PyQt6.QtCore import QProcess, Qt, pyqtSlot
18 from PyQt6.QtGui import QGuiApplication
19 from PyQt6.QtWidgets import QDialog, QDialogButtonBox, QTreeWidgetItem
20
21 from eric7 import Preferences
22 from eric7.EricWidgets import EricMessageBox
23 from eric7.EricWidgets.EricApplication import ericApp
24 from eric7.QScintilla.Editor import Editor
25 from eric7.SystemUtilities import PythonUtilities
26
27 from .Ui_PyrightCheckerDialog import Ui_PyrightCheckerDialog
28
29
30 class PyrightCheckerDialog(QDialog, Ui_PyrightCheckerDialog):
31 """
32 Class documentation goes here.
33 """
34
35 filenameRole = Qt.ItemDataRole.UserRole + 1
36 severityRole = Qt.ItemDataRole.UserRole + 2
37 startRole = Qt.ItemDataRole.UserRole + 3
38 endRole = Qt.ItemDataRole.UserRole + 4
39
40 def __init__(self, plugin, parent=None):
41 """
42 Constructor
43
44 @param plugin reference to the plugin object
45 @type PyrightPlugin
46 @param parent reference to the parent widget (defaults to None)
47 @type QWidget (optional)
48 """
49 super().__init__(parent)
50 self.setupUi(self)
51 self.setWindowFlags(Qt.WindowType.Window)
52
53 self.__plugin = plugin
54
55 self.__severityMapping = {
56 "error": self.tr("Error"),
57 "warning": self.tr("Warning"),
58 "information": self.tr("Information"),
59 }
60
61 self.__severityForEditor = {"warning": Editor.WarningCode}
62 try:
63 self.__severityForEditor["error"] = Editor.WarningError
64 except AttributeError:
65 self.__severityForEditor["error"] = Editor.WarningCode
66 try:
67 self.__severityForEditor["information"] = Editor.WarningInfo
68 except AttributeError:
69 self.__severityForEditor["information"] = Editor.WarningCode
70
71 self.__exitCodeMapping = {
72 0: self.tr("No issues detected"),
73 1: self.tr("Issues detected"),
74 2: self.tr("Fatal error occurred with no errors or warnings reported"),
75 3: self.tr("Config file could not be read or parsed"),
76 4: self.tr("Illegal command-line parameters specified"),
77 }
78
79 self.platformComboBox.addItem("", "")
80 self.platformComboBox.addItem("Linux", "Linux")
81 self.platformComboBox.addItem("macOS", "Darwin")
82 self.platformComboBox.addItem("Windows", "Windows")
83
84 self.versionComboBox.addItems(["", "3.8", "3.9", "3.10", "3.11", "3.12"])
85
86 self.__dirOrFileList = []
87 self.__project = None
88 self.__forProject = False
89 self.__process = None
90 self.__hasResults = False
91
92 self.showButton.setEnabled(False)
93 self.tomlButton.setEnabled(False)
94 self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setEnabled(False)
95
96 self.resultList.headerItem().setText(self.resultList.columnCount(), "")
97 self.resultList.header().setSortIndicator(0, Qt.SortOrder.AscendingOrder)
98
99 self.__colors = {}
100
101 self.on_loadDefaultButton_clicked()
102
103 self.mainWidget.setCurrentWidget(self.configureTab)
104
105 def __resort(self):
106 """
107 Private method to resort the tree.
108 """
109 self.resultList.sortItems(
110 self.resultList.sortColumn(), self.resultList.header().sortIndicatorOrder()
111 )
112
113 def __createResultItem(self, result):
114 """
115 Private method to create an entry in the result list.
116
117 @param result dictionary containing check result data
118 @type dict
119 """
120 # step 1: search the file entry or create it
121 filePath = (
122 self.__project.getRelativePath(result["file"])
123 if self.__forProject
124 else result["file"]
125 )
126 fileItems = self.resultList.findItems(filePath, Qt.MatchFlag.MatchExactly, 0)
127 if fileItems:
128 fileItem = fileItems[0]
129 else:
130 fileItem = QTreeWidgetItem(self.resultList, [filePath])
131 fileItem.setFirstColumnSpanned(True)
132 fileItem.setExpanded(True)
133 fileItem.setData(0, self.filenameRole, result["file"])
134
135 itm = QTreeWidgetItem(
136 fileItem,
137 [
138 "{0:6}".format(result["range"]["start"]["line"] + 1),
139 self.__severityMapping[result["severity"]],
140 result["message"],
141 ],
142 )
143
144 itm.setTextAlignment(
145 0, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
146 )
147 itm.setTextAlignment(
148 1, Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter
149 )
150 itm.setTextAlignment(2, Qt.AlignmentFlag.AlignVCenter)
151
152 itm.setData(0, self.filenameRole, result["file"])
153 itm.setData(0, self.severityRole, result["severity"])
154 itm.setData(0, self.startRole, result["range"]["start"])
155 itm.setData(0, self.endRole, result["range"]["end"])
156
157 with contextlib.suppress(KeyError):
158 itm.setData(
159 1, Qt.ItemDataRole.ForegroundRole, self.__colors[result["severity"]][0]
160 )
161 itm.setData(
162 1, Qt.ItemDataRole.BackgroundRole, self.__colors[result["severity"]][1]
163 )
164
165 def __updateSummary(self, summary):
166 """
167 Private method to update the summary data of the dialog.
168
169 @param summary dictionary containing the summary data
170 @type dict
171 """
172 self.filesLabel.setText(str(summary["filesAnalyzed"]))
173 self.errorLabel.setText(str(summary["errorCount"]))
174 self.warningLabel.setText(str(summary["warningCount"]))
175 self.infoLabel.setText(str(summary["informationCount"]))
176
177 def __processResult(self, result):
178 """
179 Private method to process the pyright result.
180
181 @param result dictionary containing the type checking result.
182 @type dict
183 """
184 # 1. update the severity color mapping
185 self.__colors = {
186 # tuple of foreground and background colors
187 "error": (
188 Preferences.getEditorColour("AnnotationsErrorForeground"),
189 Preferences.getEditorColour("AnnotationsErrorBackground"),
190 ),
191 "warning": (
192 Preferences.getEditorColour("AnnotationsWarningForeground"),
193 Preferences.getEditorColour("AnnotationsWarningBackground"),
194 ),
195 }
196 with contextlib.suppress(KeyError):
197 # eric-ide before 23.12 doesn't have this color
198 self.__colors["information"] = (
199 Preferences.getEditorColour("AnnotationsInfoForeground"),
200 Preferences.getEditorColour("AnnotationsInfoBackground"),
201 )
202
203 # 2. set pyright version
204 try:
205 self.pyrightLabel.setText(result["version"])
206 except KeyError:
207 self.pyrightLabel.setText(self.tr("unknown"))
208
209 # 3. create result items
210 if result["exitCode"] == 1:
211 self.__hasResults = True
212 for diagnostic in result["generalDiagnostics"]:
213 self.__createResultItem(diagnostic)
214 else:
215 itm = QTreeWidgetItem(
216 self.resultList, self.__exitCodeMapping[result["exitCode"]]
217 )
218 itm.setFirstColumnSpanned(True)
219
220 for col in range(self.resultList.columnCount()):
221 self.resultList.resizeColumnToContents(col)
222 self.resultList.header().setStretchLastSection(True)
223 self.__resort()
224 self.resultList.setSortingEnabled(True)
225
226 # 4. set summary information
227 self.__updateSummary(result["summary"])
228
229 self.showButton.setEnabled(self.__hasResults)
230 self.mainWidget.setCurrentWidget(self.resultsTab)
231
232 def getDefaults(self):
233 """
234 Public method to get a dictionary containing the default values.
235
236 @return dictionary containing the default values
237 @rtype dict
238 """
239 defaults = {
240 "PythonPlatform": "",
241 "PythonVersion": "",
242 "SkipUnannotated": False,
243 }
244
245 return defaults
246
247 def prepare(self, project):
248 """
249 Public method to prepare the dialog with a list of filenames.
250
251 @param project reference to the project object
252 @type Project
253 """
254 self.__project = project
255 self.__forProject = True
256 self.__dirOrFileList = [self.__project.getProjectPath()]
257
258 self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setEnabled(True)
259 self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setDefault(True)
260
261 defaultParameters = self.getDefaults()
262 self.__data = self.__project.getData("CHECKERSPARMS", "PyrightChecker")
263 if self.__data is None:
264 # initialize the data structure
265 self.__data = copy.deepcopy(defaultParameters)
266 else:
267 for key in defaultParameters:
268 if key not in self.__data:
269 self.__data[key] = defaultParameters[key]
270
271 self.platformComboBox.setCurrentIndex(
272 self.platformComboBox.findData(self.__data["PythonPlatform"])
273 )
274 self.versionComboBox.setCurrentText(self.__data["PythonVersion"])
275 self.skipUnannotatedCheckBox.setChecked(self.__data["SkipUnannotated"])
276
277 self.tomlButton.setEnabled(True)
278
279 self.mainWidget.setCurrentWidget(self.configureTab)
280
281 def start(self, files=None, save=False):
282 """
283 Public method to start a pyright type checking run.
284
285 @param files list of files to be checked (defaults to None)
286 @type list of str (optional)
287 @param save flag indicating to save the given file/file list/directory
288 @type bool
289 """
290 self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setEnabled(False)
291 self.startButton.setEnabled(False)
292 self.restartButton.setEnabled(False)
293 self.showButton.setEnabled(False)
294
295 if not files and not self.__forProject:
296 EricMessageBox.critical(
297 self,
298 self.tr("pyright Type Checking"),
299 self.tr(
300 """pyright type checking has to be performed for individual"""
301 """ files or a project but neither was given. Aborting..."""
302 ),
303 )
304 return
305
306 if files and save:
307 self.__dirOrFileList = files
308
309 interpreter = PythonUtilities.getPythonExecutable()
310 args = ["-m", "pyright", "--outputjson"]
311
312 pythonPlatform = self.platformComboBox.currentData()
313 if pythonPlatform:
314 args.extend(["--pythonplatform", pythonPlatform])
315 pythonVersion = self.versionComboBox.currentText()
316 if pythonVersion:
317 args.extend(["--pythonversion", pythonVersion])
318 if self.skipUnannotatedCheckBox.isChecked():
319 args.append("--skipunannotated")
320 if self.__forProject:
321 args.extend(["--project", self.__project.getProjectPath()])
322 args.extend(files)
323
324 self.__process = QProcess(self)
325 self.__process.readyReadStandardError.connect(self.__readError)
326 self.__process.finished.connect(self.__pyrightProcessFinished)
327 self.__process.start(interpreter, args)
328
329 @pyqtSlot()
330 def __readError(self):
331 """
332 Private slot to get the output of the error channel and show it to the user.
333 """
334 errorMsg = str(self.__process.readAllStandardError(), encoding="utf-8")
335 EricMessageBox.critical(
336 self,
337 self.tr("pyright Type Checking"),
338 self.tr(
339 "<p>The pyright type checking run failed.</p><p>Reason: {0}</p>"
340 ).format(errorMsg),
341 )
342
343 @pyqtSlot(int, QProcess.ExitStatus)
344 def __pyrightProcessFinished(self, exitCode, exitStatus):
345 """
346 Private slot to process the pyright result.
347
348 @param exitCode exit code of the pyright process
349 @type int
350 @param exitStatus exit status
351 @type QProcess.ExitStatus
352 """
353 self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setEnabled(True)
354 self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setDefault(True)
355 self.showButton.setEnabled(True)
356 self.startButton.setEnabled(True)
357 self.restartButton.setEnabled(True)
358
359 if exitStatus != QProcess.ExitStatus.NormalExit:
360 EricMessageBox.critical(
361 self,
362 self.tr("pyright Type Checking"),
363 self.tr(
364 "<p>The pyright type checking process did not end normally.</p>"
365 ),
366 )
367 return
368
369 output = str(self.__process.readAllStandardOutput(), encoding="utf-8")
370 try:
371 resultDict = json.loads(output)
372 except json.JSONDecodeError as err:
373 EricMessageBox.critical(
374 self,
375 self.tr("pyright Type Checking"),
376 self.tr(
377 "<p>The pyright type checking process did not return valid"
378 " JSON data.</p><p>Issue: {0}</p>"
379 ).format(str(err)),
380 )
381 return
382
383 resultDict["exitCode"] = exitCode
384 self.__processResult(resultDict)
385
386 @pyqtSlot(QTreeWidgetItem, int)
387 def on_resultList_itemActivated(self, item, column):
388 """
389 Private slot to handle the activation of an item.
390
391 @param item reference to the activated item
392 @type QTreeWidgetItem
393 @param column column the item was activated in
394 @type int
395 """
396 if self.__hasResults and item.parent():
397 fn = os.path.abspath(item.data(0, self.filenameRole))
398 start = item.data(0, self.startRole)
399 severity = item.data(0, self.severityRole)
400
401 vm = ericApp().getObject("ViewManager")
402 vm.openSourceFile(fn, lineno=start["line"] + 1, pos=start["character"] + 1)
403 editor = vm.getOpenEditor(fn)
404
405 editor.toggleWarning(
406 start["line"] + 1,
407 start["character"] + 1,
408 True,
409 item.text(2),
410 warningType=self.__severityForEditor[severity],
411 )
412
413 editor.updateVerticalScrollBar()
414
415 @pyqtSlot()
416 def on_restartButton_clicked(self):
417 """
418 Private slot to restart the configured check.
419 """
420 self.on_startButton_clicked()
421
422 @pyqtSlot()
423 def on_showButton_clicked(self):
424 """
425 Private slot to handle the "Show" button press.
426 """
427 vm = ericApp().getObject("ViewManager")
428
429 selectedIndexes = []
430 for index in range(self.resultList.topLevelItemCount()):
431 if self.resultList.topLevelItem(index).isSelected():
432 selectedIndexes.append(index)
433 if len(selectedIndexes) == 0:
434 selectedIndexes = list(range(self.resultList.topLevelItemCount()))
435
436 for index in selectedIndexes:
437 itm = self.resultList.topLevelItem(index)
438 fn = os.path.abspath(itm.data(0, self.filenameRole))
439 vm.openSourceFile(fn, 1)
440 editor = vm.getOpenEditor(fn)
441 self.__clearEditorErrors(editor)
442 for cindex in range(itm.childCount()):
443 citm = itm.child(cindex)
444 start = citm.data(0, self.startRole)
445 severity = citm.data(0, self.severityRole)
446 editor.toggleWarning(
447 start["line"] + 1,
448 start["character"] + 1,
449 True,
450 citm.text(2),
451 warningType=self.__severityForEditor[severity],
452 )
453
454 @pyqtSlot()
455 def on_startButton_clicked(self):
456 """
457 Private slot to start the pyright type checking run.
458 """
459 if self.__forProject:
460 data = {
461 "PythonPlatform": self.platformComboBox.currentData(),
462 "PythonVersion": self.versionComboBox.currentText(),
463 "SkipUnannotated": self.skipUnannotatedCheckBox.isChecked(),
464 }
465 if json.dumps(data, sort_keys=True) != json.dumps(
466 self.__data, sort_keys=True
467 ):
468 self.__data = data
469 self.__project.setData("CHECKERSPARMS", "PyrightChecker", self.__data)
470
471 self.__clearErrors()
472 self.__clear()
473 self.start(self.__dirOrFileList)
474
475 def __clear(self):
476 """
477 Private method to clear the dialog.
478 """
479 self.resultList.clear()
480 self.__hasResults = False
481
482 def __clearErrors(self, files=None):
483 """
484 Private method to clear all warning markers of open editors to be
485 checked.
486
487 @param files list of files to be checked (defaults to None
488 @type list of str (optional
489 """
490 vm = ericApp().getObject("ViewManager")
491 openFiles = vm.getOpenFilenames()
492 if files is not None:
493 # filter out the files checked
494 openFiles = [f for f in openFiles if f in files]
495 for file in openFiles:
496 editor = vm.getOpenEditor(file)
497 try:
498 editor.clearInfoWarnings()
499 editor.clearErrorWarnings()
500 editor.clearCodeWarnings()
501 except AttributeError:
502 # eric before 23.12
503 editor.clearFlakesWarnings()
504
505 def __clearEditorErrors(self, editor):
506 """
507 Private method to clear all warning markers of an editor.
508
509 @param editor reference to the editor to be cleared
510 @type Editor
511 """
512 try:
513 editor.clearInfoWarnings()
514 editor.clearErrorWarnings()
515 editor.clearCodeWarnings()
516 except AttributeError:
517 # eric before 23.12
518 editor.clearFlakesWarnings()
519
520 ############################################################################
521 ## Methods for storing, loading and resetting the default values. ##
522 ############################################################################
523
524 @pyqtSlot()
525 def on_loadDefaultButton_clicked(self):
526 """
527 Private slot to load the default configuration values.
528 """
529 defaultParameters = self.getDefaults()
530 settings = Preferences.getSettings()
531
532 self.platformComboBox.setCurrentIndex(
533 self.platformComboBox.findData(
534 settings.value(
535 self.__plugin.PreferencesKey + "/PythonPlatform",
536 defaultParameters["PythonPlatform"],
537 )
538 )
539 )
540 self.versionComboBox.setCurrentText(
541 settings.value(
542 self.__plugin.PreferencesKey + "/PythonVersion",
543 defaultParameters["PythonVersion"],
544 )
545 )
546 self.skipUnannotatedCheckBox.setChecked(
547 Preferences.toBool(
548 settings.value(
549 self.__plugin.PreferencesKey + "/SkipUnannotated",
550 defaultParameters["SkipUnannotated"],
551 )
552 )
553 )
554
555 @pyqtSlot()
556 def on_storeDefaultButton_clicked(self):
557 """
558 Private slot to store the current configuration values as
559 default values.
560 """
561 settings = Preferences.getSettings()
562
563 settings.setValue(
564 self.__plugin.PreferencesKey + "/PythonPlatform",
565 self.platformComboBox.currentData(),
566 )
567 settings.setValue(
568 self.__plugin.PreferencesKey + "/PythonVersion",
569 self.versionComboBox.currentText(),
570 )
571 settings.setValue(
572 self.__plugin.PreferencesKey + "/SkipUnannotated",
573 self.skipUnannotatedCheckBox.isChecked(),
574 )
575
576 @pyqtSlot()
577 def on_resetDefaultButton_clicked(self):
578 """
579 Private slot to reset the configuration values to their default values.
580 """
581 defaultParameters = self.getDefaults()
582 settings = Preferences.getSettings()
583
584 settings.setValue(
585 self.__plugin.PreferencesKey + "/PythonPlatform",
586 defaultParameters["PythonPlatform"],
587 )
588 settings.setValue(
589 self.__plugin.PreferencesKey + "/PythonVersion",
590 defaultParameters["PythonVersion"],
591 )
592 settings.setValue(
593 self.__plugin.PreferencesKey + "/SkipUnannotated",
594 defaultParameters["SkipUnannotated"],
595 )
596
597 # Update UI with default values
598 self.on_loadDefaultButton_clicked()
599
600 @pyqtSlot()
601 def on_tomlButton_clicked(self):
602 """
603 Private slot to generate a TOML snippet of the current configuration.
604
605 Note: Only non-default values are included in this snippet.
606
607 The code snippet is copied to the clipboard and may be placed inside the
608 'pyproject.toml' file.
609 """
610 if not self.__forProject or self.__project is None:
611 EricMessageBox.critical(
612 self,
613 self.tr("Create TOML snippet"),
614 self.tr(
615 "The creation of a 'pyproject.toml' snippet is only available"
616 " when in project mode. Aborting..."
617 ),
618 )
619 return
620
621 configDict = self.__getConfigurationDict()
622
623 pyrightTable = tomlkit.table()
624 for key, value in configDict.items():
625 pyrightTable[key] = value
626
627 doc = tomlkit.document()
628 doc["tool"] = tomlkit.table(is_super_table=True)
629 doc["tool"]["pyright"] = pyrightTable
630
631 QGuiApplication.clipboard().setText(tomlkit.dumps(doc))
632
633 EricMessageBox.information(
634 self,
635 self.tr("Create TOML snippet"),
636 self.tr("""The 'pyproject.toml' snippet was copied to the clipboard."""),
637 )
638
639 def __getConfigurationDict(self):
640 """
641 Private method to assemble and return a dictionary containing the entered
642 non-default configuration parameters.
643
644 The configuration dictionary is amended with some common parameters not
645 accessible via the configuration tab.
646
647 @return dictionary containing the non-default configuration parameters
648 @rtype dict
649 """
650 configDict = {}
651
652 srcDir = self.__project.getProjectData("SOURCESDIR")
653 configDict["include"] = [srcDir] if srcDir else []
654
655 configDict["exclude"] = [
656 "*/node_modules",
657 "**/__pycache__",
658 "**/Ui_*.py",
659 "**/.*",
660 ]
661
662 pythonVersion = self.versionComboBox.currentText()
663 if pythonVersion:
664 configDict["pythonVersion"] = pythonVersion
665
666 pythonPlatform = self.platformComboBox.currentData()
667 if pythonPlatform:
668 configDict["pythonPlatform"] = pythonPlatform
669
670 return configDict

eric ide

mercurial