src/eric7/DataViews/PyCoverageDialog.py

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

eric ide

mercurial