[Checkins] SVN: zope.httpform/ Began extracting zope.publisher's form parser into a separate package.

Shane Hathaway shane at hathawaymix.org
Thu Feb 5 17:32:41 EST 2009


Log message for revision 96162:
  Began extracting zope.publisher's form parser into a separate package.
  

Changed:
  A   zope.httpform/
  A   zope.httpform/trunk/
  A   zope.httpform/trunk/CHANGES.txt
  A   zope.httpform/trunk/README.txt
  A   zope.httpform/trunk/bootstrap.py
  A   zope.httpform/trunk/buildout.cfg
  A   zope.httpform/trunk/setup.py
  A   zope.httpform/trunk/src/
  A   zope.httpform/trunk/src/zope/
  A   zope.httpform/trunk/src/zope/__init__.py
  A   zope.httpform/trunk/src/zope/httpform/
  A   zope.httpform/trunk/src/zope/httpform/README.txt
  A   zope.httpform/trunk/src/zope/httpform/__init__.py
  A   zope.httpform/trunk/src/zope/httpform/interfaces.py
  A   zope.httpform/trunk/src/zope/httpform/parser.py
  A   zope.httpform/trunk/src/zope/httpform/tests.py
  A   zope.httpform/trunk/src/zope/httpform/typeconv.py

-=-
Added: zope.httpform/trunk/README.txt
===================================================================
--- zope.httpform/trunk/README.txt	                        (rev 0)
+++ zope.httpform/trunk/README.txt	2009-02-05 22:32:40 UTC (rev 96162)
@@ -0,0 +1,65 @@
+
+
+This package provides a WSGI-oriented HTTP form parser with interesting
+features to make form handling easier.  This functionality has lived
+for a long time inside Zope's publisher, but has been broken out into
+a separate package to make it easier to test, explain, understand, and use.
+
+The parser uses Python's standard `cgi.FieldStorage` class, but is
+easier to use than FieldStorage.  The parser converts field names and
+values to Unicode, handles file uploads in a graceful manner, and allows
+variable name suffixes that tell the parser how to handle each variable.
+The available suffixes are:
+
+    - `:int`      -- convert to an integer
+    - `:float`    -- convert to a float
+    - `:long`     -- convert to a long integer
+    - `:string`   -- convert to a string instead of Unicode
+    - `:required` -- raise ValueError if the field is not provided
+    - `:tokens`   -- split the input on whitespace characters
+    - `:lines`    -- split multiline input into a list of lines
+    - `:text`     -- convert multiline text to a string instead of Unicode
+    - `:boolean`  -- true if nonempty, false if empty
+    - `:list`     -- make a list even if there is only one value
+    - `:tuple`    -- make a tuple
+    - `:action`   -- specify the form action
+    - `:method`   -- same as `:action`
+    - `:default`  -- provide a default value
+    - `:record`   -- generate a record object
+    - `:records`  -- generate a list of record object
+    - `:ignore_empty`   -- discard the field value if it's empty
+    - `:default_action` -- specifies a default form action
+    - `:default_method` -- same as `:default_action`
+
+Here are some examples of ways to use these suffixes.
+
+* Using this package, you can provide a default for a field in an HTML form::
+
+    <input type="text" name="country:ignore_empty" />
+    <input type="hidden" name="country:default" value="Chile" />
+
+  The form data returned by the parser will have a Unicode value for the
+  `country` field, even if the user does not enter anything into the text box.
+
+* You can ensure that certain variables are placed
+  in a list, even when only one value is selected::
+
+    <select name="cars:list" multiple="multiple">
+    <option value="volvo">Volvo</option>
+    <option value="saab">Saab</option>
+    <option value="mercedes">Mercedes</option>
+    <option value="audi">Audi</option>
+    </select>
+
+* You can group data into record objects, which is very useful for complex
+  forms::
+
+    <input type="text" name="shipping.name:record" />
+    <input type="text" name="shipping.address:record" />
+    <input type="text" name="shipping.phone:record" />
+    <input type="text" name="billing.name:record" />
+    <input type="text" name="billing.address:record" />
+    <input type="text" name="billing.phone:record" />
+
+See `src/zope/httpform/README.txt` for a demonstration and test
+of all features.

Added: zope.httpform/trunk/bootstrap.py
===================================================================
--- zope.httpform/trunk/bootstrap.py	                        (rev 0)
+++ zope.httpform/trunk/bootstrap.py	2009-02-05 22:32:40 UTC (rev 96162)
@@ -0,0 +1,54 @@
+##############################################################################
+#
+# Copyright (c) 2007 Zope Corporation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+##############################################################################
+"""Bootstrap a buildout-based project
+
+Simply run this script in a directory containing a buildout.cfg.
+The script accepts buildout command-line options, so you can
+use the -c option to specify an alternate configuration file.
+
+$Id: bootstrap.py,v 1.1 2009/01/10 15:13:39 shane Exp $
+"""
+
+import os, shutil, sys, tempfile, urllib2
+
+tmpeggs = tempfile.mkdtemp()
+
+ez = {}
+exec urllib2.urlopen('http://peak.telecommunity.com/dist/ez_setup.py'
+                     ).read() in ez
+ez['use_setuptools'](to_dir=tmpeggs, download_delay=0)
+
+# use pkg_resources from the package just downloaded.
+del sys.modules['pkg_resources']
+import pkg_resources
+
+cmd = 'from setuptools.command.easy_install import main; main()'
+if sys.platform == 'win32':
+    cmd = '"%s"' % cmd # work around spawn lamosity on windows
+
+ws = pkg_resources.working_set
+assert os.spawnle(
+    os.P_WAIT, sys.executable, sys.executable,
+    '-c', cmd, '-mqNxd', tmpeggs, 'zc.buildout',
+    dict(os.environ,
+         PYTHONPATH=
+         ws.find(pkg_resources.Requirement.parse('setuptools')).location
+         ),
+    ) == 0
+
+ws.add_entry(tmpeggs)
+ws.require('zc.buildout')
+import zc.buildout.buildout
+zc.buildout.buildout.main(sys.argv[1:] + ['bootstrap'])
+shutil.rmtree(tmpeggs)

Added: zope.httpform/trunk/buildout.cfg
===================================================================
--- zope.httpform/trunk/buildout.cfg	                        (rev 0)
+++ zope.httpform/trunk/buildout.cfg	2009-02-05 22:32:40 UTC (rev 96162)
@@ -0,0 +1,23 @@
+[buildout]
+develop = .
+parts = test python coverage-test coverage-report
+
+[test]
+recipe = zc.recipe.testrunner
+eggs = zope.httpform
+
+[python]
+recipe = zc.recipe.egg
+eggs = zope.httpform
+interpreter = python
+
+[coverage-test]
+recipe = zc.recipe.testrunner
+eggs = zope.httpform
+defaults = ['--coverage', '../../coverage']
+
+[coverage-report]
+recipe = zc.recipe.egg
+eggs = z3c.coverage
+scripts = coverage=coverage-report
+arguments = ('coverage', 'coverage/report')

Added: zope.httpform/trunk/setup.py
===================================================================
--- zope.httpform/trunk/setup.py	                        (rev 0)
+++ zope.httpform/trunk/setup.py	2009-02-05 22:32:40 UTC (rev 96162)
@@ -0,0 +1,52 @@
+##############################################################################
+#
+# Copyright (c) 2009 Zope Corporation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+##############################################################################
+"""HTTP Form Data Parser setup
+
+$Id$
+"""
+
+import os
+from setuptools import setup, find_packages
+
+def read(*rnames):
+    return open(os.path.join(os.path.dirname(__file__), *rnames)).read()
+
+long_description = (read('README.txt') +
+                    '\n\n' +
+                    read('CHANGES.txt'))
+
+setup(
+    name='zope.httpform',
+    version='1.0dev',
+    url='http://pypi.python.org/pypi/zope.httpform',
+    license='ZPL 2.1',
+    author='Zope Corporation and Contributors',
+    author_email='zope-dev at zope.org',
+    description="HTTP Form Data Parser",
+    long_description=long_description,
+
+    # Get more from http://www.python.org/pypi?%3Aaction=list_classifiers
+    classifiers=['Programming Language :: Python',
+                'Environment :: Web Environment',
+                'Topic :: Internet :: WWW/HTTP :: WSGI :: Application',
+                'Framework :: Zope3',
+                ],
+
+    packages=find_packages('src'),
+    package_dir={'': 'src'},
+    install_requires=[
+        'setuptools',
+        'zope.interface',
+        ],
+    )

Added: zope.httpform/trunk/src/zope/__init__.py
===================================================================
--- zope.httpform/trunk/src/zope/__init__.py	                        (rev 0)
+++ zope.httpform/trunk/src/zope/__init__.py	2009-02-05 22:32:40 UTC (rev 96162)
@@ -0,0 +1,7 @@
+# this is a namespace package
+try:
+    import pkg_resources
+    pkg_resources.declare_namespace(__name__)
+except ImportError:
+    import pkgutil
+    __path__ = pkgutil.extend_path(__path__, __name__)

Added: zope.httpform/trunk/src/zope/httpform/README.txt
===================================================================
--- zope.httpform/trunk/src/zope/httpform/README.txt	                        (rev 0)
+++ zope.httpform/trunk/src/zope/httpform/README.txt	2009-02-05 22:32:40 UTC (rev 96162)
@@ -0,0 +1,355 @@
+
+Tests of zope.httpform
+======================
+
+Basic Usage
+-----------
+
+The FormParser class expects a subset of a WSGI environment.  Start with
+a simple form.
+
+    >>> import pprint
+    >>> from zope.httpform import FormParser
+    >>> env = {'REQUEST_METHOD': 'GET', 'QUERY_STRING': 'x=1&y=my+data&z='}
+    >>> pprint.pprint(FormParser(env).parse())
+    {u'x': u'1', u'y': u'my data', u'z': u''}
+
+Now let's start using some of the features of this package.  Use the `:int`
+suffix on the variable name:
+
+    >>> env['QUERY_STRING'] = 'x:int=1&y:int=2'
+    >>> pprint.pprint(FormParser(env).parse())
+    {u'x': 1, u'y': 2}
+
+Floating point and long integers work too:
+
+    >>> env['QUERY_STRING'] = 'x:float=1&y:float=2&z:long=3&zz:long=4L'
+    >>> pprint.pprint(FormParser(env).parse())
+    {u'x': 1.0, u'y': 2.0, u'z': 3L, u'zz': 4L}
+
+The `:boolean` suffix is good for HTML checkboxes:
+
+    >>> env['QUERY_STRING'] = 'x:boolean=checked&y:boolean='
+    >>> pprint.pprint(FormParser(env).parse())
+    {u'x': True, u'y': False}
+
+Lists and Tuples
+----------------
+
+What happens if variables get repeated?
+
+    >>> env['QUERY_STRING'] = 'x:int=1&x:float=2'
+    >>> pprint.pprint(FormParser(env).parse())
+    {u'x': [1, 2.0]}
+
+That's reasonable, but it's even better to use another suffix so that
+certain variables are returned as a list even when they occur only once.
+
+    >>> env['QUERY_STRING'] = 'x:int:list=1'
+    >>> pprint.pprint(FormParser(env).parse())
+    {u'x': [1]}
+
+Another variation:
+
+    >>> env['QUERY_STRING'] = 'x:list:int=1'
+    >>> pprint.pprint(FormParser(env).parse())
+    {u'x': [1]}
+
+Empty values are preserved:
+
+    >>> env['QUERY_STRING'] = 'x:list=a&x:list='
+    >>> pprint.pprint(FormParser(env).parse())
+    {u'x': [u'a', u'']}
+
+Order is preserved:
+
+    >>> env['QUERY_STRING'] = 'x:list=c&x:list=b&x:list=a'
+    >>> pprint.pprint(FormParser(env).parse())
+    {u'x': [u'c', u'b', u'a']}
+
+Empty values are not preserved if you use the ignore_empty suffix:
+
+    >>> env['QUERY_STRING'] = 'x:list:ignore_empty=a&x:list:ignore_empty='
+    >>> pprint.pprint(FormParser(env).parse())
+    {u'x': [u'a']}
+
+Use `:tuple` to generate a tuple instead of a list.  Note that the order
+of the field values is always preserved.
+
+    >>> env['QUERY_STRING'] = 'x:int:tuple=2&x:int:tuple=1'
+    >>> pprint.pprint(FormParser(env).parse())
+    {u'x': (2, 1)}
+
+Default Values
+--------------
+
+Sometimes it's useful for a form to provide a default value.
+
+    >>> env['QUERY_STRING'] = 'country:default=United+States'
+    >>> pprint.pprint(FormParser(env).parse())
+    {u'country': u'United States'}
+    >>> env['QUERY_STRING'] = 'country:default=United+States&country=Ireland'
+    >>> pprint.pprint(FormParser(env).parse())
+    {u'country': u'Ireland'}
+
+An empty value overrides a default value, unless the potentially
+empty value uses the `:ignore_empty` suffix.
+
+    >>> env['QUERY_STRING'] = 'country:default=US&country='
+    >>> pprint.pprint(FormParser(env).parse())
+    {u'country': u''}
+    >>> env['QUERY_STRING'] = 'country:default=US&country:ignore_empty='
+    >>> pprint.pprint(FormParser(env).parse())
+    {u'country': u'US'}
+
+A default value can take the place of a list.
+
+    >>> env['QUERY_STRING'] = 'x:default=null'
+    >>> pprint.pprint(FormParser(env).parse())
+    {u'x': u'null'}
+    >>> env['QUERY_STRING'] = 'x:int:list=1&x:default=null'
+    >>> pprint.pprint(FormParser(env).parse())
+    {u'x': [1]}
+
+Required Values
+---------------
+
+The `:required` suffix raises `ValueError` if the field is left empty.
+
+    >>> env['QUERY_STRING'] = 'x:required='
+    >>> pprint.pprint(FormParser(env).parse())
+    Traceback (most recent call last):
+    ...
+    ValueError: No input for required field
+
+Don't leave it empty.
+
+    >>> env['QUERY_STRING'] = 'x:required=123'
+    >>> pprint.pprint(FormParser(env).parse())
+    {u'x': u'123'}
+    >>> env['QUERY_STRING'] = 'x:int:required=123'
+    >>> pprint.pprint(FormParser(env).parse())
+    {u'x': 123}
+
+Simple Text Handling
+--------------------
+
+Use `:tokens` to split the input on whitespace.
+
+    >>> env['QUERY_STRING'] = 'x:tokens=a+b++c%0Dd'
+    >>> pprint.pprint(FormParser(env).parse())
+    {u'x': [u'a', u'b', u'c', u'd']}
+
+Use `:text` to normalize multiline input.  This is helpful for textarea tags.
+
+    >>> env['QUERY_STRING'] = 'stuff:text=line1%0D%0Aline2%0D%0A'
+    >>> pprint.pprint(FormParser(env).parse())
+    {u'stuff': u'line1\nline2\n'}
+
+Use `:lines` to convert multiline input to a series of lines.
+
+    >>> env['QUERY_STRING'] = 'x:lines=line1%0D%0A%0D%0Aline2%0D%0A'
+    >>> pprint.pprint(FormParser(env).parse())
+    {u'x': [u'line1', u'', u'line2']}
+
+Records
+-------
+
+The :record suffix produces a record object.
+
+    >>> env['QUERY_STRING'] = 'x.a:int:record=1&x.b:int:record=2'
+    >>> form = FormParser(env).parse()
+    >>> pprint.pprint(form)
+    {u'x': {'a': 1, 'b': 2}}
+
+You can access record values using either attribute or item access.
+
+    >>> x = form['x']
+    >>> from zope.httpform.interfaces import IFormRecord
+    >>> IFormRecord.providedBy(x)
+    True
+    >>> x.a
+    1
+    >>> x['a']
+    1
+
+Some attribute names would clash with mapping method names and are thus
+disallowed.
+
+    >>> env['QUERY_STRING'] = 'x.keys:record='
+    >>> FormParser(env).parse()
+    Traceback (most recent call last):
+    ...
+    AttributeError: Illegal record attribute name: keys
+
+Records are useful for address information, for example:
+
+    >>> q = 'shipping.address1:record=Apt+1'
+    >>> q += '&shipping.address2:record=75+First+St'
+    >>> q += '&billing.address1:record=Apt+29'
+    >>> q += '&billing.address2:record=75+First+St'
+    >>> env['QUERY_STRING'] = q
+    >>> pprint.pprint(FormParser(env).parse())
+    {u'billing': {'address1': u'Apt 29', 'address2': u'75 First St'},
+     u'shipping': {'address1': u'Apt 1', 'address2': u'75 First St'}}
+
+The :records suffix produces multiple record objects.
+
+    >>> q = 'points.x:float:records=1'
+    >>> q += '&points.y:float:records=2'
+    >>> q += '&points.x:float:records=11'
+    >>> q += '&points.y:float:records=-2'
+    >>> env['QUERY_STRING'] = q
+    >>> pprint.pprint(FormParser(env).parse())
+    {u'points': [{'x': 1.0, 'y': 2.0}, {'x': 11.0, 'y': -2.0}]}
+
+Actions
+-------
+
+The value of the field named :action is stored as an attribute of
+FormParser.  Zope uses the action as the name of a method to call,
+but other packages can use the action any other way they need.
+
+By default there is no action:
+
+    >>> env['QUERY_STRING'] = ''
+    >>> parser = FormParser(env)
+    >>> parser.parse()
+    {}
+    >>> parser.action is None
+    True
+
+Here's an action:
+
+    >>> env['QUERY_STRING'] = 'y:int=1&:action=getX'
+    >>> parser = FormParser(env)
+    >>> pprint.pprint(parser.parse())
+    {u'': u'getX', u'y': 1}
+    >>> parser.action
+    u'getX'
+
+The value of the action field can be provided by the name of the field.
+This is an odd feature, but it's useful for distinguishing between multiple
+submit buttons in a form, since HTML forms submit the text on the submit
+button as the field value, and the text on the submit button is often
+localized.
+
+    >>> env['QUERY_STRING'] = 'stop:action=Parar'
+    >>> parser = FormParser(env)
+    >>> pprint.pprint(parser.parse())
+    {u'stop': u'Parar'}
+    >>> parser.action
+    u'stop'
+
+The `:method` suffix is a synonym.
+
+    >>> env['QUERY_STRING'] = 'stop:method=Parar'
+    >>> parser = FormParser(env)
+    >>> pprint.pprint(parser.parse())
+    {u'stop': u'Parar'}
+    >>> parser.action
+    u'stop'
+
+A default action can be provided.
+
+    >>> env['QUERY_STRING'] = ':default_action=next'
+    >>> parser = FormParser(env)
+    >>> pprint.pprint(parser.parse())
+    {u'': u'next'}
+    >>> parser.action
+    u'next'
+    >>> env['QUERY_STRING'] = 'next:default_action=&prev:action='
+    >>> parser = FormParser(env)
+    >>> pprint.pprint(parser.parse())
+    {u'next': u'', u'prev': u''}
+    >>> parser.action
+    u'prev'
+
+URL Encoded POST
+----------------
+
+This package accepts POST data in addition to query strings.
+
+    >>> from cStringIO import StringIO
+    >>> input_fp = StringIO("x:int=1&y:int=2")
+    >>> env = {'REQUEST_METHOD': 'POST',
+    ...        'CONTENT_TYPE': 'application/x-www-form-urlencoded',
+    ...        'wsgi.input': input_fp}
+    >>> pprint.pprint(FormParser(env).parse())
+    {u'x': 1, u'y': 2}
+
+The query string is ignored on POST.
+
+    >>> env = {'REQUEST_METHOD': 'POST', 'QUERY_STRING': 'x=1'}
+    >>> pprint.pprint(FormParser(env).parse())
+    {}
+
+File Upload
+-----------
+
+Here is an example of what browsers send when users upload a file using
+an HTML form:
+
+    >>> content_type = 'multipart/form-data; boundary=AaB03x'
+    >>> input_body="""\
+    ... --AaB03x
+    ... content-disposition: form-data; name="field1:list"
+    ...
+    ... Joe Blow
+    ... --AaB03x
+    ... content-disposition: form-data; name="pics"; filename="file1.txt"
+    ... Content-Type: text/plain
+    ...
+    ... I am file1.txt.
+    ... --AaB03x--
+    ... """
+
+Parse that form.
+
+    >>> env = {'REQUEST_METHOD': 'POST',
+    ...        'CONTENT_TYPE': content_type,
+    ...        'wsgi.input': StringIO(input_body)}
+    >>> form = FormParser(env).parse()
+    >>> pprint.pprint(form)
+    {u'field1': [u'Joe Blow'],
+     u'pics': <zope.httpform.parser.FileUpload object at ...>}
+
+Let's take a good look at the uploaded file.  It has RFC 822 headers and
+a content body.
+
+    >>> pics = form['pics']
+    >>> from zope.httpform.interfaces import IFileUpload
+    >>> IFileUpload.providedBy(pics)
+    True
+    >>> pics.read()
+    'I am file1.txt.'
+    >>> pics.filename
+    u'file1.txt'
+    >>> pics.headers
+    <rfc822.Message instance at ...>
+    >>> pics.headers['Content-Type']
+    'text/plain'
+    >>> pprint.pprint(dict(pics.headers))
+    {'content-disposition': 'form-data; name="pics"; filename="file1.txt"',
+     'content-type': 'text/plain'}
+
+You can use the :string name suffix if you expect the file to be small and
+you want just the content body.  The content body is always a byte stream,
+not a Unicode string.
+
+    >>> content_type = 'multipart/form-data; boundary=AaB03x'
+    >>> input_body="""\
+    ... --AaB03x
+    ... content-disposition: form-data; name="pics:string"; filename="file1.txt"
+    ... Content-Type: text/plain
+    ...
+    ... I am file1.txt.
+    ... --AaB03x--
+    ... """
+    >>> env = {'REQUEST_METHOD': 'POST',
+    ...        'CONTENT_TYPE': content_type,
+    ...        'wsgi.input': StringIO(input_body)}
+    >>> form = FormParser(env).parse()
+    >>> pprint.pprint(form)
+    {u'pics': 'I am file1.txt.'}
+

Added: zope.httpform/trunk/src/zope/httpform/__init__.py
===================================================================
--- zope.httpform/trunk/src/zope/httpform/__init__.py	                        (rev 0)
+++ zope.httpform/trunk/src/zope/httpform/__init__.py	2009-02-05 22:32:40 UTC (rev 96162)
@@ -0,0 +1,4 @@
+
+from zope.httpform.parser import FormParser
+
+__all__ = ('FormParser',)

Added: zope.httpform/trunk/src/zope/httpform/interfaces.py
===================================================================
--- zope.httpform/trunk/src/zope/httpform/interfaces.py	                        (rev 0)
+++ zope.httpform/trunk/src/zope/httpform/interfaces.py	2009-02-05 22:32:40 UTC (rev 96162)
@@ -0,0 +1,70 @@
+##############################################################################
+#
+# Copyright (c) 2009 Zope Corporation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+##############################################################################
+"""Interfaces for the zope.httpform package.
+
+$Id$
+"""
+
+from zope.interface import Interface
+from zope.interface import Attribute
+from zope.interface.common.mapping import IExtendedReadMapping
+
+
+class IFormParser(Interface):
+    """Parses a form and holds the result."""
+
+    def parse():
+        """Parse the form data and return it as a mapping.
+
+        Before parsing the form data, this method verifies the
+        WSGI environment contains valid form data.  If it does not,
+        this method returns an empty mapping.
+
+        Returns the mapping of form data.
+        """
+
+    form = Attribute("form",
+        """Mapping containing the parsed form data""")
+
+    action = Attribute("action",
+        """The :method or :action specified in the form data.
+
+        Defaults to None.
+        """)
+
+class IFormRecord(IExtendedReadMapping):
+    """A record parsed from a form.
+
+    The form parser produces IFormRecord objects when forms contain
+    :record values, such as this query string::
+
+      point.x:int:record=10&point.y:int:record=20
+
+    The record data can be retrieved through either item or attribute
+    access.  Record attributes must not start with an underscore
+    and must not match dictionary method names such as 'keys'.
+    """
+
+class IFileUpload(Interface):
+    """Holds an uploaded file.
+
+    Objects providing IFileUpload also have the standard file
+    methods (read(), close(), etc.) so they can be used as normal files.
+    """
+
+    headers = Attribute("headers",
+        """A dictionary containing the file upload headers""")
+
+    filename = Attribute("filename",
+        """The name of the uploaded file, in Unicode""")

Added: zope.httpform/trunk/src/zope/httpform/parser.py
===================================================================
--- zope.httpform/trunk/src/zope/httpform/parser.py	                        (rev 0)
+++ zope.httpform/trunk/src/zope/httpform/parser.py	2009-02-05 22:32:40 UTC (rev 96162)
@@ -0,0 +1,397 @@
+##############################################################################
+#
+# Copyright (c) 2001, 2002 Zope Corporation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+##############################################################################
+"""HTTP form parser that supports file uploads, Unicode, and various suffixes.
+
+$Id$
+"""
+
+from cgi import FieldStorage
+from cStringIO import StringIO
+import re
+import tempfile
+from zope.interface import implements
+from zope.interface.common.mapping import IExtendedReadMapping
+
+from zope.httpform.interfaces import IFormParser
+from zope.httpform.interfaces import IFormRecord
+from zope.httpform.interfaces import IFileUpload
+from zope.httpform.typeconv import query_converter
+
+_type_format = re.compile('([a-zA-Z][a-zA-Z0-9_]+|\\.[xy])$')
+
+# Flag Constants
+SEQUENCE = 1
+DEFAULT = 2
+RECORD = 4
+RECORDS = 8
+REC = RECORD | RECORDS
+CONVERTED = 32
+
+
+class FormParser:
+    implements(IFormParser)
+
+    def __init__(self, env, to_unicode=None):
+        """Create a form parser for the given WSGI environment.
+
+        If to_unicode is specified, it is the function to use
+        to convert input byte strings to Unicode.
+        """
+        self._env = env
+        if to_unicode is None:
+            # use the default encoding
+            def to_unicode(s):
+                return s.decode()
+        self._to_unicode = to_unicode
+        self.form = {}
+        self.action = None
+
+    def parse(self):
+        method = self._env['REQUEST_METHOD'].upper()
+        if method in ('GET', 'HEAD'):
+            # Look for a query string instead of an input body
+            fp = None
+        else:
+            # Look for an input body
+            fp = self._env.get('wsgi.input')
+            if method == 'POST':
+                content_type = self._env.get('CONTENT_TYPE')
+                if content_type and not (
+                    content_type.startswith('application/x-www-form-urlencoded')
+                    or
+                    content_type.startswith('multipart/')
+                    ):
+                    # The WSGI environment does not contain form data.
+                    return self.form
+        return self._parse_fp(fp)
+
+    def _parse_fp(self, fp):
+        # If 'QUERY_STRING' is not present in self._env,
+        # FieldStorage will try to get it from sys.argv[1],
+        # which is not what we need.
+        if 'QUERY_STRING' not in self._env:
+            self._env['QUERY_STRING'] = ''
+
+        # If fp is None, FieldStorage might try to read from sys.stdin,
+        # which could freeze the process.  Provide an empty body.
+        if fp is None:
+            fp = StringIO('')
+
+        fs = TempFieldStorage(fp=fp, environ=self._env,
+                              keep_blank_values=1)
+
+        fslist = getattr(fs, 'list', None)
+        if fslist is not None:
+            self._tuple_items = {}
+            self._defaults = {}
+
+            # process all entries in the field storage (form)
+            for item in fslist:
+                self._process_item(item)
+
+            if self._defaults:
+                self._insert_defaults()
+
+            if self._tuple_items:
+                self._convert_to_tuples()
+
+        return self.form
+
+    def _process_item(self, item):
+        """Process item in the field storage."""
+
+        # Check whether this field is a file upload object
+        # Note: A field exists for files, even if no filename was
+        # passed in and no data was uploaded. Therefore we can only
+        # tell by the empty filename that no upload was made.
+        key = item.name
+        if (hasattr(item, 'file') and hasattr(item, 'filename')
+            and hasattr(item,'headers')):
+            if (item.file and
+                (item.filename is not None and item.filename != ''
+                 # RFC 1867 says that all fields get a content-type.
+                 # or 'content-type' in map(lower, item.headers.keys())
+                 )):
+                item = FileUpload(item)
+            else:
+                item = item.value
+
+        flags = 0
+        converter = None
+        tuple_item = False
+
+        # Loop through the different types and set
+        # the appropriate flags
+        # Syntax: var_name:type_name
+
+        # We'll search from the back to the front.
+        # We'll do the search in two steps.  First, we'll
+        # do a string search, and then we'll check it with
+        # a re search.
+
+        while key:
+            pos = key.rfind(":")
+            if pos < 0:
+                break
+            match = _type_format.match(key, pos + 1)
+            if match is None:
+                break
+
+            key, type_name = key[:pos], key[pos + 1:]
+
+            # find the right type converter
+            c = query_converter(type_name)
+
+            if c is not None:
+                converter = c
+                flags |= CONVERTED
+            elif type_name == 'list':
+                flags |= SEQUENCE
+            elif type_name == 'tuple':
+                tuple_item = True
+                flags |= SEQUENCE
+            elif (type_name == 'method' or type_name == 'action'):
+                if key:
+                    self.action = self._to_unicode(key)
+                else:
+                    self.action = self._to_unicode(item)
+            elif (type_name == 'default_method'
+                    or type_name == 'default_action') and not self.action:
+                if key:
+                    self.action = self._to_unicode(key)
+                else:
+                    self.action = self._to_unicode(item)
+            elif type_name == 'default':
+                flags |= DEFAULT
+            elif type_name == 'record':
+                flags |= RECORD
+            elif type_name == 'records':
+                flags |= RECORDS
+            elif type_name == 'ignore_empty':
+                if not item:
+                    # skip over empty fields
+                    return
+
+        # Make it unicode if not None
+        if key is not None:
+            key = self._to_unicode(key)
+
+        if isinstance(item, basestring):
+            item = self._to_unicode(item)
+
+        if tuple_item:
+            self._tuple_items[key] = True
+
+        if flags:
+            self._set_item_with_type(key, item, flags, converter)
+        else:
+            self._set_item_without_type(key, item)
+
+    def _set_item_without_type(self, key, item):
+        """Set item value without explicit type."""
+        form = self.form
+        if key not in form:
+            form[key] = item
+        else:
+            found = form[key]
+            if isinstance(found, list):
+                found.append(item)
+            else:
+                form[key] = [found, item]
+
+    def _set_item_with_type(self, key, item, flags, converter):
+        """Set item value with explicit type."""
+        #Split the key and its attribute
+        if flags & REC:
+            key, attr = self._split_key(key)
+
+        # defer conversion
+        if flags & CONVERTED:
+            try:
+                item = converter(item)
+            except:
+                if item or flags & DEFAULT or key not in self._defaults:
+                    raise
+                item = self._defaults[key]
+                if flags & RECORD:
+                    item = getattr(item, attr)
+                elif flags & RECORDS:
+                    item = getattr(item[-1], attr)
+
+        # Determine which dictionary to use
+        if flags & DEFAULT:
+            form = self._defaults
+        else:
+            form = self.form
+
+        # Insert in dictionary
+        if key not in form:
+            if flags & SEQUENCE:
+                item = [item]
+            if flags & RECORD:
+                r = form[key] = Record()
+                setattr(r, attr, item)
+            elif flags & RECORDS:
+                r = Record()
+                setattr(r, attr, item)
+                form[key] = [r]
+            else:
+                form[key] = item
+        else:
+            r = form[key]
+            if flags & RECORD:
+                if not flags & SEQUENCE:
+                    setattr(r, attr, item)
+                else:
+                    if not hasattr(r, attr):
+                        setattr(r, attr, [item])
+                    else:
+                        getattr(r, attr).append(item)
+            elif flags & RECORDS:
+                last = r[-1]
+                if not hasattr(last, attr):
+                    if flags & SEQUENCE:
+                        item = [item]
+                    setattr(last, attr, item)
+                else:
+                    if flags & SEQUENCE:
+                        getattr(last, attr).append(item)
+                    else:
+                        new = Record()
+                        setattr(new, attr, item)
+                        r.append(new)
+            else:
+                if isinstance(r, list):
+                    r.append(item)
+                else:
+                    form[key] = [r, item]
+
+    def _split_key(self, key):
+        """Split the key and its attribute."""
+        i = key.rfind(".")
+        if i >= 0:
+            return key[:i], key[i + 1:]
+        return key, ""
+
+    def _convert_to_tuples(self):
+        """Convert form values to tuples."""
+        form = self.form
+
+        for key in self._tuple_items:
+            if key in form:
+                form[key] = tuple(form[key])
+            else:
+                k, attr = self._split_key(key)
+
+                # remove any type_names in the attr
+                i = attr.find(":")
+                if i >= 0:
+                    attr = attr[:i]
+
+                if k in form:
+                    item = form[k]
+                    if isinstance(item, Record):
+                        if hasattr(item, attr):
+                            setattr(item, attr, tuple(getattr(item, attr)))
+                    else:
+                        for v in item:
+                            if hasattr(v, attr):
+                                setattr(v, attr, tuple(getattr(v, attr)))
+
+    def _insert_defaults(self):
+        """Insert defaults into the form dictionary."""
+        form = self.form
+
+        for keys, values in self._defaults.iteritems():
+            if not keys in form:
+                form[keys] = values
+            else:
+                item = form[keys]
+                if isinstance(values, Record):
+                    for k, v in values.items():
+                        if not hasattr(item, k):
+                            setattr(item, k, v)
+                elif isinstance(values, list):
+                    for val in values:
+                        if isinstance(val, Record):
+                            for k, v in val.items():
+                                for r in item:
+                                    if not hasattr(r, k):
+                                        setattr(r, k, v)
+                        elif not val in item:
+                            item.append(val)
+
+
+class Record(object):
+    """A record parsed from a form.  See `IFormRecord`."""
+    implements(IFormRecord)
+
+    _attrs = frozenset(IExtendedReadMapping)
+
+    def __getattr__(self, key, default=None):
+        if key in self._attrs:
+            return getattr(self.__dict__, key)
+        raise AttributeError(key)
+
+    def __setattr__(self, name, value):
+        if name in self._attrs or name.startswith('_'):
+            raise AttributeError("Illegal record attribute name: %s" % name)
+        self.__dict__[name] = value
+
+    def __getitem__(self, key):
+        return self.__dict__[key]
+
+    def __str__(self):
+        items = self.__dict__.items()
+        items.sort()
+        return "{" + ", ".join(["%s: %s" % item for item in items]) + "}"
+
+    def __repr__(self):
+        items = self.__dict__.items()
+        items.sort()
+        return ("{"
+            + ", ".join(["%s: %s" % (repr(key), repr(value))
+            for key, value in items]) + "}")
+
+
+class TempFieldStorage(FieldStorage):
+    """FieldStorage that stores uploads in temporary files"""
+
+    def make_file(self, binary=None):
+        return tempfile.NamedTemporaryFile('w+b')
+
+
+class FileUpload(object):
+    """Holds an uploaded file. """
+    implements(IFileUpload)
+
+    def __init__(self, field_storage):
+
+        f = field_storage.file
+        if hasattr(f, '__methods__'):
+            methods = f.__methods__
+        else:
+            methods = ['close', 'fileno', 'flush', 'isatty',
+                'read', 'readline', 'readlines', 'seek',
+                'tell', 'truncate', 'write', 'writelines',
+                'name']
+
+        d = self.__dict__
+        for m in methods:
+            if hasattr(f, m):
+                d[m] = getattr(f, m)
+
+        self.headers = field_storage.headers
+        self.filename = unicode(field_storage.filename, 'UTF-8')
+

Added: zope.httpform/trunk/src/zope/httpform/tests.py
===================================================================
--- zope.httpform/trunk/src/zope/httpform/tests.py	                        (rev 0)
+++ zope.httpform/trunk/src/zope/httpform/tests.py	2009-02-05 22:32:40 UTC (rev 96162)
@@ -0,0 +1,31 @@
+##############################################################################
+#
+# Copyright (c) 2009 Zope Corporation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+##############################################################################
+"""Tests of the zope.httpform package.
+
+$Id$
+"""
+
+import unittest
+from zope.testing import doctest
+
+
+def test_suite():
+    return unittest.TestSuite([
+        doctest.DocFileSuite(
+            'README.txt',
+            optionflags=doctest.NORMALIZE_WHITESPACE|doctest.ELLIPSIS),
+    ])
+
+if __name__ == '__main__':
+    unittest.main()

Added: zope.httpform/trunk/src/zope/httpform/typeconv.py
===================================================================
--- zope.httpform/trunk/src/zope/httpform/typeconv.py	                        (rev 0)
+++ zope.httpform/trunk/src/zope/httpform/typeconv.py	2009-02-05 22:32:40 UTC (rev 96162)
@@ -0,0 +1,101 @@
+##############################################################################
+#
+# Copyright (c) 2001, 2002 Zope Corporation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+##############################################################################
+"""Simple data type converters for form parsing, registered by name
+
+$Id$
+"""
+
+import re
+
+newlines = re.compile('\r\n|\n\r|\r')
+
+array_types = (list, tuple)
+
+def field2string(v):
+    if hasattr(v, 'read'):
+        return v.read()
+    if not isinstance(v, basestring):
+        v = str(v)
+    return v
+
+def field2text(v):
+    return newlines.sub("\n", field2string(v))
+
+def field2required(v):
+    test = field2string(v)
+    if not test.strip():
+        raise ValueError('No input for required field')
+    return v
+
+def field2int(v):
+    if isinstance(v, array_types):
+        return map(field2int, v)
+    v = field2string(v)
+    if not v:
+        raise ValueError('Empty entry when integer expected')
+    try:
+        return int(v)
+    except ValueError:
+        raise ValueError("An integer was expected in the value '%s'" % v)
+
+def field2float(v):
+    if isinstance(v, array_types):
+        return map(field2float, v)
+    v = field2string(v)
+    if not v:
+        raise ValueError('Empty entry when float expected')
+    try:
+        return float(v)
+    except ValueError:
+        raise ValueError("A float was expected in the value '%s'" % v)
+
+def field2long(v):
+    if isinstance(v, array_types):
+        return map(field2long, v)
+    v = field2string(v)
+
+    # handle trailing 'L' if present.
+    if v and v[-1].upper() == 'L':
+        v = v[:-1]
+    if not v:
+        raise ValueError('Empty entry when integer expected')
+    try:
+        return long(v)
+    except ValueError:
+        raise ValueError("A long integer was expected in the value '%s'" % v)
+
+def field2tokens(v):
+    return field2string(v).split()
+
+def field2lines(v):
+    if isinstance(v, array_types):
+        return [field2string(item) for item in v]
+    return field2text(v).splitlines()
+
+def field2boolean(v):
+    return bool(v)
+
+type_converters = {
+    'float':    field2float,
+    'int':      field2int,
+    'long':     field2long,
+    'string':   field2string,
+    'required': field2required,
+    'tokens':   field2tokens,
+    'lines':    field2lines,
+    'text':     field2text,
+    'boolean':  field2boolean,
+    }
+
+query_converter = type_converters.get



More information about the Checkins mailing list