|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2020 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing a widget to visualize the Python Disassembly for some |
|
8 Python sources. |
|
9 """ |
|
10 |
|
11 import os |
|
12 import dis |
|
13 |
|
14 from PyQt5.QtCore import pyqtSlot, Qt, QTimer |
|
15 from PyQt5.QtGui import QCursor, QBrush |
|
16 from PyQt5.QtWidgets import ( |
|
17 QTreeWidget, QApplication, QTreeWidgetItem, QAbstractItemView, QWidget, |
|
18 QVBoxLayout, QLabel |
|
19 ) |
|
20 |
|
21 |
|
22 class PythonDisViewer(QWidget): |
|
23 """ |
|
24 Class implementing a widget to visualize the Python Disassembly for some |
|
25 Python sources. |
|
26 """ |
|
27 StartLineRole = Qt.UserRole |
|
28 EndLineRole = Qt.UserRole + 1 |
|
29 |
|
30 def __init__(self, viewmanager, parent=None): |
|
31 """ |
|
32 Constructor |
|
33 |
|
34 @param viewmanager reference to the viewmanager object |
|
35 @type ViewManager |
|
36 @param parent reference to the parent widget |
|
37 @type QWidget |
|
38 """ |
|
39 super(PythonDisViewer, self).__init__(parent) |
|
40 |
|
41 self.__layout = QVBoxLayout(self) |
|
42 self.setLayout(self.__layout) |
|
43 self.__disWidget = QTreeWidget(self) |
|
44 self.__layout.addWidget(self.__disWidget) |
|
45 self.__layout.setContentsMargins(0, 0, 0, 0) |
|
46 |
|
47 self.__infoLabel = QLabel(self.tr( |
|
48 "italic: current instruction\n" |
|
49 "bold: labelled instruction" |
|
50 )) |
|
51 self.__layout.addWidget(self.__infoLabel) |
|
52 |
|
53 self.__vm = viewmanager |
|
54 self.__vmConnected = False |
|
55 |
|
56 self.__editor = None |
|
57 self.__source = "" |
|
58 |
|
59 self.__disWidget.setHeaderLabels( |
|
60 [self.tr("Line"), self.tr("Offset"), self.tr("Operation"), |
|
61 self.tr("Parameters"), self.tr("Interpreted Parameters")]) |
|
62 self.__disWidget.setSortingEnabled(False) |
|
63 self.__disWidget.setSelectionBehavior(QAbstractItemView.SelectRows) |
|
64 self.__disWidget.setSelectionMode(QAbstractItemView.SingleSelection) |
|
65 self.__disWidget.setAlternatingRowColors(True) |
|
66 |
|
67 self.__disWidget.itemClicked.connect(self.__disItemClicked) |
|
68 |
|
69 self.__vm.disViewerStateChanged.connect(self.__disViewerStateChanged) |
|
70 |
|
71 self.hide() |
|
72 |
|
73 def __editorChanged(self, editor): |
|
74 """ |
|
75 Private slot to handle a change of the current editor. |
|
76 |
|
77 @param editor reference to the current editor |
|
78 @type Editor |
|
79 """ |
|
80 if editor is not self.__editor: |
|
81 if self.__editor: |
|
82 self.__editor.clearAllHighlights() |
|
83 self.__editor = editor |
|
84 if self.__editor: |
|
85 self.__loadDIS() |
|
86 |
|
87 def __editorSaved(self, editor): |
|
88 """ |
|
89 Private slot to reload the Disassembly after the connected editor was |
|
90 saved. |
|
91 |
|
92 @param editor reference to the editor that performed a save action |
|
93 @type Editor |
|
94 """ |
|
95 if editor and editor is self.__editor: |
|
96 self.__loadDIS() |
|
97 |
|
98 def __editorDoubleClicked(self, editor, pos, buttons): |
|
99 """ |
|
100 Private slot to handle a mouse button double click in the editor. |
|
101 |
|
102 @param editor reference to the editor, that emitted the signal |
|
103 @type Editor |
|
104 @param pos position of the double click |
|
105 @type QPoint |
|
106 @param buttons mouse buttons that were double clicked |
|
107 @type Qt.MouseButtons |
|
108 """ |
|
109 if editor is self.__editor and buttons == Qt.LeftButton: |
|
110 if editor.isModified(): |
|
111 # reload the source |
|
112 QTimer.singleShot(0, self.__loadDIS) |
|
113 |
|
114 # highlight the corresponding entry |
|
115 ## QTimer.singleShot(0, self.__selectItemForEditorSelection) |
|
116 QTimer.singleShot(0, self.__grabFocus) |
|
117 |
|
118 def __lastEditorClosed(self): |
|
119 """ |
|
120 Private slot to handle the last editor closed signal of the view |
|
121 manager. |
|
122 """ |
|
123 self.hide() |
|
124 |
|
125 def show(self): |
|
126 """ |
|
127 Public slot to show the DIS viewer. |
|
128 """ |
|
129 super(PythonDisViewer, self).show() |
|
130 |
|
131 if not self.__vmConnected: |
|
132 self.__vm.editorChangedEd.connect(self.__editorChanged) |
|
133 self.__vm.editorSavedEd.connect(self.__editorSaved) |
|
134 self.__vm.editorDoubleClickedEd.connect(self.__editorDoubleClicked) |
|
135 self.__vmConnected = True |
|
136 |
|
137 def hide(self): |
|
138 """ |
|
139 Public slot to hide the DIS viewer. |
|
140 """ |
|
141 super(PythonDisViewer, self).hide() |
|
142 |
|
143 if self.__editor: |
|
144 self.__editor.clearAllHighlights() |
|
145 |
|
146 if self.__vmConnected: |
|
147 self.__vm.editorChangedEd.disconnect(self.__editorChanged) |
|
148 self.__vm.editorSavedEd.disconnect(self.__editorSaved) |
|
149 self.__vm.editorDoubleClickedEd.disconnect( |
|
150 self.__editorDoubleClicked) |
|
151 self.__vmConnected = False |
|
152 |
|
153 def shutdown(self): |
|
154 """ |
|
155 Public method to perform shutdown actions. |
|
156 """ |
|
157 self.__editor = None |
|
158 |
|
159 def __disViewerStateChanged(self, on): |
|
160 """ |
|
161 Private slot to toggle the display of the Disassembly viewer. |
|
162 |
|
163 @param on flag indicating to show the Disassembly |
|
164 @type bool |
|
165 """ |
|
166 editor = self.__vm.activeWindow() |
|
167 if on and editor and editor.isPyFile(): |
|
168 if editor is not self.__editor: |
|
169 self.__editor = editor |
|
170 self.show() |
|
171 self.__loadDIS() |
|
172 else: |
|
173 self.hide() |
|
174 self.__editor = None |
|
175 |
|
176 def __createErrorItem(self, error): |
|
177 """ |
|
178 Private method to create a top level error item. |
|
179 |
|
180 @param error error message |
|
181 @type str |
|
182 @return generated item |
|
183 @rtype QTreeWidgetItem |
|
184 """ |
|
185 itm = QTreeWidgetItem(self.__disWidget, [error]) |
|
186 itm.setFirstColumnSpanned(True) |
|
187 itm.setForeground(0, QBrush(Qt.red)) |
|
188 return itm |
|
189 |
|
190 def __createTitleItem(self, title, line): |
|
191 """ |
|
192 Private method to create a title item. |
|
193 |
|
194 @param title titel string for the item |
|
195 @type str |
|
196 @param line start line of the titled disassembly |
|
197 @type int |
|
198 @return generated item |
|
199 @rtype QTreeWidgetItem |
|
200 """ |
|
201 itm = QTreeWidgetItem(self.__disWidget, [title]) |
|
202 itm.setFirstColumnSpanned(True) |
|
203 itm.setExpanded(True) |
|
204 |
|
205 itm.setData(0, self.StartLineRole, line) |
|
206 itm.setData(0, self.EndLineRole, line) |
|
207 |
|
208 return itm |
|
209 |
|
210 def __createInstructionItem(self, instr, parent, lasti=-1): |
|
211 """ |
|
212 Private method to create an item for the given instruction. |
|
213 |
|
214 @param instr instruction the item should be based on |
|
215 @type dis.Instruction |
|
216 @param parent reference to the parent item |
|
217 @type QTreeWidgetItem |
|
218 @param lasti index of the instruction of a traceback |
|
219 @type int |
|
220 @return generated item |
|
221 @rtype QTreeWidgetItem |
|
222 """ |
|
223 fields = [] |
|
224 # Column: Source code line number (right aligned) |
|
225 if instr.starts_line: |
|
226 fields.append("{0:d}".format(instr.starts_line)) |
|
227 else: |
|
228 fields.append("") |
|
229 # Column: Instruction offset from start of code sequence |
|
230 # (right aligned) |
|
231 fields.append("{0:d}".format(instr.offset)) |
|
232 # Column: Opcode name |
|
233 fields.append(instr.opname) |
|
234 # Column: Opcode argument (right aligned) |
|
235 if instr.arg is not None: |
|
236 fields.append(repr(instr.arg)) |
|
237 # Column: Opcode argument details |
|
238 if instr.argrepr: |
|
239 fields.append('(' + instr.argrepr + ')') |
|
240 |
|
241 itm = QTreeWidgetItem(parent, fields) |
|
242 for col in (0, 1, 3): |
|
243 itm.setTextAlignment(col, Qt.AlignRight) |
|
244 font = itm.font(0) |
|
245 if instr.offset == lasti: |
|
246 font.setItalic(True) |
|
247 if instr.is_jump_target: |
|
248 font.setBold(True) |
|
249 for col in range(itm.columnCount()): |
|
250 itm.setFont(col, font) |
|
251 |
|
252 itm.setExpanded(True) |
|
253 |
|
254 if instr.starts_line: |
|
255 itm.setData(0, self.StartLineRole, instr.starts_line) |
|
256 itm.setData(0, self.EndLineRole, instr.starts_line) |
|
257 else: |
|
258 # get line from parent (= start line) |
|
259 lineno = parent.data(0, self.StartLineRole) |
|
260 itm.setData(0, self.StartLineRole, lineno) |
|
261 itm.setData(0, self.EndLineRole, lineno) |
|
262 return itm |
|
263 |
|
264 def __updateItemEndLine(self, itm): |
|
265 """ |
|
266 Private method to update an items end line based on its children. |
|
267 |
|
268 @param itm reference to the item to be updated |
|
269 @type QTreeWidgetItem |
|
270 """ |
|
271 if itm.childCount(): |
|
272 endLine = max( |
|
273 itm.child(index).data(0, self.EndLineRole) |
|
274 for index in range(itm.childCount()) |
|
275 ) |
|
276 else: |
|
277 endLine = itm.data(0, self.StartLineRole) |
|
278 itm.setData(0, self.EndLineRole, endLine) |
|
279 |
|
280 def __loadDIS(self): |
|
281 """ |
|
282 Private method to generate the Disassembly from the source of the |
|
283 current editor and visualize it. |
|
284 """ |
|
285 if not self.__editor: |
|
286 return |
|
287 |
|
288 self.__disWidget.clear() |
|
289 self.__editor.clearAllHighlights() |
|
290 |
|
291 if not self.__editor.isPyFile(): |
|
292 self.__createErrorItem(self.tr( |
|
293 "The current editor text does not contain Python source." |
|
294 )) |
|
295 return |
|
296 |
|
297 source = self.__editor.text() |
|
298 if not source.strip(): |
|
299 # empty editor or white space only |
|
300 return |
|
301 |
|
302 filename = self.__editor.getFileName() |
|
303 if not filename: |
|
304 filename = "<dis>" |
|
305 |
|
306 QApplication.setOverrideCursor(QCursor(Qt.WaitCursor)) |
|
307 try: |
|
308 codeObject = self.__tryCompile(source, filename) |
|
309 except Exception as exc: |
|
310 codeObject = None |
|
311 self.__createErrorItem(str(exc)) |
|
312 |
|
313 if codeObject: |
|
314 self.setUpdatesEnabled(False) |
|
315 |
|
316 self.__disassembleObject(codeObject, self.__disWidget, filename) |
|
317 QTimer.singleShot(0, self.__resizeColumns) |
|
318 |
|
319 self.setUpdatesEnabled(True) |
|
320 |
|
321 QApplication.restoreOverrideCursor() |
|
322 |
|
323 self.__grabFocus() |
|
324 |
|
325 def __resizeColumns(self): |
|
326 """ |
|
327 Private method to resize the columns to suitable values. |
|
328 """ |
|
329 for col in range(self.__disWidget.columnCount()): |
|
330 self.__disWidget.resizeColumnToContents(col) |
|
331 |
|
332 def resizeEvent(self, evt): |
|
333 """ |
|
334 Protected method to handle resize events. |
|
335 |
|
336 @param evt resize event |
|
337 @type QResizeEvent |
|
338 """ |
|
339 # just adjust the sizes of the columns |
|
340 self.__resizeColumns() |
|
341 |
|
342 def __grabFocus(self): |
|
343 """ |
|
344 Private method to grab the input focus. |
|
345 """ |
|
346 self.__disWidget.setFocus(Qt.OtherFocusReason) |
|
347 |
|
348 @pyqtSlot(QTreeWidgetItem, int) |
|
349 def __disItemClicked(self, itm, column): |
|
350 """ |
|
351 Private slot handling a user click on a Disassembly node item. |
|
352 |
|
353 @param itm reference to the clicked item |
|
354 @type QTreeWidgetItem |
|
355 @param column column number of the click |
|
356 @type int |
|
357 """ |
|
358 self.__editor.clearAllHighlights() |
|
359 |
|
360 if itm is not None: |
|
361 startLine = itm.data(0, self.StartLineRole) |
|
362 endLine = itm.data(0, self.EndLineRole) |
|
363 |
|
364 self.__editor.gotoLine(startLine, firstVisible=True, |
|
365 expand=True) |
|
366 self.__editor.setHighlight(startLine - 1, 0, endLine, -1) |
|
367 |
|
368 def __tryCompile(self, source, name): |
|
369 """ |
|
370 Private method to attempt to compile the given source, first as an |
|
371 expression and then as a statement if the first approach fails. |
|
372 |
|
373 @param source source code string to be compiled |
|
374 @type str |
|
375 @param name name of the file containing the source |
|
376 @type str |
|
377 @return compiled code |
|
378 @rtype code object |
|
379 """ |
|
380 try: |
|
381 c = compile(source, name, 'eval') |
|
382 except SyntaxError: |
|
383 c = compile(source, name, 'exec') |
|
384 return c |
|
385 |
|
386 def __disassembleObject(self, co, parentItem, name="", lasti=-1): |
|
387 """ |
|
388 Private method to disassemble the given code object recursively. |
|
389 |
|
390 @param co code object to be disassembled |
|
391 @type code object |
|
392 @param parentItem reference to the parent item |
|
393 @type QTreeWidget or QTreeWidgetItem |
|
394 @param name name of the code object |
|
395 @type str |
|
396 @param lasti index of the instruction of a traceback |
|
397 @type int |
|
398 """ |
|
399 if co.co_name == "<module>": |
|
400 title = ( |
|
401 self.tr("Disassembly of module '{0}'") |
|
402 .format(os.path.basename(co.co_filename)) |
|
403 ) |
|
404 else: |
|
405 title = ( |
|
406 self.tr("Disassembly of code object '{0}'") |
|
407 .format(co.co_name) |
|
408 ) |
|
409 titleItem = self.__createTitleItem(title, co.co_firstlineno) |
|
410 lastStartItem = None |
|
411 for instr in dis.get_instructions(co): |
|
412 if instr.starts_line: |
|
413 if lastStartItem: |
|
414 self.__updateItemEndLine(lastStartItem) |
|
415 lastStartItem = self.__createInstructionItem( |
|
416 instr, titleItem, lasti=lasti) |
|
417 else: |
|
418 self.__createInstructionItem(instr, lastStartItem, lasti=lasti) |
|
419 if lastStartItem: |
|
420 self.__updateItemEndLine(lastStartItem) |
|
421 |
|
422 self.__updateItemEndLine(titleItem) |
|
423 |
|
424 for x in co.co_consts: |
|
425 if hasattr(x, 'co_code'): |
|
426 self.__disassembleObject(x, self.__disWidget, lasti=lasti) |