Generating HTML on the fly in Python

More like data as code than code as data.



Due to questionable advice from a random online article last night I decided to sign up for LinkedIn. As can be expected I've accomplished very little of use since then. As painful as it is, cranking through all the work experience and self promotion stuff, it is also pretty cool to play celebrity chaser and follow people like Rich Hickey or Erik Demaine.

Anyway, the plan today was to implement a click & drag selection mode in the document list of my application Lister. I did find some awkward JQuery code on StackOverflow that I could fix enough to be usable, but I've been thinking about moving my JS code (all 200 lines) over to ClojureScript. Then I could use core.async to sanely manage the app state across events. I could also clean up a lot of the ugliness I've let live just because its Javascript. I'm not sure it will be worth it to add another (big!) dependency and additional build steps though. I'll probably try a smaller experiment first and see how it goes.

As a supplement for today though I'll show off this little module I wrote to do HTML generation. The main widget in Lister is a QTabWidget containing one QWebView widget per tab. Since I didn't want to include an extra library to populate those web views I rolled my own super simple generator instead. It is inspired by libraries like Hiccup and Hoplon, and takes advantage of the Python syntax to do most of the heavy lifting.

The key function is tag, which produces an arbitrary tag containing its args as its innerHTML and kwargs as attributes:

from functools import partial
from itertools import chain

self_closing = ["area", "base", "br", "col", "command", "embed", "hr", "img",
                "input", "keygen", "link", "meta", "param", "source", "track",
                "wbr"]

def tag(kind, *args, **kwargs):
    """Generic tag-producing function."""
    kind = kind.lower()
    result = list()
    result.append("<{}".format(kind))
    for key, val in kwargs.items():
        if key in ('kind', '_type'):
            key = 'type'
        elif key in ('klass', 'cls'):
            key = 'class'
        result.append(" {}=\"{}\"".format(key, val))
    if kind in self_closing:
        result.append(" />")
    else:
        result.append(">{}</{}>\n".format("".join(args), kind))
    return "".join(result)

(Note that no attempt is made to sanitize or encode anything)

I then use that to build some basic tags as partial functions, and some specific convenience functions:

# Misc simple tags
style = partial(tag, 'style')
div = partial(tag, 'div')
span = partial(tag, 'span')

# Form related tags
label = partial(tag, 'label')
textbox = partial(tag, 'input', _type='text')
button = partial(tag, 'button', _type='button')

# Helper functions
def html(head='', body='', **body_attrs):
    """Root document element."""
    return tag('html', tag('head', head), tag('body', body, **body_attrs))

def css(url):
    """A css link tag."""
    return tag('link', href=url, rel='stylesheet', _type='text/css')

js = partial(tag, 'script', _type='text/javascript')
def script(url=None, script=None):
    """A script tag."""
    if url:
        return js(src=url)
    elif script:
        return js(script)

From there I build some application specific template functions:

# Doclist filter input controls
docfilter = div(" ", label('Filter:'), " ",
                textbox(id='filter_input', autofocus='autofocus'),
                button("Clear", id='filter_clear'),
                id='docfilter')

bar = span('|', cls='bar noselect')

def cell(idx, contents):
    """Create html representing a cell in a row."""
    return span(" {}".format(contents),
                cls="col{} noselect".format(idx))

def add_bars(data):
    """Transform column data into cell tags, and add separating bars."""
    cells = [(cell(i, c), bar) for i, c in enumerate(data)]
    cells = list(chain(*cells))
    cells.pop() # Remove last vertical bar
    return cells

def row(idx, data):
    """Create html representing a row of cells."""
    cells = add_bars(data)
    # Setup row classes
    classes = "row noselect"
    if idx%2 == 1:
        classes += " zebra"
    return div(*cells, id="row{}".format(idx), cls=classes)

def header(columns):
    """Create html for column headers."""
    cells = add_bars(columns)
    return div(docfilter, div(*cells, cls='noselect'), id='header')

def doclist(headers, data):
    """Create a complete doclist from headers and 2d data."""
    rows = [header(headers)]
    rows.extend([row(i, r) for i, r in enumerate(data)])
    return div(*rows, id='doclist')

A lot of this is messy, and not necessarily how I would do this in a web page, but since it is hidden inside a desktop application I did what was expedient.

You can see the full code as a Gist here.