|
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) |