SVN: r22711 - in trunk/quixote: . form2

Neil Schemenauer <nascheme-fVcApmY9cLvQ3/1i3zOLAti2O/[email protected]> Wed, 08 Oct 2003 18:18:10 -0400
Newsgroups gmane.comp.web.quixote.cvs
Message-ID <[email protected]>
Author: nascheme
Date: 2003-10-08 18:18:10 -0400 (Wed, 08 Oct 2003)
New Revision: 22711

Added:
   trunk/quixote/form2/
   trunk/quixote/form2/__init__.py
   trunk/quixote/form2/form.py
   trunk/quixote/form2/widget.py
Log:
Implement next generation form framework.


Added: trunk/quixote/form2/__init__.py
===================================================================
--- trunk/quixote/form2/__init__.py	2003-10-08 21:55:48 UTC (rev 22710)
+++ trunk/quixote/form2/__init__.py	2003-10-08 22:18:10 UTC (rev 22711)
@@ -0,0 +1,19 @@
+"""$URL$
+$Id$
+
+The web interface framework, consisting of Form and Widget base classes
+(and a bunch of standard widget classes recognized by Form).
+Application developers will typically create a Form instance each
+form in their application; each form object will contain a number
+of widget objects.  Custom widgets can be created by inheriting
+and/or composing the standard widget classes.  More complicated components
+of forms can be built by inheriting from the FormComponent class.
+"""
+
+from quixote.form2.form import Form, FormComponent, FormTokenWidget, \
+    WidgetList
+from quixote.form2.widget import Widget, StringWidget, FileWidget, \
+    PasswordWidget, TextWidget, CheckboxWidget, RadiobuttonsWidget, \
+    SingleSelectWidget, SelectWidget, OptionSelectWidget, \
+    MultipleSelectWidget, SubmitWidget, HiddenWidget, \
+    FloatWidget, IntWidget, subname

Added: trunk/quixote/form2/form.py
===================================================================
--- trunk/quixote/form2/form.py	2003-10-08 21:55:48 UTC (rev 22710)
+++ trunk/quixote/form2/form.py	2003-10-08 22:18:10 UTC (rev 22711)
@@ -0,0 +1,469 @@
+"""$URL$
+$Id$
+
+Provides the Form class and related classes.  Forms are a convenient
+way of building HTML forms that are composed of Widget objects.
+"""
+
+from types import StringType, ListType
+from quixote import get_request, get_session, get_publisher
+from quixote.html import url_quote, htmltag, htmltext, nl2br, TemplateIO
+from quixote.form2.widget import Widget, HiddenWidget, StringWidget, \
+    SubmitWidget, subname
+
+
+try:
+    True, False
+except NameError:
+    True = 1
+    False = 0
+
+
+class FormComponent:
+    """Part of a form. Generally contains one or more than one widgets.
+    The standard Form class renders components inside a 'table' tag.
+    Some sites may wish to provide their own Form.render() and WidgetRow
+    in order to get a different look.
+
+    The purpose of this class is to document the FormComponent interface.
+
+    Instance attributes: none
+    """
+
+    def __init__(self, name, *args, **kwargs):
+        raise NotImplementedError, 'subclass must implement'
+
+    def get_value(self):
+        raise NotImplementedError, 'subclass must implement'
+
+    def set_error(self, error):
+        raise NotImplementedError, 'subclass must implement'
+
+    def has_errors(self):
+        raise NotImplementedError, 'subclass must implement'
+
+    def render(self):
+        raise NotImplementedError, 'subclass must implement'
+
+
+def render_error(error):
+    if error:
+        return htmltext('<font color="red">%s</font><br />') % nl2br(error)
+    else:
+        return ''
+
+
+def render_hint(hint):
+    if hint:
+        return htmltext('<em>%s</em>') % hint
+    else:
+        return ''
+
+
+class WidgetRow(FormComponent):
+    """Standard wrapper for widgets added to a Form.
+
+    Instance attribues:
+        widget : Widget
+        title : string
+        hint : string
+        required : bool
+    """
+
+    def __init__(self, name, widget_class, value, title=None, hint=None,
+                 required=False, **args):
+        self.widget = apply(widget_class, (name, value), args)
+        self.title = title
+        self.hint = hint
+        self.required = required
+        if required and get_request().form:
+            if self.get_value() is None and not self.get_error():
+                self.set_error('value is required')
+
+    def get_value(self):
+        return self.widget.get_value()
+
+    def set_error(self, error):
+        self.widget.set_error(error)
+
+    def _get_error(self):
+        error = self.widget.get_error()
+        if not error and self.required and self.get_value() is None:
+            error = 'value is required'
+        return error
+
+    def has_errors(self):
+        return self._get_error()
+
+    def _render_error(self):
+        return render_error(self._get_error())
+
+    def _render_hint(self):
+        return render_hint(self.hint)
+
+    def render(self):
+        title = self.title or ''
+        if title and self.required:
+            title = title + htmltext('&nbsp;*')
+        r = TemplateIO(html=True)
+        r += htmltext('<tr><th colspan="3" align="left">')
+        r += title
+        r += htmltext('</th></tr>'
+                      '<tr><td>&nbsp;&nbsp;</td><td>')
+        r += self.widget.render()
+        r += htmltext('</td><td>')
+        r += self._render_error()
+        r += self._render_hint()
+        r += htmltext('</td></tr>')
+        return r.getvalue()
+
+
+class WidgetList(FormComponent):
+    """A variable length list of widgets.
+
+    Instance attributes:
+      value : [any]
+      error : string
+    """
+
+    def __init__(self,
+                 name,
+                 element_type=StringWidget,
+                 value=None,
+                 title=None,
+                 hint=None,
+                 element_name="row",
+                 **args):
+        assert value is None or type(value) is ListType, (
+            "value '%s' not a list: got %r" % (name, value))
+        assert type(element_name) in (StringType, htmltext), (
+            "value '%s' element_name not a string: "
+            "got %r" % (name, element_name))
+
+        self.element_type = element_type
+        self.title = title
+        self.hint = hint
+        self.error = None
+
+        self.added_elements = HiddenWidget(subname(name, "added_elements"))
+        self.add_button = SubmitWidget(subname(name, "add_element"),
+                                       value="Add %s" % element_name)
+        self.elements = []
+        def add_element(value=None):
+            self.elements.append(
+                element_type(subname(name, "element%d" % len(self.elements)),
+                             value=value, **args))
+        if value is not None:
+            for element_value in value:
+                add_element(element_value)
+        num_added = int(self.added_elements.get_value() or 1)
+        for i in range(num_added):
+            add_element()
+        if self.add_button.get_value():
+            add_element()
+            num_added += 1
+        self.added_elements.set_value(num_added)
+        request = get_request()
+        if request.form:
+            self.value = []
+            for element in self.elements:
+                value = element.get_value()
+                if value is not None:
+                    self.value.append(value)
+            self.value = value or None
+        else:
+            self.value = value
+
+    def get_value(self):
+        return self.value
+
+    def set_error(self, error):
+        self.error = error
+
+    def has_errors(self):
+        if self.error:
+            return True
+        for element in self.elements:
+            if element.get_error():
+                return True
+        return False
+
+    def _render_element(self, element, hint=None, error=None):
+        r = TemplateIO(html=True)
+        r += htmltext('<tr><td>&nbsp;&nbsp;</td><td>')
+        r += element.render()
+        r += htmltext('</td><td>')
+        r += render_error(element.get_error() or error)
+        r += render_hint(hint)
+        r += htmltext('</td></tr>\n')
+        return r.getvalue()
+
+    def render(self):
+        r = TemplateIO(html=True)
+        r += htmltext('\n<tr><th colspan="3" align="left">')
+        r += self.title or htmltext('&nbsp;')
+        r += htmltext('</th></tr>')
+        r += self._render_element(self.elements[0], self.hint, self.error)
+        for element in self.elements[1:]:
+            r += self._render_element(element)
+        r += htmltext('<tr><td>&nbsp;</td><td>')
+        r += self.add_button.render()
+        r += self.added_elements.render()
+        r += htmltext('</td></tr>')
+        return r.getvalue()
+
+
+class FormTokenWidget(HiddenWidget):
+    def render(self):
+        self.value = get_session().create_form_token()
+        return HiddenWidget.render(self)
+
+
+JAVASCRIPT_MARKUP = htmltext('''\
+<script type="text/javascript">
+<!--
+%s
+// -->
+</script>
+''')
+
+
+class Form:
+    # XXX needs a nice docstring that explains typical usage!
+    """
+    Instance attributes:
+      components : [FormComponent]
+      hidden_widgets = [HiddenWidget]
+      submit_buttons = [SubmitWidget]
+      _names : { name:string : Widget|FormComponent }
+        names used in the form
+    """
+
+    TOKEN_NAME = "_form_id" # name of hidden token widget
+
+    WIDGET_ROW_CLASS = WidgetRow
+
+    def __init__(self, name=None, method="post", action_url=None,
+                 enctype=None, use_tokens=True):
+
+        if method not in ("post", "get"):
+            raise ValueError("Form method must be 'post' or 'get', "
+                             "not %r" % method)
+        self.name = name
+        self.method = method
+        self.action_url = action_url or self._get_default_action_url()
+        self.components = []
+        self.hidden_widgets = []
+        self.submit_buttons = []
+        self._names = {}
+
+        if enctype is not None and enctype not in (
+            "application/x-www-form-urlencoded", "multipart/form-data"):
+            raise ValueError, ("Form enctype must be "
+                               "'application/x-www-form-urlencoded' or "
+                               "'multipart/form-data', not %r" % enctype)
+        self.enctype = enctype
+
+        self.use_form_tokens = False
+        if use_tokens and self.method == "post":
+            config = get_publisher().config
+            if config.form_tokens:
+                # unique token for each form, this prevents many cross-site
+                # attacks and prevents a form from being submitted twice
+                self.add_hidden(self.TOKEN_NAME, None, FormTokenWidget)
+                self.use_form_tokens = True
+
+    def _get_default_action_url(self):
+        request = get_request()
+        action_url = url_quote(request.get_path())
+        query = request.get_environ("QUERY_STRING")
+        if query:
+            action_url += "?" + query
+        return action_url
+
+    def __getitem__(self, name):
+        """(name) -> any
+        Return a component's or widget's value.
+        """
+        try:
+            return self._names[name].get_value()
+        except KeyError:
+            raise KeyError, 'no widget or component named %r' % name
+
+    def is_submitted(self):
+        """() -> bool
+
+        Return true if a form was submitted.
+        """
+        return len(get_request().form) > 0
+
+    def set_error(self, name, error):
+        widget = self._names.get(name)
+        if not widget:
+            raise KeyError, "unknown name %r" % name
+        widget.set_error(error)
+
+    def has_errors(self):
+        """() -> bool
+
+        Return true if form has errors.
+        """
+        for component in self.components:
+            if component.has_errors():
+                return True
+        return False
+
+    def get_submit(self):
+        """() -> string | bool
+
+        Get the name of the submit button that was used to submit the
+        current form.  If the form is submitted but not by a button added by
+        add_submit() then return True.  Otherwise, return False.
+
+        """
+        request = get_request()
+        for button in self.submit_buttons:
+            if request.form.has_key(button.name):
+                return button.name
+        else:
+            if request.form:
+                return True
+            else:
+                return False
+
+    # -- Form population methods ---------------------------------------
+
+    def _add_name(self, name, obj):
+        if self._names.has_key(name):
+            raise ValueError, "form already has '%s' variable" % name
+        self._names[name] = obj
+
+    def add_component(self, klass, name, *args, **kwargs):
+        """(FormComponent, name : string, ...)
+
+        Add a form component object to the form.
+        """
+        if not issubclass(klass, FormComponent):
+            raise TypeError, 'FormComponent subclass required (got %r)' % klass
+        component = apply(klass, (name,) + args, kwargs)
+        self._add_name(name, component)
+        self.components.append(component)
+
+    def add(self, klass, name, value=None,
+             title=None, hint=None, required=False, **args):
+        """(Widget,
+            name : string,
+            value : any = None,
+            title : string = None,
+            hint : string = None,
+            required : boolean = False,
+            ...)
+
+        Create a new widget and add it to the form.  The expected type of
+        'value' also depends on the widget class.  Any extra keyword args are
+        passed to the constructor method.
+        """
+        if issubclass(klass, HiddenWidget):
+            raise TypeError, "use add_hidden() to add hidden widgets"
+        apply(self.add_component, (self.WIDGET_ROW_CLASS, name, klass, value,
+                                   title, hint, required), args)
+
+
+    def add_submit(self, name, value):
+        widget = SubmitWidget(name, value)
+        self._add_name(name, widget)
+        self.submit_buttons.append(widget)
+
+    def add_hidden(self, name, value, klass=HiddenWidget):
+        widget = klass(name, value)
+        self._add_name(name, widget)
+        self.hidden_widgets.append(widget)
+
+    # -- Layout (rendering) methods ------------------------------------
+
+    def render(self):
+        """() -> HTML text
+        Render a form as HTML.
+        """
+        r = TemplateIO(html=True)
+        r += self._render_start()
+        r += self._render_body()
+        r += self._render_finish()
+        return r.getvalue()
+
+    def _render_start(self):
+        r = TemplateIO(html=True)
+        r += htmltag('form', name=self.name, method=self.method,
+                     enctype=self.enctype, action=self.action_url)
+        r += self._render_hidden_widgets()
+        return r.getvalue()
+
+    def _render_finish(self):
+        r = TemplateIO(html=True)
+        r += htmltext('</form>')
+        r += self._render_javascript()
+        return r.getvalue()
+
+    def _render_sep(self, text, line=True):
+        return htmltext('<tr><td colspan="3">%s<strong><big>%s'
+                        '</big></strong></td></tr>') % \
+                                      (line and htmltext('<hr>') or '', text)
+
+    def _render_hidden_widgets(self):
+        r = TemplateIO(html=True)
+        for widget in self.hidden_widgets:
+            r += widget.render()
+        return r.getvalue()
+
+    def _render_submit_buttons(self, ncols=3):
+        r = TemplateIO(html=True)
+        r += htmltext('<tr><td colspan="%d">\n') % ncols
+        for button in self.submit_buttons:
+            r += button.render()
+        r += htmltext('</td></tr>')
+        return r.getvalue()
+
+    def _render_components(self):
+        r = TemplateIO(html=True)
+        for component in self.components:
+            r += component.render()
+        return r.getvalue()
+
+    def _render_error_notice(self):
+        if self.has_errors():
+            r = htmltext('<tr><td colspan="3">'
+                         '<font color="red"><strong>Warning:</strong></font> '
+                         'there were errors processing your form.  '
+                         'See below for details.'
+                         '</td></tr>')
+        else:
+            r = ''
+        return r
+
+    def _render_body(self):
+        r = TemplateIO(html=True)
+        r += htmltext('<table>')
+        r += self._render_error_notice()
+        r += self._render_components()
+        r += self._render_submit_buttons()
+        r += htmltext('</table>')
+        return r.getvalue()
+
+    def _render_javascript(self):
+        """Render javacript code for the form, if any.
+           Insert code lexically sorted by code_id
+        """
+        javascript_code = get_request().response.javascript_code
+        if javascript_code:
+            form_code = []
+            code_ids = javascript_code.keys()
+            code_ids.sort()
+            for code_id in code_ids:
+                code = javascript_code[code_id]
+                if code:
+                    form_code.append(code)
+                    javascript_code[code_id] = ''
+            if form_code:
+                return JAVASCRIPT_MARKUP % htmltext(''.join(form_code))
+        return ''
+

Added: trunk/quixote/form2/widget.py
===================================================================
--- trunk/quixote/form2/widget.py	2003-10-08 21:55:48 UTC (rev 22710)
+++ trunk/quixote/form2/widget.py	2003-10-08 22:18:10 UTC (rev 22711)
@@ -0,0 +1,629 @@
+"""$URL$
+$Id$
+
+Provides the basic web widget classes: Widget itself, plus StringWidget,
+TextWidget, CheckboxWidget, etc.
+"""
+
+import struct
+from types import FloatType, IntType, ListType, StringType, TupleType
+from quixote import get_request
+from quixote.html import htmltext, htmlescape, htmltag, ValuelessAttr
+from quixote.upload import Upload
+
+try:
+    True, False
+except NameError:
+    True = 1
+    False = 0
+
+
+def subname(prefix, name):
+    """Create a unique name for a sub-widget or sub-component."""
+    # $ is nice because it's valid as part of a Javascript identifier
+    return "%s$%s" % (prefix, name)
+
+
+class Widget:
+    """Abstract base class for web widgets.
+
+    Instance attributes:
+      name : string
+      value : any
+      error : string
+
+    Feel free to access these directly; to set them, use the 'set_*()'
+    modifier methods.
+    """
+
+    def __init__(self, name, value=None):
+        assert self.__class__ is not Widget, "abstract class"
+        self.name = name
+        self.error = None
+        request = get_request()
+        if request.form:
+            self._parse(request)
+        else:
+            self.set_value(value)
+        
+    def __repr__(self):
+        return "<%s at %x: %s>" % (self.__class__.__name__,
+                                   id(self),
+                                   self.name)
+
+    def __str__(self):
+        return "%s: %s" % (self.__class__.__name__, self.name)
+
+    def get_name(self):
+        return self.name
+
+    def set_name(self, name):
+        self.name = name
+
+    def get_value(self):
+        return self.value
+
+    def set_value(self, value):
+        self.value = value
+
+    def set_error(self, error):
+        self.error = error
+
+    def get_error(self):
+        return self.error
+
+    def _parse(self, request):
+        # subclasses may override but this is not part of the public API
+        value = request.form.get(self.name)
+        if type(value) is StringType and value.strip():
+            self.value = value
+        else:
+            self.value = None
+
+    def render(self):
+        """render() -> HTML text"""
+        raise NotImplementedError, 'subclass must implement'
+
+
+# class Widget
+
+# -- Fundamental widget types ------------------------------------------
+# These correspond to the standard types of input tag in HTML:
+#   text     StringWidget
+#   password PasswordWidget
+#   radio    RadiobuttonWidget
+#   checkbox CheckboxWidget
+#
+# and also to the other basic form elements:
+#   <textarea>  TextWidget
+#   <select>    SingleSelectWidget
+#   <select multiple>
+#               MultipleSelectWidget
+
+class StringWidget(Widget):
+    """Widget for entering a single string: corresponds to
+    '<input type="text">' in HTML.
+
+    Instance attributes:
+      value : string
+      size : int
+      maxlength : int
+    """
+
+    # This lets PasswordWidget be a trivial subclass
+    HTML_TYPE = "text"
+
+    def __init__(self, name, value=None,
+                 size=None, maxlength=None):
+        Widget.__init__(self, name, value)
+        self.size = size
+        self.maxlength = maxlength
+
+    def render(self, **attributes):
+        return htmltag("input", xml_end=True,
+                       type=self.HTML_TYPE,
+                       name=self.name,
+                       size=self.size,
+                       maxlength=self.maxlength,
+                       value=self.value,
+                       **attributes)
+
+
+class FileWidget(StringWidget):
+    """Subclass of StringWidget for uploading files.
+
+    Instance attributes: none
+    """
+
+    HTML_TYPE = "file"
+
+    def _parse(self, request):
+        parsed_value = request.form.get(self.name)
+        if isinstance(value, Upload):
+            self.value = parsed_value
+        else:
+            self.value = None
+
+
+class PasswordWidget(StringWidget):
+    """Trivial subclass of StringWidget for entering passwords (different
+    widget type because HTML does it that way).
+
+    Instance attributes: none
+    """
+
+    HTML_TYPE = "password"
+
+
+class TextWidget(Widget):
+    """Widget for entering a long, multi-line string; corresponds to
+    the HTML "<textarea>" tag.
+
+    Instance attributes:
+      value : string
+      cols : int
+      rows : int
+      wrap : string
+        (see an HTML book for details on text widget wrap options)
+    """
+
+    def __init__(self, name, value=None, cols=None, rows=None, wrap=None):
+        Widget.__init__(self, name, value)
+        self.cols = cols
+        self.rows = rows
+        self.wrap = wrap
+
+    def _parse(self, request):
+        Widget._parse(self, request)
+        if self.value and self.value.find("\r\n") >= 0:
+            self.value = self.value.replace("\r\n", "\n")
+
+    def render(self):
+        return (htmltag("textarea", name=self.name,
+                        cols=self.cols,
+                        rows=self.rows,
+                        wrap=self.wrap) +
+                htmlescape(self.value or "") +
+                htmltext("</textarea>"))
+
+
+class CheckboxWidget(Widget):
+    """Widget for a single checkbox: corresponds to "<input
+    type=checkbox>".  Do not put multiple CheckboxWidgets with the same
+    name in the same form.
+
+    Instance attributes:
+      value : boolean
+    """
+
+    def _parse(self, request):
+        self.value = request.form.has_key(self.name)
+
+    def render(self):
+        return htmltag("input", xml_end=True,
+                       type="checkbox",
+                       name=self.name,
+                       value="yes",
+                       checked=self.value and ValuelessAttr or None)
+
+
+
+class SelectWidget(Widget):
+    """Widget for single or multiple selection; corresponds to
+    <select name=...>
+      <option value="Foo">Foo</option>
+      ...
+    </select>
+
+    Instance attributes:
+      options : [ (value:any, description:any, key:string) ]
+      value : any
+        The value is None or an element of dict(options.values()).
+      size : int
+        The number of options that should be presented without scrolling.
+    """
+
+    def __init__(self, name, value=None,
+                 allowed_values=None,
+                 descriptions=None,
+                 options=None,
+                 size=None,
+                 sort=True,
+                 verify_selection=True):
+        assert self.__class__ is not SelectWidget, "abstract class"
+        self.options = []
+        # if options passed, cannot pass allowed_values or descriptions
+        if allowed_values is not None:
+            assert options is None, (
+                'cannot pass both allowed_values and options')
+            assert allowed_values, (
+                'cannot pass empty allowed_values list')
+            self.set_allowed_values(allowed_values, descriptions, sort)
+        elif options is not None:
+            assert descriptions is None, (
+                'cannot pass both options and descriptions')
+            assert options, (
+                'cannot pass empty options list')
+            self.set_options(options, sort)
+        self.verify_selection = verify_selection
+        self.size = size
+        Widget.__init__(self, name, value)
+
+    def get_allowed_values(self):
+        return [item[0] for item in self.options]
+
+    def get_descriptions(self):
+        return [item[1] for item in self.options]
+
+    def set_value(self, value):
+        self.value = None
+        for object, description, key in self.options:
+            if value == object:
+                self.value = value
+                break
+
+    def _generate_keys(self, values, descriptions):
+        """Called if no keys were provided.  Try to generate a set of keys
+        that will be consistent between rendering and parsing.
+        """
+        # try to use ZODB object IDs
+        keys = []
+        for value in values:
+            if value is None:
+                oid = ""
+            else:
+                oid = getattr(value, "_p_oid", None)
+                if not oid:
+                    break
+                hi, lo = struct.unpack(">LL", oid)
+                oid = "%x" % ((hi << 32) | lo)
+            keys.append(oid)
+        else:
+            # found OID for every value
+            return keys
+        # can't use OIDs, try using descriptions
+        used_keys = {}
+        keys = map(str, descriptions)
+        for key in keys:
+            if used_keys.has_key(key):
+                raise ValueError, "duplicated descriptions (provide keys)"
+            used_keys[key] = 1
+        return keys
+
+    def set_options(self, options, sort=False):
+        """(options: [objects:any], sort=False)
+         or
+           (options: [(object:any, description:any)], sort=False)
+         or
+           (options: [(object:any, description:any, key:any)], sort=False)
+        """
+
+        """
+        Set the options list.  The list of options can be a list of objects, in
+        which case the descriptions default to map(htmlescape, objects)
+        applying htmlescape() to each description and
+        key.
+        If keys are provided they must be distinct.  If the sort keyword
+        argument is true, sort the options by case-insensitive lexicographic
+        order of descriptions, except that options with value None appear
+        before others.
+        """
+        if options:
+            first = options[0]
+            values = []
+            descriptions = []
+            keys = []
+            if type(first) is TupleType:
+                if len(first) == 2:
+                    for value, description in options:
+                        values.append(value)
+                        descriptions.append(description)
+                elif len(first) == 3:
+                    for value, description, key in options:
+                        values.append(value)
+                        descriptions.append(description)
+                        keys.append(str(key))
+                else:
+                    raise ValueError, 'invalid options %r' % options
+            else:
+                values = descriptions = options
+
+            if not keys:
+                keys = self._generate_keys(values, descriptions)
+
+            options = zip(values, descriptions, keys)
+
+            if sort:
+                def make_sort_key(option):
+                    value, description, key = option
+                    if value is None:
+                        return ('', option)
+                    else:
+                        return (str(description).lower(), option)
+                doptions = map(make_sort_key, options)
+                doptions.sort()
+                options = [item[1] for item in doptions]
+        self.options = options
+
+    def _parse_single_selection(self, parsed_key):
+        for value, description, key in self.options:
+            if key == parsed_key:
+                return value
+        else:
+            if self.verify_selection:
+                self.error = "invalid value selected"
+                return None
+            elif self.options:
+                return self.options[0][0]
+            else:
+                return None
+
+    def set_allowed_values(self, allowed_values, descriptions=None,
+                           sort=False):
+        """(allowed_values:[any], descriptions:[any], sort:boolean=False)
+
+        Set the options for this widget.  The allowed_values and descriptions
+        parameters must be sequences of the same length.  The sort option
+        causes the options to be sorted using case-insensitive lexicographic
+        order of descriptions, except that options with value None appear
+        before others.
+        """
+        if descriptions is None:
+            self.set_options(allowed_values, sort)
+        else:
+            assert len(descriptions) == len(allowed_values)
+            self.set_options(zip(allowed_values, descriptions), sort)
+
+    def is_selected(self, value):
+        return value == self.value
+
+    def render(self):
+        if self.SELECT_TYPE == "multiple_select":
+            multiple = ValuelessAttr
+        else:
+            multiple = None
+        if self.SELECT_TYPE == "option_select":
+            onchange = "submit()"
+        else:
+            onchange = None
+        tags = [htmltag("select", name=self.name,
+                        multiple=multiple, onchange=onchange,
+                        size=self.size)]
+        for object, description, key in self.options:
+            if self.is_selected(object):
+                selected = ValuelessAttr
+            else:
+                selected = None
+            if description is None:
+                description = ""
+            r = htmltag("option", value=key, selected=selected)
+            tags.append(r + htmlescape(description) + htmltext('</option>'))
+        tags.append(htmltext("</select>"))
+        return htmltext("\n").join(tags)
+
+
+class SingleSelectWidget(SelectWidget):
+    """Widget for single selection.
+    """
+
+    SELECT_TYPE = "single_select"
+
+    def _parse(self, request):
+        parsed_key = request.form.get(self.name)
+        if parsed_key:
+            if type(parsed_key) is ListType:
+                self.error = "cannot select multiple values"
+            else:
+                self.value = self._parse_single_selection(parsed_key)
+        else:
+            self.value = None
+
+
+class RadiobuttonsWidget(SingleSelectWidget):
+    """Widget for a *set* of related radiobuttons -- all have the
+    same name, but different values (and only one of those values
+    is returned by the whole group).
+
+    Instance attributes:
+      delim : string = None
+        string to emit between each radiobutton in the group.  If
+        None, a single newline is emitted.
+    """
+
+    SELECT_TYPE = "radiobuttons"
+
+    def __init__(self, name, value=None,
+                 allowed_values=None,
+                 descriptions=None,
+                 options=None,
+                 delim=None):
+        SingleSelectWidget.__init__(self, name, value, allowed_values,
+                                    descriptions, options)
+        if delim is None:
+            self.delim = "\n"
+        else:
+            self.delim = delim
+
+    def render(self, request):
+        tags = []
+        for object, description, key in self.options:
+            if self.is_selected(object):
+                checked = ValuelessAttr
+            else:
+                checked = None
+            r = htmltag("input",
+                        type="radio",
+                        name=self.name,
+                        value=key,
+                        checked=checked)
+            tags.append(r + htmlescape(description) + htmltext('</input>'))
+        return htmlescape(self.delim).join(tags)
+
+
+class MultipleSelectWidget(SelectWidget):
+    """Widget for multiple selection.
+
+    Instance attributes:
+      value : [any]
+        for multipe selects, the value is None or a list of
+        elements from dict(self.options).values()
+    """
+
+    SELECT_TYPE = "multiple_select"
+
+    def set_value(self, value):
+        allowed_values = self.get_allowed_values()
+        if value in allowed_values:
+            self.value = [ value ]
+        elif type(value) in (ListType, TupleType):
+            self.value = [ element
+                           for element in value
+                           if element in allowed_values ] or None
+        else:
+            self.value = None
+
+    def is_selected(self, value):
+        if self.value is None:
+            return value is None
+        else:
+            return value in self.value
+
+    def _parse(self, request):
+        parsed_keys = request.form.get(self.name)
+        if parsed_keys:
+            if type(parsed_keys) is ListType:
+                self.value =  [value
+                               for value, description, key in self.options
+                               if key in parsed_keys] or None
+            else:
+                self.value = [self._parse_single_selection(parsed_keys)]
+        else:
+            self.value = None
+
+
+class SubmitWidget(Widget):
+    """
+    Instance attributes:
+      value : boolean
+    """
+
+    def __init__(self, name, value=None):
+        self.name = name
+        self.error = None
+        # slightly different behavior here, we always render the
+        # tag using the 'value' passed in as a parameter.  The 'value'
+        # attribute is a boolean that is true if the button's name appears
+        # in the request.
+        self.label = value
+        request = get_request()
+        if request.form:
+            self._parse(request)
+        else:
+            self.value = False
+
+    def set_error(self, error):
+        return TypeError, 'error not allowed on submit buttons'
+
+    def render(self):
+        value = (self.label and htmlescape(self.label) or None)
+        return htmltag("input", xml_end=True, type="submit",
+                       name=self.name, value=value)
+
+    def _parse(self, request):
+        self.value = request.form.has_key(self.name)
+
+
+
+class HiddenWidget(Widget):
+    """
+    Instance attributes:
+      value : string
+    """
+
+    def set_error(self, error):
+        return TypeError, 'error not allowed on hidden widgets'
+
+    def render(self):
+        if self.value is None:
+            value = None
+        else:
+            value = htmlescape(self.value)
+        return htmltag("input", xml_end=True,
+                       type="hidden",
+                       name=self.name,
+                       value=value)
+
+
+# -- Derived widget types ----------------------------------------------
+# (these don't correspond to fundamental widget types in HTML,
+# so they're separated)
+
+class NumberWidget(StringWidget):
+    """
+    Instance attributes: none
+    """
+
+    # Parameterize the number type (either float or int) through
+    # these class attributes:
+    TYPE_OBJECT = None                  # eg. int, float
+    TYPE_ERROR = None                   # human-readable error message
+    TYPE_CONVERTER = None               # eg. int(), float()
+
+    def __init__(self, name,
+                 value=None,
+                 size=None, maxlength=None):
+        assert self.__class__ is not NumberWidget, "abstract class"
+        assert value is None or type(value) is self.TYPE_OBJECT, (
+            "form value '%s' not a %s: got %r" % (name,
+                                                  self.TYPE_OBJECT,
+                                                  value))
+        StringWidget.__init__(self, name, value, size, maxlength)
+
+    def _parse(self, request):
+        StringWidget._parse(self, request)
+        if self.value is not None:
+            try:
+                self.value = self.TYPE_CONVERTER(self.value)
+            except ValueError:
+                self.error = self.TYPE_ERROR
+
+
+class FloatWidget(NumberWidget):
+    """
+    Instance attributes:
+      value : float
+    """
+    TYPE_OBJECT = FloatType
+    TYPE_CONVERTER = float
+    TYPE_ERROR = "must be a number"
+
+
+class IntWidget(NumberWidget):
+    """
+    Instance attributes:
+      value : int
+    """
+    TYPE_OBJECT = IntType
+    TYPE_CONVERTER = int
+    TYPE_ERROR = "must be an integer"
+
+
+class OptionSelectWidget(SingleSelectWidget):
+    """Widget for single selection with automatic submission and early
+    parsing.  This widget parses the request when it is created.  This
+    allows its value to be used to decide what other widgets need to be
+    created in a form.  It's a powerful feature but it can be hard to
+    understand what's going on.
+
+    Instance attributes:
+      value : any
+    """
+
+    SELECT_TYPE = "option_select"
+
+    def render(self, request):
+        return (SingleSelectWidget.render(self) +
+                htmltext('<noscript>'
+                         '<input type="submit" name="" value="apply" />'
+                         '</noscript>'))
+