Started implementing the Python AST Viewer.

Sun, 07 Apr 2019 19:55:21 +0200

author
Detlev Offenbach <detlev@die-offenbachs.de>
date
Sun, 07 Apr 2019 19:55:21 +0200
changeset 6931
faac36ec9d76
parent 6930
33c98cea84f0
child 6932
8a3df4c6ac9a

Started implementing the Python AST Viewer.

Plugins/ViewManagerPlugins/Listspace/Listspace.py file | annotate | diff | comparison | revisions
Plugins/ViewManagerPlugins/Tabview/Tabview.py file | annotate | diff | comparison | revisions
UI/PythonAstViewer.py file | annotate | diff | comparison | revisions
UI/UserInterface.py file | annotate | diff | comparison | revisions
ViewManager/ViewManager.py file | annotate | diff | comparison | revisions
icons/default/astTree.png file | annotate | diff | comparison | revisions
--- a/Plugins/ViewManagerPlugins/Listspace/Listspace.py	Sat Apr 06 16:33:49 2019 +0200
+++ b/Plugins/ViewManagerPlugins/Listspace/Listspace.py	Sun Apr 07 19:55:21 2019 +0200
@@ -171,6 +171,8 @@
     @signal syntaxerrorToggled(Editor) emitted when a syntax error is toggled.
     @signal previewStateChanged(bool) emitted to signal a change in the
         preview state
+    @signal previewStateChanged(bool) emitted to signal a change in the
+        preview state
     @signal editorLanguageChanged(Editor) emitted to signal a change of an
         editors language
     @signal editorTextChanged(Editor) emitted to signal a change of an
@@ -196,6 +198,7 @@
     bookmarkToggled = pyqtSignal(Editor)
     syntaxerrorToggled = pyqtSignal(Editor)
     previewStateChanged = pyqtSignal(bool)
+    astViewerStateChanged = pyqtSignal(bool)
     editorLanguageChanged = pyqtSignal(Editor)
     editorTextChanged = pyqtSignal(Editor)
     editorLineChanged = pyqtSignal(str, int)
--- a/Plugins/ViewManagerPlugins/Tabview/Tabview.py	Sat Apr 06 16:33:49 2019 +0200
+++ b/Plugins/ViewManagerPlugins/Tabview/Tabview.py	Sun Apr 07 19:55:21 2019 +0200
@@ -764,6 +764,8 @@
     @signal syntaxerrorToggled(Editor) emitted when a syntax error is toggled.
     @signal previewStateChanged(bool) emitted to signal a change in the
         preview state
+    @signal previewStateChanged(bool) emitted to signal a change in the
+        preview state
     @signal editorLanguageChanged(Editor) emitted to signal a change of an
         editors language
     @signal editorTextChanged(Editor) emitted to signal a change of an
@@ -789,6 +791,7 @@
     bookmarkToggled = pyqtSignal(Editor)
     syntaxerrorToggled = pyqtSignal(Editor)
     previewStateChanged = pyqtSignal(bool)
+    astViewerStateChanged = pyqtSignal(bool)
     editorLanguageChanged = pyqtSignal(Editor)
     editorTextChanged = pyqtSignal(Editor)
     editorLineChanged = pyqtSignal(str, int)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/UI/PythonAstViewer.py	Sun Apr 07 19:55:21 2019 +0200
@@ -0,0 +1,420 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (c) 2019 Detlev Offenbach <detlev@die-offenbachs.de>
+#
+
+"""
+Module implementing a widget to visualize the Python AST for some Python
+sources.
+"""
+
+from __future__ import unicode_literals
+
+try:
+    str = unicode
+except NameError:
+    pass
+
+import ast
+
+from PyQt5.QtCore import Qt, QTimer
+from PyQt5.QtGui import QCursor, QBrush
+from PyQt5.QtWidgets import QTreeWidget, QApplication, QTreeWidgetItem, \
+    QAbstractItemView, QWidget, QVBoxLayout
+
+from ThirdParty.asttokens.asttokens import ASTTokens
+
+
+# TODO: highlight code area in editor when a tree node is clicked
+# TODO: jump to node when a double click in the editor is detected
+#       (rebuild the tree, if the source code has changed)
+class PythonAstViewer(QWidget):
+    """
+    Class implementing a widget to visualize the Python AST for some Python
+    sources.
+    """
+    StartLineRole = Qt.UserRole
+    StartIndexRole = Qt.UserRole + 1
+    EndLineRole = Qt.UserRole + 2
+    EndIndexRole = Qt.UserRole + 3
+    
+    def __init__(self, viewmanager, parent=None):
+        """
+        Constructor
+        
+        @param viewmanager reference to the viewmanager object
+        @type ViewManager
+        @param parent reference to the parent widget
+        @type QWidget
+        """
+        super(PythonAstViewer, self).__init__(parent)
+        
+        self.__layout = QVBoxLayout(self)
+        self.setLayout(self.__layout)
+        self.__astWidget = QTreeWidget(self)
+        self.__layout.addWidget(self.__astWidget)
+        self.__layout.setContentsMargins(0, 0, 0, 0)
+        
+        self.__vm = viewmanager
+        self.__vmConnected = False
+        
+        self.__editor = None
+        self.__source = ""
+        
+        self.__astWidget.setHeaderLabels([self.tr("Node"),
+                                          self.tr("Code Range")])
+        self.__astWidget.setSortingEnabled(False)
+        self.__astWidget.setSelectionBehavior(QAbstractItemView.SelectRows)
+        self.__astWidget.setSelectionMode(QAbstractItemView.SingleSelection)
+        self.__astWidget.setAlternatingRowColors(True)
+        
+        self.__vm.astViewerStateChanged.connect(self.__astViewerStateChanged)
+        
+        self.hide()
+    
+    def __editorChanged(self, editor):
+        """
+        Private slot to handle a change of the current editor.
+        
+        @param editor reference to the current editor
+        @type Editor
+        """
+        if editor is not self.__editor:
+            self.__editor = editor
+            if self.__editor:
+                self.__loadAST()
+    
+    def __editorSaved(self, editor):
+        """
+        Private slot to reload the AST after the connected editor was saved.
+        
+        @param editor reference to the editor that performed a save action
+        @type Editor
+        """
+        if editor and editor is self.__editor:
+            self.__loadAST()
+    
+    def __lastEditorClosed(self):
+        """
+        Private slot to handle the last editor closed signal of the view
+        manager.
+        """
+        self.hide()
+    
+    def show(self):
+        """
+        Public slot to show the AST viewer.
+        """
+        super(PythonAstViewer, self).show()
+        
+        if not self.__vmConnected:
+            self.__vm.editorChangedEd.connect(self.__editorChanged)
+            self.__vm.editorSavedEd.connect(self.__editorSaved)
+            self.__vmConnected = True
+    
+    def hide(self):
+        """
+        Public slot to hide the AST viewer.
+        """
+        super(PythonAstViewer, self).hide()
+        
+        if self.__vmConnected:
+            self.__vm.editorChangedEd.disconnect(self.__editorChanged)
+            self.__vm.editorSavedEd.disconnect(self.__editorSaved)
+            self.__vmConnected = False
+    
+    def shutdown(self):
+        """
+        Public method to perform shutdown actions.
+        """
+        self.__editor = None
+    
+    def __astViewerStateChanged(self, on):
+        """
+        Private slot to toggle the display of the AST viewer.
+        
+        @param on flag indicating to show the AST
+        @type bool
+        """
+        editor = self.__vm.activeWindow()
+        if on and editor and editor.isPyFile():
+            if editor is not self.__editor:
+                self.__editor = editor
+            self.show()
+            self.__loadAST()
+        else:
+            self.__editor = None
+            self.hide()
+    
+    def __createErrorItem(self, error):
+        """
+        Private method to create a top level error item.
+        
+        @param error error message
+        @type str
+        @return generated item
+        @rtype QTreeWidgetItem
+        """
+        itm = QTreeWidgetItem(self.__astWidget, [error])
+        itm.setFirstColumnSpanned(True)
+        itm.setForeground(0, QBrush(Qt.red))
+        return itm
+    
+    def __loadAST(self):
+        """
+        Private method to generate the AST from the source of the current
+        editor and visualize it.
+        """
+        if not self.__editor:
+            return
+        
+        self.__astWidget.clear()
+        
+        if not self.__editor.isPyFile():
+            self.__createErrorItem(self.tr(
+                "The current editor text does not contain Python source."
+            ))
+            return
+        
+        source = self.__editor.text()
+        if not source.strip():
+            # empty editor or white space only
+            return
+        
+        QApplication.setOverrideCursor(QCursor(Qt.WaitCursor))
+        try:
+            # generate the AST
+            root = ast.parse(source, self.__editor.getFileName(), "exec")
+            self.__markTextRanges(root, source)
+            astValid = True
+        except Exception as exc:
+            self.__createErrorItem(str(exc))
+            astValid = False
+        
+        if astValid:
+            self.setUpdatesEnabled(False)
+            
+            # populate the AST tree
+            self.__populateNode(self.tr("Module"), root, self.__astWidget)
+            self.__selectItemForEditorSelection()
+            QTimer.singleShot(0, self.__resizeColumns)
+            
+            self.setUpdatesEnabled(True)
+        
+        QApplication.restoreOverrideCursor()
+    
+    def __populateNode(self, name, nodeOrFields, parent):
+        """
+        Private method to populate the tree view with a node.
+        
+        @param name name of the node
+        @type str
+        @param nodeOrFields reference to the node or a list node fields
+        @type ast.AST or list
+        @param parent reference to the parent item
+        @type QTreeWidget or QTreeWidgetItem
+        """
+        if isinstance(nodeOrFields, ast.AST):
+            fields = [(key, val) for key, val in ast.iter_fields(nodeOrFields)]
+            value = nodeOrFields.__class__.__name__
+        elif isinstance(nodeOrFields, list):
+            fields = list(enumerate(nodeOrFields))
+            if len(nodeOrFields) == 0:
+                value = "[]"
+            else:
+                value = "[...]"
+        else:
+            fields = []
+            value = repr(nodeOrFields)
+        
+        text = self.tr("{0}: {1}").format(name, value)
+        itm = QTreeWidgetItem(parent, [text])
+        itm.setExpanded(True)
+        
+        if hasattr(nodeOrFields, "lineno") and \
+           hasattr(nodeOrFields, "col_offset"):
+            itm.setData(0, self.StartLineRole, nodeOrFields.lineno)
+            itm.setData(0, self.StartIndexRole, nodeOrFields.col_offset)
+            startStr = self.tr("{0},{1}").format(
+                nodeOrFields.lineno, nodeOrFields.col_offset)
+            endStr = ""
+            
+            if hasattr(nodeOrFields, "end_lineno") and \
+               hasattr(nodeOrFields, "end_col_offset"):
+                itm.setData(0, self.EndLineRole, nodeOrFields.end_lineno)
+                itm.setData(0, self.EndIndexRole,
+                            nodeOrFields.end_col_offset)
+                endStr = self.tr("{0},{1}").format(
+                    nodeOrFields.end_lineno, nodeOrFields.end_col_offset)
+            else:
+                itm.setData(0, self.EndLineRole, nodeOrFields.lineno)
+                itm.setData(0, self.EndIndexRole,
+                            nodeOrFields.col_offset + 1)
+            if endStr:
+                rangeStr = self.tr("{0}  -  {1}").format(startStr, endStr)
+            else:
+                rangeStr = startStr
+            
+            itm.setText(1, rangeStr)
+        
+        for fieldName, fieldValue in fields:
+            self.__populateNode(fieldName, fieldValue, itm)
+    
+    def __markTextRanges(self, tree, source):
+        """
+        Private method to modify the AST nodes with end_lineno and
+        end_col_offset information.
+        
+        Note: The modifications are only done for nodes containing lineno and
+        col_offset attributes.
+        
+        @param tree reference to the AST to be modified
+        @type ast.AST
+        @param source source code the AST was created from
+        @type str
+        """
+        ASTTokens(source, tree=tree)
+        for child in ast.walk(tree):
+            if hasattr(child, 'last_token'):
+                child.end_lineno, child.end_col_offset = child.last_token.end
+                if hasattr(child, 'lineno'):
+                    # Fixes problems with some nodes like binop
+                    child.lineno, child.col_offset = child.first_token.start
+    
+    def __findClosestContainingNode(self, node, textRange):
+        """
+        Private method to search for the AST node that contains a range
+        closest.
+        
+        @param node AST node to start searching at
+        @type ast.AST
+        @param textRange tuple giving the start and end positions
+        @type tuple of (int, int, int, int)
+        @return best matching node
+        @rtype ast.AST
+        """
+        if textRange == (-1, -1, -1, -1):
+            # no valid range, i.e. no selection
+            return None
+        
+        # first look among children
+        for child in ast.iter_child_nodes(node):
+            result = self.__findClosestContainingNode(child, textRange)
+            if result is not None:
+                return result
+        
+        # no suitable child was found
+        if hasattr(node, "lineno") and self.__rangeContainsSmaller(
+            (node.lineno, node.col_offset, node.end_lineno,
+             node.end_col_offset), textRange):
+            return node
+        else:
+            # nope
+            return None
+    
+    def __findClosestContainingItem(self, itm, textRange):
+        """
+        Private method to search for the tree item that contains a range
+        closest.
+        
+        @param itm tree item to start searching at
+        @type QTreeWidgetItem
+        @param textRange tuple giving the start and end positions
+        @type tuple of (int, int, int, int)
+        @return best matching tree item
+        @rtype QTreeWidgetItem
+        """
+        if textRange == (-1, -1, -1, -1):
+            # no valid range, i.e. no selection
+            return None
+        
+        # first look among children
+        for index in range(itm.childCount()):
+            child = itm.child(index)
+            result = self.__findClosestContainingItem(child, textRange)
+            if result is not None:
+                return result
+        
+        # no suitable child was found
+        lineno = itm.data(0, self.StartLineRole)
+        if lineno is not None and self.__rangeContainsSmaller(
+           (itm.data(0, self.StartLineRole), itm.data(0, self.StartIndexRole),
+            itm.data(0, self.EndLineRole), itm.data(0, self.EndIndexRole)),
+           textRange):
+            return itm
+        else:
+            # nope
+            return None
+    
+    def __resizeColumns(self):
+        """
+        Private method to resize the columns to suitable values.
+        """
+        for col in range(self.__astWidget.columnCount()):
+            self.__astWidget.resizeColumnToContents(col)
+        
+        rangeSize = self.__astWidget.columnWidth(1) + 10
+        # 10 px extra for the range
+        nodeSize = max(400, self.__astWidget.viewport().width() - rangeSize)
+        self.__astWidget.setColumnWidth(0, nodeSize)
+        self.__astWidget.setColumnWidth(1, rangeSize)
+    
+    def resizeEvent(self, evt):
+        """
+        Protected method to handle resize events.
+        
+        @param evt resize event
+        @type QResizeEvent
+        """
+        # just adjust the sizes of the columns
+        self.__resizeColumns()
+    
+    def __rangeContainsSmaller(self, first, second):
+        """
+        Private method to check, if second is contained in first.
+        
+        @param first text range to check against
+        @type tuple of (int, int, int, int)
+        @param second text range to check for
+        @type tuple of (int, int, int, int)
+        @return flag indicating second is contained in first
+        @rtype bool
+        """
+        firstStart = first[:2]
+        firstEnd = first[2:]
+        secondStart = second[:2]
+        secondEnd = second[2:]
+
+        return (
+            (firstStart < secondStart and firstEnd > secondEnd) or
+            (firstStart == secondStart and firstEnd > secondEnd) or
+            (firstStart < secondStart and firstEnd == secondEnd)
+        )
+    
+    def __clearSelection(self):
+        """
+        Private method to clear all selected items.
+        """
+        for itm in self.__astWidget.selectedItems():
+            itm.setSelected(False)
+    
+    def __selectItemForEditorSelection(self):
+        """
+        Private slot to select the item corresponding to an editor selection.
+        """
+        # step 1: clear all selected items
+        self.__clearSelection()
+        
+        # step 2: retrieve the editor selection
+        selection = self.__editor.getSelection()
+        # make the line numbers 1-based
+        selection = (selection[0] + 1, selection[1],
+                     selection[2] + 1, selection[3])
+        
+        # step 3: search the corresponding item, scroll to it and select it
+        itm = self.__findClosestContainingItem(
+            self.__astWidget.topLevelItem(0), selection)
+        if itm:
+            self.__astWidget.scrollToItem(
+                itm, QAbstractItemView.PositionAtCenter)
+            itm.setSelected(True)
--- a/UI/UserInterface.py	Sat Apr 06 16:33:49 2019 +0200
+++ b/UI/UserInterface.py	Sun Apr 07 19:55:21 2019 +0200
@@ -757,6 +757,12 @@
         self.__previewer = Previewer(self.viewmanager, splitter)
         splitter.addWidget(self.__previewer)
         
+        # Create AST viewer
+        logging.debug("Creating Python AST Viewer")
+        from .PythonAstViewer import PythonAstViewer
+        self.__astViewer = PythonAstViewer(self.viewmanager, splitter)
+        splitter.addWidget(self.__astViewer)
+        
         # Create layout with toolbox windows embedded in dock windows
         if self.__layoutType == "Toolboxes":
             logging.debug("Creating toolboxes...")
@@ -2872,6 +2878,14 @@
                 self.tr("Code Documentation Viewer"),
                 self.activateCodeDocumentationViewer)
         self.__menus["subwindow"].addAction(self.debugViewerActivateAct)
+        if self.pipWidget is not None:
+            self.__menus["subwindow"].addAction(
+                self.tr("PyPI"),
+                self.__activatePipWidget)
+        if self.condaWidget is not None:
+            self.__menus["subwindow"].addAction(
+                self.tr("Conda"),
+                self.__activateCondaWidget)
         if self.cooperation is not None:
             self.__menus["subwindow"].addAction(
                 self.cooperationViewerActivateAct)
@@ -4449,6 +4463,36 @@
                 self.codeDocumentationViewer.setFocus(
                     Qt.ActiveWindowFocusReason)
     
+    def __activatePipWidget(self):
+        """
+        Private slot to handle the activation of the PyPI manager widget.
+        """
+        if self.pipWidget is not None:
+            if self.__layoutType == "Toolboxes":
+                self.rToolboxDock.show()
+                self.rToolbox.setCurrentWidget(self.pipWidget)
+            elif self.__layoutType == "Sidebars":
+                self.rightSidebar.show()
+                self.rightSidebar.setCurrentWidget(self.pipWidget)
+            else:
+                self.pipWidget.show()
+            self.pipWidget.setFocus(Qt.ActiveWindowFocusReason)
+    
+    def __activateCondaWidget(self):
+        """
+        Private slot to handle the activation of the Conda manager widget.
+        """
+        if self.condaWidget is not None:
+            if self.__layoutType == "Toolboxes":
+                self.rToolboxDock.show()
+                self.rToolbox.setCurrentWidget(self.condaWidget)
+            elif self.__layoutType == "Sidebars":
+                self.rightSidebar.show()
+                self.rightSidebar.setCurrentWidget(self.condaWidget)
+            else:
+                self.condaWidget.show()
+            self.condaWidget.setFocus(Qt.ActiveWindowFocusReason)
+    
     def __toggleWindow(self, w):
         """
         Private method to toggle a workspace editor window.
@@ -6623,6 +6667,8 @@
         
         sessionCreated = self.__writeSession()
         
+        self.__astViewer.hide()
+        
         if not self.project.closeProject():
             return False
         
@@ -6640,6 +6686,8 @@
         
         self.__previewer.shutdown()
         
+        self.__astViewer.shutdown()
+        
         self.shell.closeShell()
         
         self.__writeTasks()
--- a/ViewManager/ViewManager.py	Sat Apr 06 16:33:49 2019 +0200
+++ b/ViewManager/ViewManager.py	Sun Apr 07 19:55:21 2019 +0200
@@ -114,6 +114,8 @@
     @signal syntaxerrorToggled(Editor) emitted when a syntax error is toggled
     @signal previewStateChanged(bool) emitted to signal a change in the
         preview state
+    @signal astViewerStateChanged(bool) emitted to signal a change in the
+        AST viewer state
     @signal editorLanguageChanged(Editor) emitted to signal a change of an
         editor's language
     @signal editorTextChanged(Editor) emitted to signal a change of an
@@ -139,6 +141,7 @@
     bookmarkToggled = pyqtSignal(Editor)
     syntaxerrorToggled = pyqtSignal(Editor)
     previewStateChanged = pyqtSignal(bool)
+    astViewerStateChanged = pyqtSignal(bool)
     editorLanguageChanged = pyqtSignal(Editor)
     editorTextChanged = pyqtSignal(Editor)
     editorLineChanged = pyqtSignal(str, int)
@@ -3745,6 +3748,23 @@
         self.previewAct.toggled[bool].connect(self.__previewEditor)
         self.viewActions.append(self.previewAct)
         
+        self.astViewerAct = E5Action(
+            QCoreApplication.translate('ViewManager', 'Python AST Viewer'),
+            UI.PixmapCache.getIcon("astTree"),
+            QCoreApplication.translate('ViewManager', 'Python AST Viewer'),
+            0, 0, self, 'vm_python_ast_viewer', True)
+        self.astViewerAct.setStatusTip(QCoreApplication.translate(
+            'ViewManager', 'Show the AST for the current Python file'))
+        self.astViewerAct.setWhatsThis(QCoreApplication.translate(
+            'ViewManager',
+            """<b>Python AST Viewer</b>"""
+            """<p>This opens the a tree view of the AST of the current"""
+            """ Python source file.</p>"""
+        ))
+        self.astViewerAct.setChecked(False)
+        self.astViewerAct.toggled[bool].connect(self.__astViewer)
+        self.viewActions.append(self.astViewerAct)
+        
         self.viewActGrp.setEnabled(False)
         self.viewFoldActGrp.setEnabled(False)
         self.unhighlightAct.setEnabled(False)
@@ -3754,6 +3774,7 @@
         self.nextSplitAct.setEnabled(False)
         self.prevSplitAct.setEnabled(False)
         self.previewAct.setEnabled(True)
+        self.astViewerAct.setEnabled(False)
         self.newDocumentViewAct.setEnabled(False)
         self.newDocumentSplitViewAct.setEnabled(False)
         
@@ -3774,6 +3795,7 @@
         menu.addActions(self.viewFoldActGrp.actions())
         menu.addSeparator()
         menu.addAction(self.previewAct)
+        menu.addAction(self.astViewerAct)
         menu.addSeparator()
         menu.addAction(self.unhighlightAct)
         menu.addSeparator()
@@ -3806,6 +3828,7 @@
         tb.addActions(self.viewActGrp.actions())
         tb.addSeparator()
         tb.addAction(self.previewAct)
+        tb.addAction(self.astViewerAct)
         tb.addSeparator()
         tb.addAction(self.newDocumentViewAct)
         if self.canSplit():
@@ -6148,6 +6171,14 @@
         Preferences.setUI("ShowFilePreview", checked)
         self.previewStateChanged.emit(checked)
     
+    def __astViewer(self, checked):
+        """
+        Private slot to handle a change of the AST Viewer selection state.
+        
+        @param checked state of the action (boolean)
+        """
+        self.astViewerStateChanged.emit(checked)
+    
     ##################################################################
     ## Below are the action methods for the macro menu
     ##################################################################
@@ -6616,6 +6647,7 @@
         self.splitViewAct.setEnabled(False)
         self.splitOrientationAct.setEnabled(False)
         self.previewAct.setEnabled(True)
+        self.astViewerAct.setEnabled(False)
         self.macroActGrp.setEnabled(False)
         self.bookmarkActGrp.setEnabled(False)
         self.__enableSpellingActions()
@@ -6634,6 +6666,9 @@
         self.__searchWidget.hide()
         self.__replaceWidget.hide()
         
+        # hide the AST Viewer via its action
+        self.astViewerAct.setChecked(False)
+        
     def __editorOpened(self):
         """
         Private slot to handle the editorOpened signal.
@@ -6658,6 +6693,7 @@
         self.macroActGrp.setEnabled(True)
         self.bookmarkActGrp.setEnabled(True)
         self.__enableSpellingActions()
+        self.astViewerAct.setEnabled(True)
         
         # activate the autosave timer
         if not self.autosaveTimer.isActive() and \
Binary file icons/default/astTree.png has changed

eric ide

mercurial