|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2003 - 2022 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing a dialog to display profile data. |
|
8 """ |
|
9 |
|
10 import os |
|
11 import pickle # secok |
|
12 import time |
|
13 |
|
14 from PyQt6.QtCore import Qt |
|
15 from PyQt6.QtWidgets import ( |
|
16 QDialog, QDialogButtonBox, QMenu, QHeaderView, QTreeWidgetItem, |
|
17 QApplication |
|
18 ) |
|
19 |
|
20 from EricWidgets import EricMessageBox |
|
21 |
|
22 from .Ui_PyProfileDialog import Ui_PyProfileDialog |
|
23 import Utilities |
|
24 |
|
25 |
|
26 class ProfileTreeWidgetItem(QTreeWidgetItem): |
|
27 """ |
|
28 Class implementing a custom QTreeWidgetItem to allow sorting on numeric |
|
29 values. |
|
30 """ |
|
31 def __getNC(self, itm): |
|
32 """ |
|
33 Private method to get the value to compare on for the first column. |
|
34 |
|
35 @param itm item to operate on (ProfileTreeWidgetItem) |
|
36 @return comparison value for the first column (integer) |
|
37 """ |
|
38 s = itm.text(0) |
|
39 return int(s.split('/')[0]) |
|
40 |
|
41 def __lt__(self, other): |
|
42 """ |
|
43 Special method to check, if the item is less than the other one. |
|
44 |
|
45 @param other reference to item to compare against |
|
46 (ProfileTreeWidgetItem) |
|
47 @return true, if this item is less than other (boolean) |
|
48 """ |
|
49 column = self.treeWidget().sortColumn() |
|
50 if column == 0: |
|
51 return self.__getNC(self) < self.__getNC(other) |
|
52 if column == 6: |
|
53 return int(self.text(column)) < int(other.text(column)) |
|
54 return self.text(column) < other.text(column) |
|
55 |
|
56 |
|
57 class PyProfileDialog(QDialog, Ui_PyProfileDialog): |
|
58 """ |
|
59 Class implementing a dialog to display the results of a profiling run. |
|
60 """ |
|
61 def __init__(self, parent=None): |
|
62 """ |
|
63 Constructor |
|
64 |
|
65 @param parent parent widget (QWidget) |
|
66 """ |
|
67 super().__init__(parent) |
|
68 self.setupUi(self) |
|
69 self.setWindowFlags(Qt.WindowType.Window) |
|
70 |
|
71 self.buttonBox.button( |
|
72 QDialogButtonBox.StandardButton.Close).setEnabled(False) |
|
73 self.buttonBox.button( |
|
74 QDialogButtonBox.StandardButton.Cancel).setDefault(True) |
|
75 |
|
76 self.cancelled = False |
|
77 self.exclude = True |
|
78 self.ericpath = os.path.dirname( |
|
79 os.path.dirname(os.path.abspath(__file__))) |
|
80 self.pyLibPath = Utilities.getPythonLibPath() |
|
81 |
|
82 self.summaryList.headerItem().setText( |
|
83 self.summaryList.columnCount(), "") |
|
84 self.resultList.headerItem().setText(self.resultList.columnCount(), "") |
|
85 self.resultList.header().setSortIndicator( |
|
86 0, Qt.SortOrder.DescendingOrder) |
|
87 |
|
88 self.__menu = QMenu(self) |
|
89 self.filterItm = self.__menu.addAction( |
|
90 self.tr('Exclude Python Library'), |
|
91 self.__filter) |
|
92 self.__menu.addSeparator() |
|
93 self.__menu.addAction( |
|
94 self.tr('Erase Profiling Info'), self.__eraseProfile) |
|
95 self.__menu.addAction( |
|
96 self.tr('Erase Timing Info'), self.__eraseTiming) |
|
97 self.__menu.addSeparator() |
|
98 self.__menu.addAction(self.tr('Erase All Infos'), self.__eraseAll) |
|
99 self.resultList.setContextMenuPolicy( |
|
100 Qt.ContextMenuPolicy.CustomContextMenu) |
|
101 self.resultList.customContextMenuRequested.connect( |
|
102 self.__showContextMenu) |
|
103 self.summaryList.setContextMenuPolicy( |
|
104 Qt.ContextMenuPolicy.CustomContextMenu) |
|
105 self.summaryList.customContextMenuRequested.connect( |
|
106 self.__showContextMenu) |
|
107 |
|
108 def __createResultItem(self, calls, totalTime, totalTimePerCall, |
|
109 cumulativeTime, cumulativeTimePerCall, file, line, |
|
110 functionName): |
|
111 """ |
|
112 Private method to create an entry in the result list. |
|
113 |
|
114 @param calls number of calls (integer) |
|
115 @param totalTime total time (double) |
|
116 @param totalTimePerCall total time per call (double) |
|
117 @param cumulativeTime cumulative time (double) |
|
118 @param cumulativeTimePerCall cumulative time per call (double) |
|
119 @param file filename of file (string) |
|
120 @param line linenumber (integer) |
|
121 @param functionName function name (string) |
|
122 """ |
|
123 itm = ProfileTreeWidgetItem(self.resultList, [ |
|
124 calls, |
|
125 "{0: 8.3f}".format(totalTime), |
|
126 totalTimePerCall, |
|
127 "{0: 8.3f}".format(cumulativeTime), |
|
128 cumulativeTimePerCall, |
|
129 file, |
|
130 str(line), |
|
131 functionName |
|
132 ]) |
|
133 for col in [0, 1, 2, 3, 4, 6]: |
|
134 itm.setTextAlignment(col, Qt.AlignmentFlag.AlignRight) |
|
135 |
|
136 def __createSummaryItem(self, label, contents): |
|
137 """ |
|
138 Private method to create an entry in the summary list. |
|
139 |
|
140 @param label text of the first column (string) |
|
141 @param contents text of the second column (string) |
|
142 """ |
|
143 itm = QTreeWidgetItem(self.summaryList, [label, contents]) |
|
144 itm.setTextAlignment(1, Qt.AlignmentFlag.AlignRight) |
|
145 |
|
146 def __resortResultList(self): |
|
147 """ |
|
148 Private method to resort the tree. |
|
149 """ |
|
150 self.resultList.sortItems(self.resultList.sortColumn(), |
|
151 self.resultList.header() |
|
152 .sortIndicatorOrder()) |
|
153 |
|
154 def __populateLists(self, exclude=False): |
|
155 """ |
|
156 Private method used to populate the listviews. |
|
157 |
|
158 @param exclude flag indicating whether files residing in the |
|
159 Python library should be excluded |
|
160 """ |
|
161 self.resultList.clear() |
|
162 self.summaryList.clear() |
|
163 |
|
164 self.checkProgress.setMaximum(len(self.stats)) |
|
165 QApplication.processEvents() |
|
166 |
|
167 total_calls = 0 |
|
168 prim_calls = 0 |
|
169 total_tt = 0 |
|
170 |
|
171 try: |
|
172 # disable updates of the list for speed |
|
173 self.resultList.setUpdatesEnabled(False) |
|
174 self.resultList.setSortingEnabled(False) |
|
175 |
|
176 # now go through all the files |
|
177 now = time.monotonic() |
|
178 for progress, (func, (cc, nc, tt, ct, _callers)) in enumerate( |
|
179 list(self.stats.items()), start=1 |
|
180 ): |
|
181 if self.cancelled: |
|
182 return |
|
183 |
|
184 if ( |
|
185 not (self.ericpath and |
|
186 func[0].startswith(self.ericpath)) and |
|
187 not func[0].startswith("DebugClients") and |
|
188 func[0] != "profile" and |
|
189 not (exclude and (func[0].startswith(self.pyLibPath) or |
|
190 func[0] == "") |
|
191 ) and |
|
192 (self.file is None or |
|
193 func[0].startswith(self.file) or |
|
194 func[0].startswith(self.pyLibPath)) |
|
195 ): |
|
196 # calculate the totals |
|
197 total_calls += nc |
|
198 prim_calls += cc |
|
199 total_tt += tt |
|
200 |
|
201 if nc != cc: |
|
202 c = "{0:d}/{1:d}".format(nc, cc) |
|
203 else: |
|
204 c = str(nc) |
|
205 if nc == 0: |
|
206 tpc = "{0: 8.3f}".format(0.0) |
|
207 else: |
|
208 tpc = "{0: 8.3f}".format(tt / nc) |
|
209 if cc == 0: |
|
210 cpc = "{0: 8.3f}".format(0.0) |
|
211 else: |
|
212 cpc = "{0: 8.3f}".format(ct / cc) |
|
213 self.__createResultItem(c, tt, tpc, ct, cpc, func[0], |
|
214 func[1], func[2]) |
|
215 |
|
216 self.checkProgress.setValue(progress) |
|
217 if time.monotonic() - now > 0.01: |
|
218 QApplication.processEvents() |
|
219 now = time.monotonic() |
|
220 finally: |
|
221 # reenable updates of the list |
|
222 self.resultList.setSortingEnabled(True) |
|
223 self.resultList.setUpdatesEnabled(True) |
|
224 self.__resortResultList() |
|
225 |
|
226 # now do the summary stuff |
|
227 self.__createSummaryItem(self.tr("function calls"), |
|
228 str(total_calls)) |
|
229 if total_calls != prim_calls: |
|
230 self.__createSummaryItem(self.tr("primitive calls"), |
|
231 str(prim_calls)) |
|
232 self.__createSummaryItem(self.tr("CPU seconds"), |
|
233 "{0:.3f}".format(total_tt)) |
|
234 |
|
235 def start(self, pfn, fn=None): |
|
236 """ |
|
237 Public slot to start the calculation of the profile data. |
|
238 |
|
239 @param pfn basename of the profiling file (string) |
|
240 @param fn file to display the profiling data for (string) |
|
241 """ |
|
242 self.basename = os.path.splitext(pfn)[0] |
|
243 |
|
244 fname = "{0}.profile".format(self.basename) |
|
245 if not os.path.exists(fname): |
|
246 EricMessageBox.warning( |
|
247 self, |
|
248 self.tr("Profile Results"), |
|
249 self.tr("""<p>There is no profiling data""" |
|
250 """ available for <b>{0}</b>.</p>""") |
|
251 .format(pfn)) |
|
252 self.close() |
|
253 return |
|
254 try: |
|
255 with open(fname, 'rb') as f: |
|
256 self.stats = pickle.load(f) # secok |
|
257 except (OSError, pickle.PickleError, EOFError): |
|
258 EricMessageBox.critical( |
|
259 self, |
|
260 self.tr("Loading Profiling Data"), |
|
261 self.tr("""<p>The profiling data could not be""" |
|
262 """ read from file <b>{0}</b>.</p>""") |
|
263 .format(fname)) |
|
264 self.close() |
|
265 return |
|
266 |
|
267 self.file = fn |
|
268 self.__populateLists() |
|
269 self.__finish() |
|
270 |
|
271 def __finish(self): |
|
272 """ |
|
273 Private slot called when the action finished or the user pressed the |
|
274 button. |
|
275 """ |
|
276 self.cancelled = True |
|
277 self.buttonBox.button( |
|
278 QDialogButtonBox.StandardButton.Close).setEnabled(True) |
|
279 self.buttonBox.button( |
|
280 QDialogButtonBox.StandardButton.Cancel).setEnabled(False) |
|
281 self.buttonBox.button( |
|
282 QDialogButtonBox.StandardButton.Close).setDefault(True) |
|
283 QApplication.processEvents() |
|
284 self.resultList.header().resizeSections( |
|
285 QHeaderView.ResizeMode.ResizeToContents) |
|
286 self.resultList.header().setStretchLastSection(True) |
|
287 self.summaryList.header().resizeSections( |
|
288 QHeaderView.ResizeMode.ResizeToContents) |
|
289 self.summaryList.header().setStretchLastSection(True) |
|
290 |
|
291 def __unfinish(self): |
|
292 """ |
|
293 Private slot called to revert the effects of the __finish slot. |
|
294 """ |
|
295 self.cancelled = False |
|
296 self.buttonBox.button( |
|
297 QDialogButtonBox.StandardButton.Close).setEnabled(False) |
|
298 self.buttonBox.button( |
|
299 QDialogButtonBox.StandardButton.Cancel).setEnabled(True) |
|
300 self.buttonBox.button( |
|
301 QDialogButtonBox.StandardButton.Cancel).setDefault(True) |
|
302 |
|
303 def on_buttonBox_clicked(self, button): |
|
304 """ |
|
305 Private slot called by a button of the button box clicked. |
|
306 |
|
307 @param button button that was clicked (QAbstractButton) |
|
308 """ |
|
309 if button == self.buttonBox.button( |
|
310 QDialogButtonBox.StandardButton.Close |
|
311 ): |
|
312 self.close() |
|
313 elif button == self.buttonBox.button( |
|
314 QDialogButtonBox.StandardButton.Cancel |
|
315 ): |
|
316 self.__finish() |
|
317 |
|
318 def __showContextMenu(self, coord): |
|
319 """ |
|
320 Private slot to show the context menu of the listview. |
|
321 |
|
322 @param coord the position of the mouse pointer (QPoint) |
|
323 """ |
|
324 self.__menu.popup(self.mapToGlobal(coord)) |
|
325 |
|
326 def __eraseProfile(self): |
|
327 """ |
|
328 Private slot to handle the Erase Profile context menu action. |
|
329 """ |
|
330 fname = "{0}.profile".format(self.basename) |
|
331 if os.path.exists(fname): |
|
332 os.remove(fname) |
|
333 |
|
334 def __eraseTiming(self): |
|
335 """ |
|
336 Private slot to handle the Erase Timing context menu action. |
|
337 """ |
|
338 fname = "{0}.timings".format(self.basename) |
|
339 if os.path.exists(fname): |
|
340 os.remove(fname) |
|
341 |
|
342 def __eraseAll(self): |
|
343 """ |
|
344 Private slot to handle the Erase All context menu action. |
|
345 """ |
|
346 self.__eraseProfile() |
|
347 self.__eraseTiming() |
|
348 |
|
349 def __filter(self): |
|
350 """ |
|
351 Private slot to handle the Exclude/Include Python Library context menu |
|
352 action. |
|
353 """ |
|
354 self.__unfinish() |
|
355 if self.exclude: |
|
356 self.exclude = False |
|
357 self.filterItm.setText(self.tr('Include Python Library')) |
|
358 self.__populateLists(True) |
|
359 else: |
|
360 self.exclude = True |
|
361 self.filterItm.setText(self.tr('Exclude Python Library')) |
|
362 self.__populateLists(False) |
|
363 self.__finish() |