|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2003 - 2019 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing a Python code coverage dialog. |
|
8 """ |
|
9 |
|
10 from __future__ import unicode_literals |
|
11 |
|
12 import os |
|
13 |
|
14 from PyQt5.QtCore import pyqtSlot, Qt |
|
15 from PyQt5.QtWidgets import QDialog, QDialogButtonBox, QMenu, QHeaderView, \ |
|
16 QTreeWidgetItem, QApplication |
|
17 |
|
18 from E5Gui import E5MessageBox |
|
19 from E5Gui.E5Application import e5App |
|
20 from E5Gui.E5ProgressDialog import E5ProgressDialog |
|
21 |
|
22 from .Ui_PyCoverageDialog import Ui_PyCoverageDialog |
|
23 |
|
24 import Utilities |
|
25 from coverage import Coverage |
|
26 from coverage.misc import CoverageException |
|
27 |
|
28 |
|
29 class PyCoverageDialog(QDialog, Ui_PyCoverageDialog): |
|
30 """ |
|
31 Class implementing a dialog to display the collected code coverage data. |
|
32 """ |
|
33 def __init__(self, parent=None): |
|
34 """ |
|
35 Constructor |
|
36 |
|
37 @param parent parent widget (QWidget) |
|
38 """ |
|
39 super(PyCoverageDialog, self).__init__(parent) |
|
40 self.setupUi(self) |
|
41 self.setWindowFlags(Qt.Window) |
|
42 |
|
43 self.buttonBox.button(QDialogButtonBox.Close).setEnabled(False) |
|
44 self.buttonBox.button(QDialogButtonBox.Cancel).setDefault(True) |
|
45 |
|
46 self.summaryList.headerItem().setText( |
|
47 self.summaryList.columnCount(), "") |
|
48 self.resultList.headerItem().setText(self.resultList.columnCount(), "") |
|
49 |
|
50 self.cancelled = False |
|
51 self.path = '.' |
|
52 self.reload = False |
|
53 |
|
54 self.excludeList = ['# *pragma[: ]*[nN][oO] *[cC][oO][vV][eE][rR]'] |
|
55 |
|
56 self.__menu = QMenu(self) |
|
57 self.__menu.addSeparator() |
|
58 self.openAct = self.__menu.addAction( |
|
59 self.tr("Open"), self.__openFile) |
|
60 self.__menu.addSeparator() |
|
61 self.annotate = self.__menu.addAction( |
|
62 self.tr('Annotate'), self.__annotate) |
|
63 self.__menu.addAction(self.tr('Annotate all'), self.__annotateAll) |
|
64 self.__menu.addAction( |
|
65 self.tr('Delete annotated files'), self.__deleteAnnotated) |
|
66 self.__menu.addSeparator() |
|
67 self.__menu.addAction(self.tr('Erase Coverage Info'), self.__erase) |
|
68 self.resultList.setContextMenuPolicy(Qt.CustomContextMenu) |
|
69 self.resultList.customContextMenuRequested.connect( |
|
70 self.__showContextMenu) |
|
71 |
|
72 def __format_lines(self, lines): |
|
73 """ |
|
74 Private method to format a list of integers into string by coalescing |
|
75 groups. |
|
76 |
|
77 @param lines list of integers |
|
78 @return string representing the list |
|
79 """ |
|
80 pairs = [] |
|
81 lines.sort() |
|
82 maxValue = lines[-1] |
|
83 start = None |
|
84 |
|
85 i = lines[0] |
|
86 while i <= maxValue: |
|
87 try: |
|
88 if start is None: |
|
89 start = i |
|
90 ind = lines.index(i) |
|
91 end = i |
|
92 i += 1 |
|
93 except ValueError: |
|
94 pairs.append((start, end)) |
|
95 start = None |
|
96 if ind + 1 >= len(lines): |
|
97 break |
|
98 i = lines[ind + 1] |
|
99 if start: |
|
100 pairs.append((start, end)) |
|
101 |
|
102 def stringify(pair): |
|
103 """ |
|
104 Private helper function to generate a string representation of a |
|
105 pair. |
|
106 |
|
107 @param pair pair of integers |
|
108 """ |
|
109 start, end = pair |
|
110 if start == end: |
|
111 return "{0:d}".format(start) |
|
112 else: |
|
113 return "{0:d}-{1:d}".format(start, end) |
|
114 |
|
115 return ", ".join(map(stringify, pairs)) |
|
116 |
|
117 def __createResultItem(self, file, statements, executed, coverage, |
|
118 excluded, missing): |
|
119 """ |
|
120 Private method to create an entry in the result list. |
|
121 |
|
122 @param file filename of file (string) |
|
123 @param statements amount of statements (integer) |
|
124 @param executed amount of executed statements (integer) |
|
125 @param coverage percent of coverage (integer) |
|
126 @param excluded list of excluded lines (string) |
|
127 @param missing list of lines without coverage (string) |
|
128 """ |
|
129 itm = QTreeWidgetItem(self.resultList, [ |
|
130 file, |
|
131 str(statements), |
|
132 str(executed), |
|
133 "{0:.0f}%".format(coverage), |
|
134 excluded, |
|
135 missing |
|
136 ]) |
|
137 for col in range(1, 4): |
|
138 itm.setTextAlignment(col, Qt.AlignRight) |
|
139 if statements != executed: |
|
140 font = itm.font(0) |
|
141 font.setBold(True) |
|
142 for col in range(itm.columnCount()): |
|
143 itm.setFont(col, font) |
|
144 |
|
145 def start(self, cfn, fn): |
|
146 """ |
|
147 Public slot to start the coverage data evaluation. |
|
148 |
|
149 @param cfn basename of the coverage file (string) |
|
150 @param fn file or list of files or directory to be checked |
|
151 (string or list of strings) |
|
152 """ |
|
153 self.__cfn = cfn |
|
154 self.__fn = fn |
|
155 |
|
156 self.basename = os.path.splitext(cfn)[0] |
|
157 |
|
158 self.cfn = "{0}.coverage".format(self.basename) |
|
159 |
|
160 if isinstance(fn, list): |
|
161 files = fn |
|
162 self.path = os.path.dirname(cfn) |
|
163 elif os.path.isdir(fn): |
|
164 files = Utilities.direntries(fn, True, '*.py', False) |
|
165 self.path = fn |
|
166 else: |
|
167 files = [fn] |
|
168 self.path = os.path.dirname(cfn) |
|
169 files.sort() |
|
170 |
|
171 cover = Coverage(data_file=self.cfn) |
|
172 cover.load() |
|
173 |
|
174 # set the exclude pattern |
|
175 self.excludeCombo.clear() |
|
176 self.excludeCombo.addItems(self.excludeList) |
|
177 |
|
178 self.checkProgress.setMaximum(len(files)) |
|
179 QApplication.processEvents() |
|
180 |
|
181 total_statements = 0 |
|
182 total_executed = 0 |
|
183 total_exceptions = 0 |
|
184 |
|
185 cover.exclude(self.excludeList[0]) |
|
186 progress = 0 |
|
187 |
|
188 try: |
|
189 # disable updates of the list for speed |
|
190 self.resultList.setUpdatesEnabled(False) |
|
191 self.resultList.setSortingEnabled(False) |
|
192 |
|
193 # now go through all the files |
|
194 for file in files: |
|
195 if self.cancelled: |
|
196 return |
|
197 |
|
198 try: |
|
199 statements, excluded, missing, readable = \ |
|
200 cover.analysis2(file)[1:] |
|
201 readableEx = (excluded and self.__format_lines(excluded) or |
|
202 '') |
|
203 n = len(statements) |
|
204 m = n - len(missing) |
|
205 if n > 0: |
|
206 pc = 100.0 * m / n |
|
207 else: |
|
208 pc = 100.0 |
|
209 self.__createResultItem( |
|
210 file, str(n), str(m), pc, readableEx, readable) |
|
211 |
|
212 total_statements = total_statements + n |
|
213 total_executed = total_executed + m |
|
214 except CoverageException: |
|
215 total_exceptions += 1 |
|
216 |
|
217 progress += 1 |
|
218 self.checkProgress.setValue(progress) |
|
219 QApplication.processEvents() |
|
220 finally: |
|
221 # reenable updates of the list |
|
222 self.resultList.setSortingEnabled(True) |
|
223 self.resultList.setUpdatesEnabled(True) |
|
224 self.checkProgress.reset() |
|
225 |
|
226 # show summary info |
|
227 if len(files) > 1: |
|
228 if total_statements > 0: |
|
229 pc = 100.0 * total_executed / total_statements |
|
230 else: |
|
231 pc = 100.0 |
|
232 itm = QTreeWidgetItem(self.summaryList, [ |
|
233 str(total_statements), |
|
234 str(total_executed), |
|
235 "{0:.0f}%".format(pc) |
|
236 ]) |
|
237 for col in range(0, 3): |
|
238 itm.setTextAlignment(col, Qt.AlignRight) |
|
239 else: |
|
240 self.summaryGroup.hide() |
|
241 |
|
242 if total_exceptions: |
|
243 E5MessageBox.warning( |
|
244 self, |
|
245 self.tr("Parse Error"), |
|
246 self.tr("""%n file(s) could not be parsed. Coverage""" |
|
247 """ info for these is not available.""", "", |
|
248 total_exceptions)) |
|
249 |
|
250 self.__finish() |
|
251 |
|
252 def __finish(self): |
|
253 """ |
|
254 Private slot called when the action finished or the user pressed the |
|
255 button. |
|
256 """ |
|
257 self.cancelled = True |
|
258 self.buttonBox.button(QDialogButtonBox.Close).setEnabled(True) |
|
259 self.buttonBox.button(QDialogButtonBox.Cancel).setEnabled(False) |
|
260 self.buttonBox.button(QDialogButtonBox.Close).setDefault(True) |
|
261 QApplication.processEvents() |
|
262 self.resultList.header().resizeSections(QHeaderView.ResizeToContents) |
|
263 self.resultList.header().setStretchLastSection(True) |
|
264 self.summaryList.header().resizeSections(QHeaderView.ResizeToContents) |
|
265 self.summaryList.header().setStretchLastSection(True) |
|
266 |
|
267 def on_buttonBox_clicked(self, button): |
|
268 """ |
|
269 Private slot called by a button of the button box clicked. |
|
270 |
|
271 @param button button that was clicked (QAbstractButton) |
|
272 """ |
|
273 if button == self.buttonBox.button(QDialogButtonBox.Close): |
|
274 self.close() |
|
275 elif button == self.buttonBox.button(QDialogButtonBox.Cancel): |
|
276 self.__finish() |
|
277 |
|
278 def __showContextMenu(self, coord): |
|
279 """ |
|
280 Private slot to show the context menu of the listview. |
|
281 |
|
282 @param coord the position of the mouse pointer (QPoint) |
|
283 """ |
|
284 itm = self.resultList.itemAt(coord) |
|
285 if itm: |
|
286 self.annotate.setEnabled(True) |
|
287 self.openAct.setEnabled(True) |
|
288 else: |
|
289 self.annotate.setEnabled(False) |
|
290 self.openAct.setEnabled(False) |
|
291 self.__menu.popup(self.mapToGlobal(coord)) |
|
292 |
|
293 def __openFile(self, itm=None): |
|
294 """ |
|
295 Private slot to open the selected file. |
|
296 |
|
297 @param itm reference to the item to be opened (QTreeWidgetItem) |
|
298 """ |
|
299 if itm is None: |
|
300 itm = self.resultList.currentItem() |
|
301 fn = itm.text(0) |
|
302 |
|
303 vm = e5App().getObject("ViewManager") |
|
304 vm.openSourceFile(fn) |
|
305 editor = vm.getOpenEditor(fn) |
|
306 editor.codeCoverageShowAnnotations() |
|
307 |
|
308 def __annotate(self): |
|
309 """ |
|
310 Private slot to handle the annotate context menu action. |
|
311 |
|
312 This method produce an annotated coverage file of the |
|
313 selected file. |
|
314 """ |
|
315 itm = self.resultList.currentItem() |
|
316 fn = itm.text(0) |
|
317 |
|
318 cover = Coverage(data_file=self.cfn) |
|
319 cover.exclude(self.excludeList[0]) |
|
320 cover.load() |
|
321 cover.annotate([fn], None, True) |
|
322 |
|
323 def __annotateAll(self): |
|
324 """ |
|
325 Private slot to handle the annotate all context menu action. |
|
326 |
|
327 This method produce an annotated coverage file of every |
|
328 file listed in the listview. |
|
329 """ |
|
330 amount = self.resultList.topLevelItemCount() |
|
331 if amount == 0: |
|
332 return |
|
333 |
|
334 # get list of all filenames |
|
335 files = [] |
|
336 for index in range(amount): |
|
337 itm = self.resultList.topLevelItem(index) |
|
338 files.append(itm.text(0)) |
|
339 |
|
340 cover = Coverage(data_file=self.cfn) |
|
341 cover.exclude(self.excludeList[0]) |
|
342 cover.load() |
|
343 |
|
344 # now process them |
|
345 progress = E5ProgressDialog( |
|
346 self.tr("Annotating files..."), self.tr("Abort"), |
|
347 0, len(files), self.tr("%v/%m Files"), self) |
|
348 progress.setMinimumDuration(0) |
|
349 progress.setWindowTitle(self.tr("Coverage")) |
|
350 count = 0 |
|
351 |
|
352 for file in files: |
|
353 progress.setValue(count) |
|
354 if progress.wasCanceled(): |
|
355 break |
|
356 cover.annotate([file], None) # , True) |
|
357 count += 1 |
|
358 |
|
359 progress.setValue(len(files)) |
|
360 |
|
361 def __erase(self): |
|
362 """ |
|
363 Private slot to handle the erase context menu action. |
|
364 |
|
365 This method erases the collected coverage data that is |
|
366 stored in the .coverage file. |
|
367 """ |
|
368 cover = Coverage(data_file=self.cfn) |
|
369 cover.load() |
|
370 cover.erase() |
|
371 |
|
372 self.reloadButton.setEnabled(False) |
|
373 self.resultList.clear() |
|
374 self.summaryList.clear() |
|
375 |
|
376 def __deleteAnnotated(self): |
|
377 """ |
|
378 Private slot to handle the delete annotated context menu action. |
|
379 |
|
380 This method deletes all annotated files. These are files |
|
381 ending with ',cover'. |
|
382 """ |
|
383 files = Utilities.direntries(self.path, True, '*,cover', False) |
|
384 for file in files: |
|
385 try: |
|
386 os.remove(file) |
|
387 except EnvironmentError: |
|
388 pass |
|
389 |
|
390 @pyqtSlot() |
|
391 def on_reloadButton_clicked(self): |
|
392 """ |
|
393 Private slot to reload the coverage info. |
|
394 """ |
|
395 self.resultList.clear() |
|
396 self.summaryList.clear() |
|
397 self.reload = True |
|
398 excludePattern = self.excludeCombo.currentText() |
|
399 if excludePattern in self.excludeList: |
|
400 self.excludeList.remove(excludePattern) |
|
401 self.excludeList.insert(0, excludePattern) |
|
402 self.cancelled = False |
|
403 self.buttonBox.button(QDialogButtonBox.Close).setEnabled(False) |
|
404 self.buttonBox.button(QDialogButtonBox.Cancel).setEnabled(True) |
|
405 self.buttonBox.button(QDialogButtonBox.Cancel).setDefault(True) |
|
406 self.start(self.__cfn, self.__fn) |
|
407 |
|
408 @pyqtSlot(QTreeWidgetItem, int) |
|
409 def on_resultList_itemActivated(self, item, column): |
|
410 """ |
|
411 Private slot to handle the activation of an item. |
|
412 |
|
413 @param item reference to the activated item (QTreeWidgetItem) |
|
414 @param column column the item was activated in (integer) |
|
415 """ |
|
416 self.__openFile(item) |