GUIs in PyGraphics: Working With AMP

What is AMP?

AMP is the Asynchronous Messaging Protocol, a bidirectional RPC protocol used by PyGraphics to communicate between processes.

Why do we have multiple processes and RPC?

Part of what PyGraphics does is let the user display images to the screen, after the user has made some changes, and continue to display the image while the user does work.

Originally, this was done via PIL’s show method. (And before that, through pygame). When this didn’t work, it was replaced with a custom-built GUI window implemented through ImageTk.

Because images are supposed to be shown while the Python interactive interpreter is running, a new thread would be created for Tkinter to run its event loop, and messages would be passed to create new windows etc.

Unfortunately, threading didn’t work out either. OS X 10.6 wouldn’t permit TkAqua to be initialized from the wrong thread, as documented here.

Several solutions were attempted with running Tkinter in another thread. During the summer of 2011, it was removed and placed in another process instead. However, threads were kept for running IPC. Even this use of threads had to be removed, due to a known dead-lock in the Python import system that would make students’ code too fragile. Currently, PyGraphics does not use threads at all, and instead uses AMP with ampy

How PyGraphics uses AMP

When mediawindows.init_mediawindows() is called, a subprocess is created that runs mediawindows.tkinter_server. The current process connects to the subprocess on a constant port (mediawindows.amp.PORT) and from then on they communicate via AMP.

Any functions that require asynchronously executing code (in particular, GUI windows) must code this as an AMP command, send it over the connection, where it is decoded by the subprocess and executed.

Creating your own AMP command

In this tutorial, we will be implementing media.say(). The code used to implement that was written as part of writing this tutorial.

The first step in creating a new function that needs to use AMP for IPC is to figure out what that function does, of course! This function will produce a GUI window with some text in it. So our function will take a single parameter, “text”:

def say(text):
    """Put some text in a GUI window and stuff.

    say is called with one argument, you can use it like this:

        >>> say("foo")

    and say will return right away. You can even put up multiple windows
    at once!
    """

    # what do we put here?
    pass

In particular, this text is the only thing that needs to be communicated to the other process, too. The GUI process only needs to know that it is displaying text, and what text to display. It won’t communicate anything back, and it won’t raise any exceptions.

So the next, and most important step, is to define the inter-process interface. AMP is used in a statically-typed fashion, with commands crafted with specific arguments of specific types, so that AMP knows how to transmit them on the wire.

There are no positional arguments in an AMP command, just keyword arguments. Similarly, AMP commands only return dictionaries. So our amp command will take in a single keyword argument: "text", and return a dict with no keys (the empty dict, {})

To write all of this down, we would write the following class into mediawindows.amp:

class Say(amp.Command):
    arguments = [
        ('text', BigString())]
    response = []

Static typing in AMP

arguments is a list of key, AmpType pairs. ampy has a lot of types for representing Python values, including:

  • ampy.ampy.Integer
  • ampy.ampy.Float
  • ampy.ampy.Boolean
  • ampy.ampy.String
  • ampy.ampy.Unicode

In addition, mediawindows.amp offers the following extra AMP types:

  • mediawindows.amp.BigString
  • mediawindows.amp.BigUnicode
  • mediawindows.amp.PILImage

Each of these let you pass in Python values of that type and get the same value back on the other side of the AMP connection.

By declaring there to be an argument of name ‘text’ and value mediawindows.amp.BigString, we allow BigString to handle serialization and deserialization of the ‘text’ keyword argument we pass or receive during a Say command.

A complete media.say()

Before implementing the responder, let’s implement the front-end. media.py will have a say() function that sends this AMP command to the Tkinter process.

It would be helpful to learn how to send commands!

So say should look exactly like this:

def say(text):
    """Put some text in a GUI window and stuff.

    say is called with one argument, you can use it like this:

        >>> say("foo")

    and say will return right away. You can even put up multiple windows
    at once!
    """

    mw.callRemote(amp.mp.Say, text=text)

We would probably put this inside mediawindows.proxy, and then have a short stub function in media that goes as follows:

def say(text):
    """Put some text in a GUI window and stuff.

    say is called with one argument, you can use it like this:

        >>> say("foo")

    and say will return right away. You can even put up multiple windows
    at once!
    """

    return mw.say(text)

It’s worth noting that mediawindows.say() is called, and not mediawindows.proxy.say(). While mediawindows is a package (a module implemented using a directory with multiple submodules), it exports an interface to other modules that makes it usable as a plain module. No mediawindows submodule should be used in new code in general, especially not media, as it’s read by first-year students who are very early into their CS education.

To do this, the following line might be added to mediawindows.__init__:

from .proxy import say

Making it actually work

Sending an AMP command is all well and good, but if we try to actually invoke the command, we’ll get an exception something like the following:

Traceback (most recent call last):
  File "...", line 1, in <module>
  File ".../pygraphics/media.py", line 530, in say
    return mw.say(text)
  File ".../pygraphics/mediawindows/proxy.py", line 43, in say
    mw.callRemote(mw.amp.Say, text=text)
  File ".../pygraphics/mediawindows/client.py", line 62, in callRemote
    return _CONNECTION_SINGLETON.proxy.callRemote(*args, **kwargs)
  File ".../ampy/ampy.py", line 152, in callRemote
    return self._callRemote(command, True, **kw)
  File ".../ampy/ampy.py", line 200, in _callRemote
    wireResponse[ERROR_DESCRIPTION])
  File ".../ampy/ampy.py", line 230, in _raiseProxiedError
    raise AMPError(code, description)
ampy.ampy.AMPError: ('UNHANDLED', 'No handler for command')

This is good! This exception means that the message was sent fine, it was received by the GUI subprocess, but the GUI subprocess indicated an error back to us: it doesn’t know what to do with a Say command. The solution is to implement a handler for Say inside the GUI subprocess.

The GUI subprocess is implemented in mediawindows.tkinter_server. This module is executed, creates an AMP server, and starts a listening socket. When it gets its first connection (hopefully the parent process), it stops listening for new connections, and handles AMP commands from the client. When it is disconnected from and all its windows have been closed, it terminates. (mediawindows will not permit the main Python process to exit until this has happened).

Because ampy doesn’t have a lot of tools for writing AMP servers (or clients), a bit of infrastructure is provided in the form of this class:

To define a handler for our command, we need a new protocol backend for it, and we need to make sure the protocol backend is registered against the actual AMP connection when it’s created.

First, we define our backend. Just so that we can be sure this works, instead of opening up a new GUI window, we’ll use the print statement:

class SayServerProtocol(ProtocolBackend):
    """
    This is a protocol backend (set of responders) the Amp protocol will
    use for handling the say function.

    Call register_against() on an amp protocol to register its logic.
    """
    responders = []
    responder = appender(responders)

    @responder(Say)
    def say(self, text):
        print text
        return {}

There are a couple of important things to note.

  1. SayProtocol has a class variable responders and responder.

    responders is used by :py:meth:`mediawindows.tkinter_server.ProtocolBackend.register_against to register command handlers against commands.

    responder is a decorator only used inside the class body, which appends to responders when called appropriately.

    When you want to define a new responder, you can decorate it with @responder(CommandClass)

  2. say returns {}

    Every responder is required to return a value, no matter if it’s used by the other side or not. The value is always a dictionary. In the case of Say, the dictionary should be empty, because there are no keys defined in Say.response.

By itself, SayServerProtocol doesn’t do anything. It still needs to be registered against the actual connection, so the following lines need to be added to mediawindows.tkinter_server.GooeyServer.buildProtocol():

self._say_prot = SayServerProtocol()
self._say_prot.register_against(protocol)

So now what happens when we run try to use this?

>>> import media
>>> media.say("Hello, world!")
Hello, world!

Perfect!

Tkinterizing

Right now the say function only prints, of course. But we want it to do more than that – all of the functions in mediawindows are for producing GUI windows.

In this case, there’s an existing GUI class we can adopt.

class SayDialog(tk.Frame):
    '''Simple Say dialog.'''
    
    window_title = "Message!"
    
    def __init__(self, s=''):
        tk.Frame.__init__(self, tk.Toplevel())
        
        self.s = s
        self._set_display()
        
    def _set_display(self):
        self._set_master_properties()
        self._set_dimensions()
        self._center_window()
        self.grid()
        self._display_components()
        self.master.wait_window(self)
        
    def _set_master_properties(self):
        self.master.title(self.window_title)
        self.master.deiconify()
        self.bind("<Return>", self.master.destroy)
        self.bind("<Escape>", self.master.destroy)
        
    def _set_dimensions(self):
        self.h = 75
        self.w = 250
        
    def _display_components(self):
        self._display_say_text()
        self._display_OK_button()
        
    def _display_say_text(self):
        self.text_say = tk.Label(self, text=self.s)
        self.text_say.grid(column=0, row=0)
    
    def _display_OK_button(self):
        self.btn_OK = tk.Button(self, text='Close', command=self.master.destroy)
        self.btn_OK.grid(column=0, row=1)
        
    def _center_window(self):
        screen_height = self.master.winfo_screenheight()
        screen_width = self.master.winfo_screenwidth()
        window_height = self.h
        window_width = self.w
        
        new_y_position = (screen_height - window_height) / 2
        new_x_position = (screen_width - window_width) / 2
        new_position = '%dx%d+%d+%d' % (window_width, window_height, 
                                          new_x_position, new_y_position)
        self.master.geometry(newGeometry=new_position)

####################------------------------------------------------------------
## Ask and Say dialogs -- Dead Code
####################------------------------------------------------------------

Understanding how it works isn’t particularly important. A SayDialog instance is created and the call to create it blocks until the dialog is closed. So instead of:

@responder(Say)
def say(self, text):
    print text
    return {}

We have:

@responder(Say)
def say(self, text):
    gui.SayDialog(text) # blocks until closed
    # it's ok to block and return, the client is blocking too,
    return {}

And that’s it! From now on, if we call media.say("text"), a window will pop up containing text. media.say() will not return until that window is closed.

../../_images/say_example3.png