PicturePile: a tutorial Woven application

To illustrate the basic design of a Woven app, we're going to walk through building a simple image gallery. Given a directory of images, it will display a listing of that directory; when a subdirectory or image is clicked on, it will be displayed.

To begin, we write an HTML template for the directory index:

<html>
  <head>
    <title model="title" view="Text">Directory listing</title>
  </head>
  <body>
    <h1 model="title" view="Text"></h1>
    <ul model="directory" view="List">
      <li pattern="listItem"><a view="Anchor" /></li>
      <li pattern="emptyList">This directory is empty.</li>
    </ul>
  </body>
</html>

The main things that distinguish a Woven template from standard XHTML are the 'model', 'view', and 'pattern' attributes on tags. Predictably, 'model' and 'view' specify which model and view will be chosen to fill the corresponding node. The 'pattern' attribute is used with views that have multiple parts, such as List. This example uses two patterns List provides; 'listItem' marks the node that will be used as the template for each item in the list, and 'emptyList' marks the node displayed when the list has no items.

Next, we create a Page that will display the directory listing, filling the template above (after a few imports):

import os
from twisted.internet import app 
from twisted.web.woven import page
from twisted.web import server

class DirectoryListing(woven.page.Page):

    templateFile = "directory-listing.xhtml"

    def initialize(self, *args, **kwargs):
        self.directory = kwargs['directory']

    def wmfactory_title(self, request):
        return self.directory

    def wmfactory_directory(self, request):
        files = os.listdir(self.directory)
        for i in xrange(len(files)):
            if os.path.isdir(os.path.join(self.directory,files[i])):
                files[i] = files[i] + '/'
        return files

    def getDynamicChild(self, name, request):
        path = os.path.join(self.directory,name)
        if os.path.exists(path):
            if os.path.isdir(path):
                return DirectoryListing(directory=path)
            else:
                return ImageDisplay(image=path)

Due to the somewhat complex inheritance hierarchy in Woven's internals, a lot of processing is done in the '__init__' method for Page. Therefore, a separate 'initialize' method is provided so that one can easily access keyword args without having to disturb the internal setup; it is called with the same args that Page.__init__ receives.

The 'templateFile' attribute tells the Page what file to load the template from; in this case, we will store the templates in the same directory as the Python module. The 'wmfactory' (short for Woven Model Factory) methods return objects to be used as models; In this case, 'wmfactory_title' will return a string, the directory's name, and 'wmfactory_directory' will return a list of strings, the directory's content.

Upon rendering, Woven will scan the template's DOM tree for nodes to fill; when it encounters one, it gets the model (in this case by calling methods on the Page prefixed with 'wmfactory_'), then creates a view for that model; this page uses standard widgets for its models and so contains no custom view code. The view fills the DOM node with the appropriate data. Here, the view for 'title' is Text, and so will merely insert the string. The view for 'directory' is List, and so each element of the list will be formatted within the '<ul>'. Since the view for list items is Anchor, each item in the list will be formatted as an '<a>' tag.

So, for a directory Images containing foo.jpeg, baz.png, and a directory MoreImages, the rendered page will look like this:

<html>
  <head>
    <title>/Users/ashort/Pictures</title>
  </head>
  <body>
    <h1>/Users/ashort/Pictures</h1>
    <ul>
      <li>
        <a href="foo.jpeg">foo.jpeg</a>
      </li>
      <li>
        <a href="baz.png">baz.png</a>
      </li>
      <li>
        <a href="MoreImages/">MoreImages/</a>
      </li>
    </ul>
  </body>
</html>

As you can see, the nodes marked with 'model' and 'view' are replaced with the data from their models, as formatted by their views. In particular, the List view repeated the node marked with the 'listItem' pattern for each item in the list.

For displaying the actual images, we use this template:

<html>
  <head>
    <title model="image" view="Text">Filename</title>
  </head>
  <body>
    <img src="preview" />
  </body>
</html>
And here is the definition of 'ImageDisplay':
class ImageDisplay(page.Page):

    templateFile="image-display.xhtml"

    def initialize(self, *args, **kwargs):
        self.image = kwargs['image']

    def wmfactory_image(self, request):
        return self.image

    def wchild_preview(self, request):
        return static.File(self.image)

Instead of using 'getDynamicChild', this class uses a 'wchild_' method to return the image data when the 'preview' child is requested. 'getDynamicChild' is only called if there are no 'wchild_' methods available to handle the requested URL.

Finally, we create a webserver set to start with a directory listing, and connect it to a port:

rootDirectory = os.path.expanduser("~/Pictures")
site = server.Site(DirectoryListing(directory=rootDirectory))
application = app.Application("PicturePile") 
application.listenTCP(8088, site)

And then start the server:

if __name__ == '__main__': 
    import sys               
    from twisted.python import log 
    log.startLogging(sys.stdout, 0) 
    application.run() 

Custom Views

Now, let's add thumbnails to our directory listing. We begin by changing the view for the links to thumbnail:

<html>
  <head>
    <title model="title" view="Text">Directory listing</title>
  </head>
  <body>
    <h1 model="title" view="Text"></h1>
    <ul model="directory" view="List">
      <li pattern="listItem"><a view="thumbnail" /></li>
      <li pattern="emptyList">This directory is empty.</li>
    </ul>
  </body>
</html>

Woven doesn't include a standard thumbnail widget, so we'll have to write the code for this view ourselves. (Standard widgets are named with initial capital letters; by convention, custom views are named like methods, with initial lowercase letters.)

The simplest way to do it is with a 'wvupdate_' (short for Woven View Update) method on our DirectoryListing class:

    def wvupdate_thumbnail(self, request, node, data):
        a = microdom.lmx(node)
        a['href'] = data
        if os.path.isdir(os.path.join(self.directory,data)):
            a.text(data)
        else:
            a.add('img', src=(data+'/preview'),width='200',height='200')

When the 'thumbnail' view is requested, this method is called with the HTTP request, the DOM node marked with this view, and the data from the associated model (in this case, the name of the image or directory). With this approach, we can now modify the DOM as necessary. First, we wrap the node in lmx, a class provided by Twisted's DOM implementation that provides convenient syntax for modifying DOM nodes; attributes can be treated as dictionary keys, and the 'text' and 'add' methods provide for adding text to the node and adding children, respectively. If this item is a directory, a textual link is displayed; else, it produces an 'IMG' tag of fixed size.

Simple Input Handling

Limiting thumbnails to a single size is rather inflexible; our app would be nicer if one could adjust it. Let's add a list of thumbnail sizes to the directory listing. Again, we start with the template:

directory-listing3.html

This time, we add a form with a list of thumbnail sizes named 'thumbnailSize': we want the form to reflect the selected option, so we place an 'adjuster' view on the 'select' tag that looks for the right 'option' tag and puts 'selected=1' on it (the default size being 200):

    def wvupdate_adjuster(self, request, widget, data):
        size = request.args.get('thumbnailSize',('200',))[0]
        domhelpers.locateNodes(widget.node.childNodes, 
                               'value', size)[0].setAttribute('selected', '1')

'request.args' is a dictionary, mapping argument names to lists of values (since multiple HTTP arguments are possible). In this case, we only care about the first argument named 'thumbnailSize'. 'domhelpers.locateNodes' is a helper function which, given a list of DOM nodes, a key, and a value, will search each tree and return all nodes that have the requested key-value pair.

Next, we modify the 'thumbnail' view to look at the arguments from the HTTP request and use that as the size for the images:

    def wvupdate_thumbnail(self, request, node, data):
        size = request.args.get('thumbnailSize',('200',))[0]
        a = microdom.lmx(node)
        a['href'] = data
        if os.path.isdir(os.path.join(self.directory,data)):
            a.text(data)
        else:
            a.add('img', src=(data+'/preview'),width=size,height=size)

Sessions

A disadvantage to the approach taken in the previous section is that subdirectories do receive the same thumbnail sizing as their parents; also, reloading the page sets it back to the default size of 200x200. To remedy this, we need a way to store data that lasts longer than a single page render. Fortunately, twisted.web provides this in the form of a Session object. Since only one Session exists per user for all applications on the server, the Session object is Componentized, and each application adds adapters to contain their own state and behaviour, as explained in the Components documentation. So, we start with an interface, and a class that implements it, and registration of our class upon Session:

class IPreferences(components.Interface):
    pass

class Preferences(components.Adapter):
    __implements__ = IPreferences
    
components.registerAdapter(Preferences, server.Session, IPreferences)

We're just going to store data on this class, so no methods are defined.

Next, we change our view methods, 'wvupdate_thumbnail' and 'wvupdate_adjuster', to retrieve their size data from the Preferences object stored on the Session, instead of the HTTP request:

    def wvupdate_thumbnail(self, request, node, data):
        prefs = request.getSession(IPreferences)
        size = getattr(prefs, 'size','200')
        a = microdom.lmx(node)
        a['href'] = data
        if os.path.isdir(os.path.join(self.directory,data)):
            a.text(data)
        else:
            a.add('img', src=(data+'/preview'),width=size,height=size)

    def wvupdate_adjuster(self, request, widget, data):
        prefs = request.getSession(IPreferences)
        size = getattr(prefs, 'size','200')
        domhelpers.locateNodes(widget.node.childNodes, 
                               'value', size)[0].setAttribute('selected', '1')

Controllers

Now we turn to the question of how the data gets into the session in the first place. While it is possible to to place it there from within the 'wvupdate_' methods, since they both have access to the HTTP request, it is desirable at times to separate out input handling, which is what controllers are for. So, we add a 'wcfactory_' (short for Woven Controller Factory) method to DirectoryListing:

    def wcfactory_adjuster(self, request, node, model):
        return ImageSizer(model, name='thumbnailSize')

ImageSizer is a controller. It checks the input for validity (in this case, since it subclasses Anything, it merely ensures the input is non-empty) and calls 'handleValid' if the check succeeds; in this case, we retrieve the Preferences component from the session, and store the size received from the form upon it:

class ImageSizer(input.Anything):
    def handleValid(self, request, data):
        prefs = request.getSession(IPreferences)
        prefs.size = data 

Finally, we must modify the template to use our new controller. Since we are concerned with the input from the '<select>' element of the form, we place the controller upon it:

directory-listing4.html

Now, the selected size will be remembered across subdirectories and page reloads.