Developing Web Applications with Quixote
PyCon 2004
March 24, 2004
A.M. Kuchling
www.amk.ca
amk @ amk.ca
Overview
Quixote is a Web development framework written in Python.
Some of Quixote's goals:
- Simplicity
- Familiar to Python programmers
- Encourages good design (in several senses)
- Easy to use with other Python libraries
Related tools:
- Sancho: unit testing
- Dulcinea: various ZODB and Quixote-related utility classes
Sites/Applications using Quixote
Sites:
Applications:
MX: site for distributed semiconductor fabrication
MX: Quixote was originally written for implementing it
LWN: the highest-traffic Quixote site
LWN: demonstrates that QX can be pretty scalable
LWN: Survived a Slashdotting w/ 1GHz Pentium, 512Mb of RAM
Cartwheel: genomic sequence analyses
Solidus: a Slashdot clone being developed
Other apps: Jim Dukarm's app for analyzing oil tests
The Quixote Toolkit
- Publisher: maps URLs to Python code
- HTTPRequest, HTTPResponse classes
- Web server interfaces for CGI, FastCGI, SCGI, mod_python
- Python Templating Language (optional)
- Form framework (optional)
How Quixote Works: The basic idea
- Take the URL path and split it apart:
http://example.com/catalog/item/details
→ ['catalog', 'item', 'details']
- Starting at a configured root package (e.g. 'store.web'),
follow the URL path down:
http://example.com/ → store.web._q_index()
http://.../catalog/ → store.web.catalog()
or store.web.catalog._q_index
/catalog/item/ → store.web.catalog.item()
or store.web.catalog.item._q_index()
- Call it and send the output to the client:
output = store.web.catalog(request)
- Python supports packages (directories containing Python code).
A Quixote application is just a Python package.
- QX then looks for the 'item' attribute of
store.web.catalog
, and so forth.
- The search stops when the end of the URL is reached, or when
something callable is found.
- The resulting object -- function, method, whatever -- is
called.
- The object gets one argument,
request
, and must
return a string.
- The string is sent back to the HTTP client.
- Search stops:
- This is something like Zope's traversal, but the rules are
simpler
- There are still some special cases, though, that we'll get
to.
Simple example
From quixote/demo/__init__.py:
# Every publicly accessible attribute has to be listed in _q_exports.
_q_exports = ["simple"]
def simple (request):
# This function returns a plain text document, not HTML.
request.response.set_content_type("text/plain")
return "This is the Python function 'quixote.demo.simple'.\n"
How Quixote Works: Special names
_q_index
: If traversal ends up at an object that
isn't callable, this name is checked for and called.
_q_lookup
: if an attribute isn't found, this name
is checked for and called with the attribute.
_q_access
: at every step, this name is checked for
and called to perform access checks.
_q_resolve
: like a memoized version of _q_lookup
(rarely used)
Names all begin with _q_.
_q_lookup: used for building URLs taken from a database
_q_access: can require that users be logged in, or check that
logged-in users have a particular privilege
_q_resolve: used to avoid having to import everything on
application startup
How Quixote Works: _q_lookup example
This example handles URLs such as /whatever/1/, .../2/, etc.
def _q_lookup (request, component):
try:
key = int(component)
except ValueError:
raise TraversalError("URL component is not an integer")
obj = ... database lookup (key) ...
if obj is None:
raise TraversalError("No such object.")
# Traversal will continue with the ObjectUI instance
return ObjectUI(obj)
How Quixote works: _q_access example
_q_access
is always called before traversing any
further.
This example requires that all users must be logged in.
from quixote.errors import AccessError, TraversalError
def _q_access (request):
if request.session.user is None:
raise AccessError("You must be logged in.")
# exits quietly if nothing is wrong
def _q_index [html] (request):
"""Here is some security-critical material ..."""
How Quixote works: The HTTPRequest class
.response
-- a HTTPResponse instance
.session
-- a Session instance
request.get_environ('SERVER_PORT', 80)
-- various standard CGI environment vars
request.get_form_var('user')
-- get form
variables
request.get_cookie('session')
-- get cookie
values
request.get_url(n=1)
-- get URL of request, chopping off n pieces
request.get_accepted_types()
-- get a dict mapping {MIME type: quality value}
browser, version =
request.guess_browser_version()
return request.redirect('../../catalog')
-- redirect to the specified URL
How Quixote works: The HTTPResponse class
.headers
-- dict of HTTP headers
.cache
-- number of seconds to cache response
.set_content_type('text/plain')
-- specify MIME content type of the response
.set_cookie('session', '12345', path='/')
-- return a Set-Cookie header to the client
.expire_cookie('session')
-- delete cookie from the client
How Quixote works: Enabling sessions
Instead of Publisher
, use
SessionPublisher
:
from quixote.publisher import SessionPublisher
app = SessionPublisher('quixote.demo')
The request will then have a .session
attribute
containing a Session
instance.
Two other classes:
SessionManager
-- stores/retrieves sessions
Session
-- can hold .user
attribute
SessionManager is a dictionary-like object responsible for
storing sessions. Default is in-memory; possible to use
ZODB/SQL/whatever.
The only interesting thing on Session is probably .user.
Running Quixote applications
Several options:
- CGI (slow, not recommended)
- FastCGI (fast and scalable; Apache mod_fastcgi possibly
buggy)
- Apache + mod_scgi (fast, scalable; recommended for high
traffic)
- Pure Python web server (slower, not many features, but useful
for standalone applications).
Running Quixote applications: CGI/FastCGI
demo.cgi:
#!/www/python/bin/python
# Example driver script for the Quixote demo:
# publishes the quixote.demo package.
from quixote import Publisher
# Create a Publisher instance, giving it the root package name
app = Publisher('quixote.demo')
# Open the configured log files
app.setup_logs()
# Enter the publishing main loop
app.publish_cgi()
This will also handle FastCGI. CGI scripts will run through
publish_cgi() once and exit; under FastCGI it will loop.
Running Quixote Applications: Stand-alone
Running a server on localhost is really easy:
import os, time
from quixote.server import medusa_http
if __name__ == '__main__':
s = medusa_http.Server('quixote.demo', port=8000)
s.run()
Easy-to-write application: run a Quixote server locally and use
Python's webbrowser.open() module to open a browser pointing at
it.
PTL: Overview
PTL = Python Templating Language
example.ptl:
# To callers, templates behave like regular Python functions
def cell [html] (content):
'<td>' # Literal expressions are appended to the output
content # Expressions are evaluated, too.
'</td>'
def row [html] (L):
# L: list of strings containing cell content
'<tr>'
for s in L:
cell(s)
'</tr>\n'
def loop (n): # No [html], so this is a regular Python function
output = ""
for i in range(1, 10):
output += row([str(i), i*'a', i*'b'])
return output
PTL: Using templates
Templates live in .ptl files, which can be imported. To enable
this:
import quixote ; quixote.enable_ptl() # Enable import hook
Templates behave just like Python functions:
>>> import example
>>> example.cell('abc')
<htmltext '<td>abc</td>'>
>>> example.loop()
<htmltext '<tr><td>1</td><td>a</td><td>b</td>...</tr>\n'>
In .ptl files, methods can even be PTL files.
PTL: Comparison with other syntaxes
System |
Syntax |
Apache SSI |
<!--#include
virtual="/script/"--> |
PHP |
<?php func()?> |
ASP |
<% func() %> |
ZPT |
<span
tal:replace="content">...</span> |
PTL |
def f [html] (): content |
PTL's advantages:
- Python users have no new rules to learn; templates have
parameters, keyword parameters, can be methods, etc.
- All Python language features are available:
for
,
if
, while
, exceptions, classes, nested
functions.
- Common code can be easily refactored out into functions
- Most syntaxes use HTML and provide escapes into code;
PTL uses code and escapes into HTML.
PTL: Automatic escaping
def no_quote [plain] (arg):
'<title>'
arg # Converted to string
'</title>'
def quote [html] (arg):
'<title>'
arg # Converted to string and HTML-escaped
'</title>'
>>> no_quote('A history of the < symbol')
'<title>A history of the < symbol</title>'
>>> quote('A history of the < symbol')
<htmltext '<title>A history of the < symbol</title>'>
By using '[html]' instead of '[plain]', string literals are
compiled as htmltext
instances. When combined with
regular strings using a + b
or '%s' % b
,
htmltext
HTML-escapes the regular string.
- Prevents XSS
- XSS occur when untrusted data is sent to the client without
escaping
We think PTL is really neat, but it's optional. Using
alternative templating isn't hard.
Toolkit: Form processing
Quixote contains a set of classes for implementing forms.
Example:
from quixote.form import Form
class UserForm (Form):
def __init__ (self):
Form.__init__(self)
user = get_session().user
self.add_widget("string", "name", title="Your name",
value=user.name)
self.add_widget("password", "password1", title="Password",
value="")
self.add_widget("password", "password2",
title="Password, again", value="")
self.add_widget("single_select", "vote",
title = "Vote on proposal",
allowed_values=[None] + range(4),
descriptions=['No vote', '+1', '+0',
'-0', '-1'],
hint = "Your vote on this proposal")
self.add_widget("submit_button", "submit",
value="Update information")
Toolkit: Form processing (cont'd)
class UserForm (Form):
...
def render [html] (self, request, action_url):
standard.header("Edit Your User Information")
Form.render(self, request, action_url)
standard.footer()
def process (self, request):
values = Form.process(self, request)
if not (values['password1'] == values['password2']):
self.error['password1'] = 'The two passwords must match.'
return values
def action (self, request, submit, values):
user = request.session.user
user.name = values['name']
if values['password1'] is not None:
user.password = values['password1']
return request.response.redirect(request.get_url(1))
Toolkit: Serving Static Files
For Quixote-only apps, you often need to return static files
such as PNGs, PDFs, etc.
from quixote.util import StaticFile, StaticDirectory
_q_exports = ['images', 'report_pdf']
report_pdf = StaticFile('/www/sites/qx/docroot/report.pdf',
mime_type='application/pdf'
images = StaticDirectory('/www/sites/qx/docroot/images/')
The quixote.util
module also contains helpers for
XML-RPC, for streaming files back to the client, etc.
SQL: Defining a class with SQLObject
It's possible to use SQL "by hand".
Easier to use SQLObject: an object/relational mapper written by
Ian Bicking (www.sqlobject.org)
This example is from Solidus, a Slashcode clone I'm working
on.
from SQLObject import *
class Story (SQLObject):
section = ForeignKey('Section', default=None)
topic = ForeignKey('Topic', default=None)
user = ForeignKey('User') # User who approved it
submitter = ForeignKey('User') # User who submitted it
discussion = IntCol(default=None)
title = StringCol(default=None)
department = StringCol(default=None)
intro = StringCol(default=None)
timestamp = DateTimeCol(default=datetime.now)
deleted = BoolCol(default=False)
approved = BoolCol(default=False)
Using SQLObject: basic tasks
# Performing a query
def get_recent_stories (N=10, topic=None, approved=True):
"""(int, Section, bool): [Story]
Return the N most recent stories in the (optional)
topic, with the requested approval status.
"""
q = AND(Story.q.approved == approved,
Story.q.deleted == False)
if topic is not None:
q = AND(q, Story.q.topicID == topic.id)
stories = Story.select(q, orderBy="-timestamp", )
stories = stories[:N]
stories = list(stories)
return stories