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