src/eric7/DataViews/PyCoverageDialog.py

branch
eric7-maintenance
changeset 9264
18a7312cfdb3
parent 9192
a763d57e23bc
parent 9238
a7cbf3d61498
child 9442
906485dcd210
equal deleted inserted replaced
9241:d23e9854aea4 9264:18a7312cfdb3
1 # -*- coding: utf-8 -*-
2
3 # Copyright (c) 2003 - 2022 Detlev Offenbach <detlev@die-offenbachs.de>
4 #
5
6 """
7 Module implementing a Python code coverage dialog.
8 """
9
10 import os
11 import time
12
13 from PyQt6.QtCore import pyqtSignal, pyqtSlot, Qt, QUrl
14 from PyQt6.QtGui import QDesktopServices
15 from PyQt6.QtWidgets import (
16 QDialog,
17 QDialogButtonBox,
18 QMenu,
19 QHeaderView,
20 QTreeWidgetItem,
21 QApplication,
22 )
23
24 from EricWidgets import EricMessageBox
25 from EricWidgets.EricApplication import ericApp
26
27 from .Ui_PyCoverageDialog import Ui_PyCoverageDialog
28
29 import Utilities
30 from coverage import Coverage
31 from coverage.misc import CoverageException
32
33
34 class PyCoverageDialog(QDialog, Ui_PyCoverageDialog):
35 """
36 Class implementing a dialog to display the collected code coverage data.
37
38 @signal openFile(str) emitted to open the given file in an editor
39 """
40
41 openFile = pyqtSignal(str)
42
43 def __init__(self, parent=None):
44 """
45 Constructor
46
47 @param parent parent widget
48 @type QWidget
49 """
50 super().__init__(parent)
51 self.setupUi(self)
52 self.setWindowFlags(Qt.WindowType.Window)
53
54 self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setEnabled(False)
55 self.buttonBox.button(QDialogButtonBox.StandardButton.Cancel).setDefault(True)
56
57 self.summaryList.headerItem().setText(self.summaryList.columnCount(), "")
58 self.resultList.headerItem().setText(self.resultList.columnCount(), "")
59
60 self.cancelled = False
61 self.path = "."
62 self.reload = False
63
64 self.excludeList = ["# *pragma[: ]*[nN][oO] *[cC][oO][vV][eE][rR]"]
65
66 self.__reportsMenu = QMenu(self.tr("Create Report"), self)
67 self.__reportsMenu.addAction(self.tr("HTML Report"), self.__htmlReport)
68 self.__reportsMenu.addSeparator()
69 self.__reportsMenu.addAction(self.tr("JSON Report"), self.__jsonReport)
70 self.__reportsMenu.addAction(self.tr("LCOV Report"), self.__lcovReport)
71
72 self.__menu = QMenu(self)
73 self.__menu.addSeparator()
74 self.openAct = self.__menu.addAction(self.tr("Open"), self.__openFile)
75 self.__menu.addSeparator()
76 self.__menu.addMenu(self.__reportsMenu)
77 self.__menu.addSeparator()
78 self.__menu.addAction(self.tr("Erase Coverage Info"), self.__erase)
79 self.resultList.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
80 self.resultList.customContextMenuRequested.connect(self.__showContextMenu)
81
82 def __format_lines(self, lines):
83 """
84 Private method to format a list of integers into string by coalescing
85 groups.
86
87 @param lines list of integers
88 @type list of int
89 @return string representing the list
90 @rtype str
91 """
92 pairs = []
93 lines.sort()
94 maxValue = lines[-1]
95 start = None
96
97 i = lines[0]
98 while i <= maxValue:
99 try:
100 if start is None:
101 start = i
102 ind = lines.index(i)
103 end = i
104 i += 1
105 except ValueError:
106 pairs.append((start, end))
107 start = None
108 if ind + 1 >= len(lines):
109 break
110 i = lines[ind + 1]
111 if start:
112 pairs.append((start, end))
113
114 def stringify(pair):
115 """
116 Private helper function to generate a string representation of a
117 pair.
118
119 @param pair pair of integers
120 @type tuple of (int, int
121 @return representation of the pair
122 @rtype str
123 """
124 start, end = pair
125 if start == end:
126 return "{0:d}".format(start)
127 else:
128 return "{0:d}-{1:d}".format(start, end)
129
130 return ", ".join(map(stringify, pairs))
131
132 def __createResultItem(
133 self, file, statements, executed, coverage, excluded, missing
134 ):
135 """
136 Private method to create an entry in the result list.
137
138 @param file filename of file
139 @type str
140 @param statements number of statements
141 @type int
142 @param executed number of executed statements
143 @type int
144 @param coverage percent of coverage
145 @type int
146 @param excluded list of excluded lines
147 @type str
148 @param missing list of lines without coverage
149 @type str
150 """
151 itm = QTreeWidgetItem(
152 self.resultList,
153 [
154 file,
155 str(statements),
156 str(executed),
157 "{0:.0f}%".format(coverage),
158 excluded,
159 missing,
160 ],
161 )
162 for col in range(1, 4):
163 itm.setTextAlignment(col, Qt.AlignmentFlag.AlignRight)
164 if statements != executed:
165 font = itm.font(0)
166 font.setBold(True)
167 for col in range(itm.columnCount()):
168 itm.setFont(col, font)
169
170 def start(self, cfn, fn):
171 """
172 Public slot to start the coverage data evaluation.
173
174 @param cfn basename of the coverage file
175 @type str
176 @param fn file or list of files or directory to be checked
177 @type str or list of str
178 """
179 # initialize the dialog
180 self.resultList.clear()
181 self.summaryList.clear()
182 self.cancelled = False
183 self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setEnabled(False)
184 self.buttonBox.button(QDialogButtonBox.StandardButton.Cancel).setEnabled(True)
185 self.buttonBox.button(QDialogButtonBox.StandardButton.Cancel).setDefault(True)
186
187 self.__cfn = cfn
188 self.__fn = fn
189
190 self.cfn = (
191 cfn
192 if cfn.endswith(".coverage")
193 else "{0}.coverage".format(os.path.splitext(cfn)[0])
194 )
195
196 if isinstance(fn, list):
197 files = fn
198 self.path = os.path.dirname(cfn)
199 elif os.path.isdir(fn):
200 files = Utilities.direntries(fn, True, "*.py", False)
201 self.path = fn
202 else:
203 files = [fn]
204 self.path = os.path.dirname(cfn)
205 files.sort()
206
207 cover = Coverage(data_file=self.cfn)
208 cover.load()
209
210 # set the exclude pattern
211 self.excludeCombo.clear()
212 self.excludeCombo.addItems(self.excludeList)
213
214 self.checkProgress.setMaximum(len(files))
215 QApplication.processEvents()
216
217 total_statements = 0
218 total_executed = 0
219 total_exceptions = 0
220
221 cover.exclude(self.excludeList[0])
222
223 try:
224 # disable updates of the list for speed
225 self.resultList.setUpdatesEnabled(False)
226 self.resultList.setSortingEnabled(False)
227
228 # now go through all the files
229 now = time.monotonic()
230 for progress, file in enumerate(files, start=1):
231 if self.cancelled:
232 return
233
234 try:
235 statements, excluded, missing, readable = cover.analysis2(file)[1:]
236 readableEx = excluded and self.__format_lines(excluded) or ""
237 n = len(statements)
238 m = n - len(missing)
239 pc = 100.0 * m / n if n > 0 else 100.0
240 self.__createResultItem(
241 file, str(n), str(m), pc, readableEx, readable
242 )
243
244 total_statements += n
245 total_executed += m
246 except CoverageException:
247 total_exceptions += 1
248
249 self.checkProgress.setValue(progress)
250 if time.monotonic() - now > 0.01:
251 QApplication.processEvents()
252 now = time.monotonic()
253 finally:
254 # reenable updates of the list
255 self.resultList.setSortingEnabled(True)
256 self.resultList.setUpdatesEnabled(True)
257 self.checkProgress.reset()
258
259 # show summary info
260 if len(files) > 1:
261 if total_statements > 0:
262 pc = 100.0 * total_executed / total_statements
263 else:
264 pc = 100.0
265 itm = QTreeWidgetItem(
266 self.summaryList,
267 [str(total_statements), str(total_executed), "{0:.0f}%".format(pc)],
268 )
269 for col in range(0, 3):
270 itm.setTextAlignment(col, Qt.AlignmentFlag.AlignRight)
271 else:
272 self.summaryGroup.hide()
273
274 if total_exceptions:
275 EricMessageBox.warning(
276 self,
277 self.tr("Parse Error"),
278 self.tr(
279 """%n file(s) could not be parsed. Coverage"""
280 """ info for these is not available.""",
281 "",
282 total_exceptions,
283 ),
284 )
285
286 self.__finish()
287
288 def __finish(self):
289 """
290 Private slot called when the action finished or the user pressed the
291 button.
292 """
293 self.cancelled = True
294 self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setEnabled(True)
295 self.buttonBox.button(QDialogButtonBox.StandardButton.Cancel).setEnabled(False)
296 self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setDefault(True)
297 QApplication.processEvents()
298 self.resultList.header().resizeSections(QHeaderView.ResizeMode.ResizeToContents)
299 self.resultList.header().setStretchLastSection(True)
300 self.summaryList.header().resizeSections(
301 QHeaderView.ResizeMode.ResizeToContents
302 )
303 self.summaryList.header().setStretchLastSection(True)
304
305 def on_buttonBox_clicked(self, button):
306 """
307 Private slot called by a button of the button box clicked.
308
309 @param button button that was clicked
310 @type QAbstractButton
311 """
312 if button == self.buttonBox.button(QDialogButtonBox.StandardButton.Close):
313 self.close()
314 elif button == self.buttonBox.button(QDialogButtonBox.StandardButton.Cancel):
315 self.__finish()
316
317 def __showContextMenu(self, coord):
318 """
319 Private slot to show the context menu of the listview.
320
321 @param coord position of the mouse pointer
322 @type QPoint
323 """
324 itm = self.resultList.itemAt(coord)
325 if itm:
326 self.openAct.setEnabled(True)
327 else:
328 self.openAct.setEnabled(False)
329 self.__reportsMenu.setEnabled(bool(self.resultList.topLevelItemCount()))
330 self.__menu.popup(self.mapToGlobal(coord))
331
332 def __openFile(self, itm=None):
333 """
334 Private slot to open the selected file.
335
336 @param itm reference to the item to be opened
337 @type QTreeWidgetItem
338 """
339 if itm is None:
340 itm = self.resultList.currentItem()
341 fn = itm.text(0)
342
343 try:
344 vm = ericApp().getObject("ViewManager")
345 vm.openSourceFile(fn)
346 editor = vm.getOpenEditor(fn)
347 editor.codeCoverageShowAnnotations(coverageFile=self.cfn)
348 except KeyError:
349 self.openFile.emit(fn)
350
351 def __prepareReportGeneration(self):
352 """
353 Private method to prepare a report generation.
354
355 @return tuple containing a reference to the Coverage object and the
356 list of files to report
357 @rtype tuple of (Coverage, list of str)
358 """
359 count = self.resultList.topLevelItemCount()
360 if count == 0:
361 return None, []
362
363 # get list of all filenames
364 files = [self.resultList.topLevelItem(index).text(0) for index in range(count)]
365
366 cover = Coverage(data_file=self.cfn)
367 cover.exclude(self.excludeList[0])
368 cover.load()
369
370 return cover, files
371
372 @pyqtSlot()
373 def __htmlReport(self):
374 """
375 Private slot to generate a HTML report of the shown data.
376 """
377 from .PyCoverageHtmlReportDialog import PyCoverageHtmlReportDialog
378
379 dlg = PyCoverageHtmlReportDialog(os.path.dirname(self.cfn), self)
380 if dlg.exec() == QDialog.DialogCode.Accepted:
381 title, outputDirectory, extraCSS, openReport = dlg.getData()
382
383 cover, files = self.__prepareReportGeneration()
384 cover.html_report(
385 morfs=files,
386 directory=outputDirectory,
387 ignore_errors=True,
388 extra_css=extraCSS,
389 title=title,
390 )
391
392 if openReport:
393 QDesktopServices.openUrl(
394 QUrl.fromLocalFile(os.path.join(outputDirectory, "index.html"))
395 )
396
397 @pyqtSlot()
398 def __jsonReport(self):
399 """
400 Private slot to generate a JSON report of the shown data.
401 """
402 from .PyCoverageJsonReportDialog import PyCoverageJsonReportDialog
403
404 dlg = PyCoverageJsonReportDialog(os.path.dirname(self.cfn), self)
405 if dlg.exec() == QDialog.DialogCode.Accepted:
406 filename, compact = dlg.getData()
407 cover, files = self.__prepareReportGeneration()
408 cover.json_report(
409 morfs=files,
410 outfile=filename,
411 ignore_errors=True,
412 pretty_print=not compact,
413 )
414
415 @pyqtSlot()
416 def __lcovReport(self):
417 """
418 Private slot to generate a LCOV report of the shown data.
419 """
420 from EricWidgets import EricPathPickerDialog
421 from EricWidgets.EricPathPicker import EricPathPickerModes
422
423 filename, ok = EricPathPickerDialog.getStrPath(
424 self,
425 self.tr("LCOV Report"),
426 self.tr("Enter the path of the output file:"),
427 mode=EricPathPickerModes.SAVE_FILE_ENSURE_EXTENSION_MODE,
428 strPath=os.path.join(os.path.dirname(self.cfn), "coverage.lcov"),
429 defaultDirectory=os.path.dirname(self.cfn),
430 filters=self.tr("LCOV Files (*.lcov);;All Files (*)"),
431 )
432 if ok:
433 cover, files = self.__prepareReportGeneration()
434 cover.lcov_report(morfs=files, outfile=filename, ignore_errors=True)
435
436 def __erase(self):
437 """
438 Private slot to handle the erase context menu action.
439
440 This method erases the collected coverage data that is
441 stored in the .coverage file.
442 """
443 cover = Coverage(data_file=self.cfn)
444 cover.load()
445 cover.erase()
446
447 self.reloadButton.setEnabled(False)
448 self.resultList.clear()
449 self.summaryList.clear()
450
451 @pyqtSlot()
452 def on_reloadButton_clicked(self):
453 """
454 Private slot to reload the coverage info.
455 """
456 self.reload = True
457 excludePattern = self.excludeCombo.currentText()
458 if excludePattern in self.excludeList:
459 self.excludeList.remove(excludePattern)
460 self.excludeList.insert(0, excludePattern)
461 self.start(self.__cfn, self.__fn)
462
463 @pyqtSlot(QTreeWidgetItem, int)
464 def on_resultList_itemActivated(self, item, column):
465 """
466 Private slot to handle the activation of an item.
467
468 @param item reference to the activated item (QTreeWidgetItem)
469 @param column column the item was activated in (integer)
470 """
471 self.__openFile(item)

eric ide

mercurial