eric6/UI/PythonAstViewer.py

changeset 6942
2602857055c5
parent 6932
8a3df4c6ac9a
child 7192
a22eee00b052
equal deleted inserted replaced
6941:f99d60d6b59b 6942:2602857055c5
1 # -*- coding: utf-8 -*-
2
3 # Copyright (c) 2019 Detlev Offenbach <detlev@die-offenbachs.de>
4 #
5
6 """
7 Module implementing a widget to visualize the Python AST for some Python
8 sources.
9 """
10
11 from __future__ import unicode_literals
12
13 try:
14 str = unicode
15 except NameError:
16 pass
17
18 import ast
19
20 from PyQt5.QtCore import pyqtSlot, Qt, QTimer
21 from PyQt5.QtGui import QCursor, QBrush
22 from PyQt5.QtWidgets import QTreeWidget, QApplication, QTreeWidgetItem, \
23 QAbstractItemView, QWidget, QVBoxLayout
24
25 from ThirdParty.asttokens.asttokens import ASTTokens
26
27
28 class PythonAstViewer(QWidget):
29 """
30 Class implementing a widget to visualize the Python AST for some Python
31 sources.
32 """
33 StartLineRole = Qt.UserRole
34 StartIndexRole = Qt.UserRole + 1
35 EndLineRole = Qt.UserRole + 2
36 EndIndexRole = Qt.UserRole + 3
37
38 def __init__(self, viewmanager, parent=None):
39 """
40 Constructor
41
42 @param viewmanager reference to the viewmanager object
43 @type ViewManager
44 @param parent reference to the parent widget
45 @type QWidget
46 """
47 super(PythonAstViewer, self).__init__(parent)
48
49 self.__layout = QVBoxLayout(self)
50 self.setLayout(self.__layout)
51 self.__astWidget = QTreeWidget(self)
52 self.__layout.addWidget(self.__astWidget)
53 self.__layout.setContentsMargins(0, 0, 0, 0)
54
55 self.__vm = viewmanager
56 self.__vmConnected = False
57
58 self.__editor = None
59 self.__source = ""
60
61 self.__astWidget.setHeaderLabels([self.tr("Node"),
62 self.tr("Code Range")])
63 self.__astWidget.setSortingEnabled(False)
64 self.__astWidget.setSelectionBehavior(QAbstractItemView.SelectRows)
65 self.__astWidget.setSelectionMode(QAbstractItemView.SingleSelection)
66 self.__astWidget.setAlternatingRowColors(True)
67
68 self.__astWidget.itemClicked.connect(self.__astItemClicked)
69
70 self.__vm.astViewerStateChanged.connect(self.__astViewerStateChanged)
71
72 self.hide()
73
74 def __editorChanged(self, editor):
75 """
76 Private slot to handle a change of the current editor.
77
78 @param editor reference to the current editor
79 @type Editor
80 """
81 if editor is not self.__editor:
82 if self.__editor:
83 self.__editor.clearAllHighlights()
84 self.__editor = editor
85 if self.__editor:
86 self.__loadAST()
87
88 def __editorSaved(self, editor):
89 """
90 Private slot to reload the AST after the connected editor was 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.__loadAST()
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.__loadAST)
113 else:
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 AST viewer.
128 """
129 super(PythonAstViewer, 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 AST viewer.
140 """
141 super(PythonAstViewer, 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 __astViewerStateChanged(self, on):
160 """
161 Private slot to toggle the display of the AST viewer.
162
163 @param on flag indicating to show the AST
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.__loadAST()
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.__astWidget, [error])
186 itm.setFirstColumnSpanned(True)
187 itm.setForeground(0, QBrush(Qt.red))
188 return itm
189
190 def __loadAST(self):
191 """
192 Private method to generate the AST from the source of the current
193 editor and visualize it.
194 """
195 if not self.__editor:
196 return
197
198 self.__astWidget.clear()
199 self.__editor.clearAllHighlights()
200
201 if not self.__editor.isPyFile():
202 self.__createErrorItem(self.tr(
203 "The current editor text does not contain Python source."
204 ))
205 return
206
207 source = self.__editor.text()
208 if not source.strip():
209 # empty editor or white space only
210 return
211
212 QApplication.setOverrideCursor(QCursor(Qt.WaitCursor))
213 try:
214 # generate the AST
215 root = ast.parse(source, self.__editor.getFileName(), "exec")
216 self.__markTextRanges(root, source)
217 astValid = True
218 except Exception as exc:
219 self.__createErrorItem(str(exc))
220 astValid = False
221
222 if astValid:
223 self.setUpdatesEnabled(False)
224
225 # populate the AST tree
226 self.__populateNode(self.tr("Module"), root, self.__astWidget)
227 self.__selectItemForEditorSelection()
228 QTimer.singleShot(0, self.__resizeColumns)
229
230 self.setUpdatesEnabled(True)
231
232 QApplication.restoreOverrideCursor()
233
234 self.__grabFocus()
235
236 def __populateNode(self, name, nodeOrFields, parent):
237 """
238 Private method to populate the tree view with a node.
239
240 @param name name of the node
241 @type str
242 @param nodeOrFields reference to the node or a list node fields
243 @type ast.AST or list
244 @param parent reference to the parent item
245 @type QTreeWidget or QTreeWidgetItem
246 """
247 if isinstance(nodeOrFields, ast.AST):
248 fields = [(key, val) for key, val in ast.iter_fields(nodeOrFields)]
249 value = nodeOrFields.__class__.__name__
250 elif isinstance(nodeOrFields, list):
251 fields = list(enumerate(nodeOrFields))
252 if len(nodeOrFields) == 0:
253 value = "[]"
254 else:
255 value = "[...]"
256 else:
257 fields = []
258 value = repr(nodeOrFields)
259
260 text = self.tr("{0}: {1}").format(name, value)
261 itm = QTreeWidgetItem(parent, [text])
262 itm.setExpanded(True)
263
264 if hasattr(nodeOrFields, "lineno") and \
265 hasattr(nodeOrFields, "col_offset"):
266 itm.setData(0, self.StartLineRole, nodeOrFields.lineno)
267 itm.setData(0, self.StartIndexRole, nodeOrFields.col_offset)
268 startStr = self.tr("{0},{1}").format(
269 nodeOrFields.lineno, nodeOrFields.col_offset)
270 endStr = ""
271
272 if hasattr(nodeOrFields, "end_lineno") and \
273 hasattr(nodeOrFields, "end_col_offset"):
274 itm.setData(0, self.EndLineRole, nodeOrFields.end_lineno)
275 itm.setData(0, self.EndIndexRole,
276 nodeOrFields.end_col_offset)
277 endStr = self.tr("{0},{1}").format(
278 nodeOrFields.end_lineno, nodeOrFields.end_col_offset)
279 else:
280 itm.setData(0, self.EndLineRole, nodeOrFields.lineno)
281 itm.setData(0, self.EndIndexRole,
282 nodeOrFields.col_offset + 1)
283 if endStr:
284 rangeStr = self.tr("{0} - {1}").format(startStr, endStr)
285 else:
286 rangeStr = startStr
287
288 itm.setText(1, rangeStr)
289
290 for fieldName, fieldValue in fields:
291 self.__populateNode(fieldName, fieldValue, itm)
292
293 def __markTextRanges(self, tree, source):
294 """
295 Private method to modify the AST nodes with end_lineno and
296 end_col_offset information.
297
298 Note: The modifications are only done for nodes containing lineno and
299 col_offset attributes.
300
301 @param tree reference to the AST to be modified
302 @type ast.AST
303 @param source source code the AST was created from
304 @type str
305 """
306 ASTTokens(source, tree=tree)
307 for child in ast.walk(tree):
308 if hasattr(child, 'last_token'):
309 child.end_lineno, child.end_col_offset = child.last_token.end
310 if hasattr(child, 'lineno'):
311 # Fixes problems with some nodes like binop
312 child.lineno, child.col_offset = child.first_token.start
313
314 def __findClosestContainingNode(self, node, textRange):
315 """
316 Private method to search for the AST node that contains a range
317 closest.
318
319 @param node AST node to start searching at
320 @type ast.AST
321 @param textRange tuple giving the start and end positions
322 @type tuple of (int, int, int, int)
323 @return best matching node
324 @rtype ast.AST
325 """
326 if textRange in [(-1, -1, -1, -1), (0, -1, 0, -1)]:
327 # no valid range, i.e. no selection
328 return None
329
330 # first look among children
331 for child in ast.iter_child_nodes(node):
332 result = self.__findClosestContainingNode(child, textRange)
333 if result is not None:
334 return result
335
336 # no suitable child was found
337 if hasattr(node, "lineno") and self.__rangeContainsSmaller(
338 (node.lineno, node.col_offset, node.end_lineno,
339 node.end_col_offset), textRange):
340 return node
341 else:
342 # nope
343 return None
344
345 def __findClosestContainingItem(self, itm, textRange):
346 """
347 Private method to search for the tree item that contains a range
348 closest.
349
350 @param itm tree item to start searching at
351 @type QTreeWidgetItem
352 @param textRange tuple giving the start and end positions
353 @type tuple of (int, int, int, int)
354 @return best matching tree item
355 @rtype QTreeWidgetItem
356 """
357 if textRange in [(-1, -1, -1, -1), (0, -1, 0, -1)]:
358 # no valid range, i.e. no selection
359 return None
360
361 lineno = itm.data(0, self.StartLineRole)
362 if lineno is not None and not self.__rangeContainsSmallerOrEqual(
363 (itm.data(0, self.StartLineRole), itm.data(0, self.StartIndexRole),
364 itm.data(0, self.EndLineRole), itm.data(0, self.EndIndexRole)),
365 textRange):
366 return None
367
368 # first look among children
369 for index in range(itm.childCount()):
370 child = itm.child(index)
371 result = self.__findClosestContainingItem(child, textRange)
372 if result is not None:
373 return result
374
375 # no suitable child was found
376 lineno = itm.data(0, self.StartLineRole)
377 if lineno is not None and self.__rangeContainsSmallerOrEqual(
378 (itm.data(0, self.StartLineRole), itm.data(0, self.StartIndexRole),
379 itm.data(0, self.EndLineRole), itm.data(0, self.EndIndexRole)),
380 textRange):
381 return itm
382 else:
383 # nope
384 return None
385
386 def __resizeColumns(self):
387 """
388 Private method to resize the columns to suitable values.
389 """
390 for col in range(self.__astWidget.columnCount()):
391 self.__astWidget.resizeColumnToContents(col)
392
393 rangeSize = self.__astWidget.columnWidth(1) + 10
394 # 10 px extra for the range
395 nodeSize = max(400, self.__astWidget.viewport().width() - rangeSize)
396 self.__astWidget.setColumnWidth(0, nodeSize)
397 self.__astWidget.setColumnWidth(1, rangeSize)
398
399 def resizeEvent(self, evt):
400 """
401 Protected method to handle resize events.
402
403 @param evt resize event
404 @type QResizeEvent
405 """
406 # just adjust the sizes of the columns
407 self.__resizeColumns()
408
409 def __rangeContainsSmaller(self, first, second):
410 """
411 Private method to check, if second is contained in first.
412
413 @param first text range to check against
414 @type tuple of (int, int, int, int)
415 @param second text range to check for
416 @type tuple of (int, int, int, int)
417 @return flag indicating second is contained in first
418 @rtype bool
419 """
420 firstStart = first[:2]
421 firstEnd = first[2:]
422 secondStart = second[:2]
423 secondEnd = second[2:]
424
425 return (
426 (firstStart < secondStart and firstEnd > secondEnd) or
427 (firstStart == secondStart and firstEnd > secondEnd) or
428 (firstStart < secondStart and firstEnd == secondEnd)
429 )
430
431 def __rangeContainsSmallerOrEqual(self, first, second):
432 """
433 Private method to check, if second is contained in or equal to first.
434
435 @param first text range to check against
436 @type tuple of (int, int, int, int)
437 @param second text range to check for
438 @type tuple of (int, int, int, int)
439 @return flag indicating second is contained in or equal to first
440 @rtype bool
441 """
442 return first == second or self.__rangeContainsSmaller(first, second)
443
444 def __clearSelection(self):
445 """
446 Private method to clear all selected items.
447 """
448 for itm in self.__astWidget.selectedItems():
449 itm.setSelected(False)
450
451 def __selectItemForEditorSelection(self):
452 """
453 Private slot to select the item corresponding to an editor selection.
454 """
455 # step 1: clear all selected items
456 self.__clearSelection()
457
458 # step 2: retrieve the editor selection
459 selection = self.__editor.getSelection()
460 # make the line numbers 1-based
461 selection = (selection[0] + 1, selection[1],
462 selection[2] + 1, selection[3])
463
464 # step 3: search the corresponding item, scroll to it and select it
465 itm = self.__findClosestContainingItem(
466 self.__astWidget.topLevelItem(0), selection)
467 if itm:
468 self.__astWidget.scrollToItem(
469 itm, QAbstractItemView.PositionAtCenter)
470 itm.setSelected(True)
471
472 def __grabFocus(self):
473 """
474 Private method to grab the input focus.
475 """
476 self.__astWidget.setFocus(Qt.OtherFocusReason)
477
478 @pyqtSlot(QTreeWidgetItem, int)
479 def __astItemClicked(self, itm, column):
480 """
481 Private slot handling a user click on an AST node item.
482
483 @param itm reference to the clicked item
484 @type QTreeWidgetItem
485 @param column column number of the click
486 @type int
487 """
488 self.__editor.clearAllHighlights()
489
490 if itm is not None:
491 startLine = itm.data(0, self.StartLineRole)
492 if startLine is not None:
493 startIndex = itm.data(0, self.StartIndexRole)
494 endLine = itm.data(0, self.EndLineRole)
495 endIndex = itm.data(0, self.EndIndexRole)
496
497 self.__editor.gotoLine(startLine, firstVisible=True,
498 expand=True)
499 self.__editor.setHighlight(startLine - 1, startIndex,
500 endLine - 1, endIndex)

eric ide

mercurial