Integrating macros with a GUI

Before we can start defining what we allow our users to do to Kalam, we need to build the core macro execution functionality. The first step is to make sure users can execute the contents of a document.

Executing the contents of a document

Unless you have skipped all previous occasions of creating and adding an action to the menubar and the toolbar, you will know by now how to do so. I have supplied the slot code that executes the contents of the currently active document. You will find the complete code in the kalamapp.py file that belongs to this chapter.

# kalamapp.py
...
class KalamApp(QMainWindow):
...
    # Macro slots
...
    def slotMacroExecuteDocument(self):
        if self.docManager.activeDocument() == None:
            QMessageBox.critical(self,
                                 "Kalam",
                                 "No document to execute as a macro ")
            return

        try:
            bytecode = compile(str(self.docManager.activeDocument().text()),
                               "<string>",
                               "exec")
        except Exception, e:
            QMessageBox.critical(self,
                                 "Kalam",
                                 "Could not compile " +
                                 self.docManager.activeDocument().title() +
                                 "\n" + str(e))
        try:
            exec bytecode # Note: we don't yet have a separate namespace
                          # for macro execution
        except Exception, e:
            QMessageBox.critical(self,
                                 "Kalam",
                                 "Error executing " +
                                 self.docManager.activeDocument().title() +
                                 "\n" + str(e))
...
      

We are being a bit careful here, and thus compile the code first to check for syntax errors. These, along with execution errors, will be shown in a dialog box. Note that anything you print from here will go to standard output—that is, a black hole if you run Kalam by activating an icon, or the terminal window if you run Kalam from the shell prompt. It would be a logical step to redirect any output to a fresh Kalam document (this is what Emacs does). It is quite easy to achieve. You can reassign the standard and error output channels to any object you want, as long as it has a write() function that accepts a string. We might want to add a write() function to KalamDoc.

The implementation of write() in KalamDoc is very simple:

# kalamdoc.py - fragment
...
    def write(self, text, view = None):
        self.text().append(text)
        self.emit(PYSIGNAL("sigDocTextChanged"),
                  (self._text, view))
      

Having done that, redirecting all output is easy:

        ...
    def slotMacroExecuteDocument(self):
        ...
            import sys
            document = self.docManager.createDocument(KalamDoc, KalamView)
            document.setTitle("Output of " + title)
            oldstdout = sys.stdout
            oldstderr = sys.stderr

            sys.stdout = document
            sys.stderr = document

            exec bytecode # Note: we don't yet have a separate namespace
                          # for macro execution

            sys.stdout = oldstdout
            sys.stderr = oldstderr
        ...
      

It is necessary to save the "real" standard output and standard error channels in order to be able to restore them when we are done printing to the output document. Otherwise all output, from anywhere inside Kalam, would go forever to that document, with nasty consequences if the user were to remove the document.

Until we create a namespace specially for executing macros, everything runs locally to the function that executes the macro. That is, you can use self to refer to the current instance of KalamApp.

Executing a bit of code from a document.

Of course, littering the KalamApp with macro execution code isn't the best of ideas. This leads us to the creation of a macro manager class, MacroManager, which keeps a dictionary of compiled code objects that can be executed at will. I won't show the unit tests here: it is available with the full source code.

"""
macromanager.py - manager class for macro administration and execution

copyright: (C) 2001, Boudewijn Rempt
email:     boud@rempt.xs4all.nl
"""

from qt import *
import sys

class MacroError(Exception):pass

class NoSuchMacroError(MacroError):

    def __init__(self, macro):
        ERR = "Macro %s is not installed"
        self.errorMessage = ERR % (macro)

    def __repr__(self):
        return self.errorMessage

    def __str__(self):
        return self.errorMessage


class CompilationError(MacroError):

    def __init__(self, macro, error):
        ERR = "Macro %s could not be compiled. Reason: %s"
        self.errorMessage = ERR % (macro, str(error))
        self.compilationError = error

    def __repr__(self):
        return self.errorMessage

    def __str__(self):
        return self.errorMessage

class ExecutionError(MacroError):

    def __init__(self, error):
        ERR = "Macro could not be executed. Reason: %s"
        self.errorMessage = ERR % (str(error))
        self.executionError = error

    def __repr__(self):
        return self.errorMessage

    def __str__(self):
        return self.errorMessage
      

First, a couple of exceptions are defined. We want to separate the GUI handling of problems with the macro from the actual execution, so that whenever something goes wrong, an exception is thrown.

class MacroAction(QAction):

    def __init__(self, code, *args):
        apply(QAction.__init__,(self,) + args)
        self.code = code
        self.bytecode = self.__compile(code)
        self.locations=[]
        self.connect(self,
                     SIGNAL("activated()"),
                     self.activated)

    def activated(self):
        self.emit(PYSIGNAL("activated"),(self,))

    def addTo(self, widget):
        apply(QAction.addTo,(self, widget))
        self.locations.append(widget)

    def removeFrom(self, widget):
        QAction.removeFrom(self, widget)
        del self.locations[widget]

    def remove(self):
        for widget in self.locations:
            self.removeFrom(widget)

    def __compile(self, code):
        try:
            bytecode = compile(code,
                               "<string>",
                               "exec")
            return bytecode
        except Exception, e:
            raise CompilationError(macroName, e)

    def execute(self, out, err, globals, locals):
        try:
            oldstdout = sys.stdout
            oldstderr = sys.stderr
            
            sys.stdout = out
            sys.stderr = err
            exec self.bytecode in globals
            sys.stdout = oldstdout
            sys.stderr = oldstderr
        except Exception, e:
            print e
            print sys.exc_info
            sys.stdout = oldstdout
            sys.stderr = oldstderr
            raise ExecutionError(e)
      

By encapsulating each macro in a QAction, it will become very easy to assign shortcut keys, menu items and toolbar buttons to a macro.

The MacroAction class also takes care of compilation and execution. The environment, consisting of the globals and locals dictionaries, is passed in the execute() function. We also pass two objects that replace the standard output and standard error objects. These can be Kalam documents, for instance. Note how we carefully restore the standard output and standard error channels. The output of the print statement in the exception clause will go to the redefined channel (in this instance, the kalam document).

class MacroManager(QObject):

    def __init__(self, parent = None, g = None, l = None, *args):
        """ Creates an instance of the MacroManager.
        Arguments:
        g = dictionary that will be used for the global namespace
        l = dictionary that will be used for the local namespace
        """
        apply(QObject.__init__,(self, parent,) + args)

        self.macroObjects = {}

        if g == None:
            self.globals = globals()
        else:
            self.globals = g

        if l == None:
            self.locals = locals()
        else:
            self.locals = l
      

All macros should be executed in the same environment, which is why the macromanager can be constructed with a globals and a locals environment. This environment will be used later to create a special API for the macro execution environment, and it will include access to the window (i.e. the KalamApp object) and to the document objects (via the DocManager object).

    def deleteMacro(self, macroName):
        del self.macroObjects[macroName]

    def addMacro(self, macroName, macroString):
        action = MacroAction(macroString, self.parent())
        self.macroObjects[macroName] = action
        self.connect(action,
                     PYSIGNAL("activated"),
                     self.executeAction)
        return action

    def executeAction(self, action):
        action.execute(sys.stdout,
                       sys.stderr,
                       self.globals,
                       self.locals)
      

The rest of the MacroManager class is simple, including methods to delete and add macros, and to execute a named macro. Note how the activated signal of the MacroAction is connected to the executeAction slot. This slot then calls execute() on the macro action with standard output and standard error as default output channels. A macro can, of course, create a new document and divert output to that document.

The MacroManager is instantiated as part of the startup process of the main application:

    # kalamapp.py
    def initMacroManager(self):
        g=globals()
        self.macroManager = MacroManager(self, g)
      

Initializing the macromanager will also entail deciding upon a good API for the macro extensions. This will be covered in a later section.

Adapting the slotMacroExecuteDocument() slot function to use the MacroManager is quite straightforward:

    # kalamapp.py
    def slotMacroExecuteDocument(self):
        if self.docManager.activeDocument() == None:
            QMessageBox.critical(self,
                                 "Kalam",
                                 "No document to execute as a macro ")
            return

        title = self.docManager.activeDocument().title()
        
        try:
            macroText = str(self.docManager.activeDocument().text())
            self.macroManager.addMacro(title, macroText)
        except CompilationError, e:
            QMessageBox.critical(self,
                                 "Kalam",
                                 "Could not compile " +
                                 self.docManager.activeDocument().title() +
                                 "\n" + str(e))
            return
        
        try:
            doc, view = self.docManager.createDocument(KalamDoc, KalamView)
            doc.setTitle("Output of " + title)
            self.macroManager.executeMacro(title, doc, doc)
        except NoSuchMacroError, e:
            QMessageBox.critical(self,
                                 "Kalam",
                                 "Error: could not find execution code.")
        except ExecutionError, e:
            QMessageBox.critical(self,
                                 "Kalam",
                                 "Error executing " + title +
                                 "\n" + str(e))            
        except Exception, e:
            QMessageBox.critical(self,
                                 "Kalam",
                                 "Unpleasant error %s when trying to run %s." \
                                 % (str(e), title))
      

Note the careful handling of exceptions. You don't want your application to crash or become unstable because of a silly error in a macro.

startup macros

Executing the contents of a document is very powerful in itself—especially since we have access to the complete KalamApp object, from which we can reach the most outlying reaches of Kalam.

It would be very unpleasant for a user to have to load his macros as a document every time he wants to execute a macro. Ideally, a user should be able to define a set of macros that run at start-up, and be able to add macros to menu options and the keyboard.

Solving the first problem takes care of many other problems in one go. Users who are capable of creating macros are probably able to create a startup macro script that loads all their favorite macros.

We define two keys in the configuration file, macrodir and startupscript. These are the name and location of the Python script that is executed when Kalam is started. We start a user macro after all standard initialization is complete:

# kalamapp.py - fragment
...
class KalamApp(QMainWindow):
    def __init__(self, *args):
        apply(QMainWindow.__init__,(self, ) + args)
        ...
        # Run the startup macro script
        self.runStartup()

    ...

    def runStartup(self):
        """Run a Python script using the macro manager. Which script is
        run is defined in the configuration variables macrodir and startup.

        All output, and eventual failures are shown on the command-line.
        """
        try:
            startupScript = os.path.join(kalamconfig.get("macrodir"),
                                         kalamconfig.get("startupscript"))
            startup = open(startupScript).read()
            self.macroManager.addMacro("startup", startup)
            self.macroManager.executeMacro("startup")
        except Exception, e:
            print "Could not execute startup macro", e
        

A sample startup script might start Kalam with an empty document:

# startup.py - startup script for Kalam
print "Kalam startup macro file"
self.docManager.createDocument(KalamDoc, KalamView)
      

It is already possible to do anything you want using these macro extensions, but life can be made easier by providing shortcut functions: a special macro API. We will create one in the next section. However, a serious macro writer would have to buy a copy of this book in order to be able to use all functionality, because hiding the underlying GUI toolkit would remove far too much power from his hands.