webapp.forms

18 January 2008
18:21

By cmlenz as Python

Simple HTML forms library thing

1 """Tools for form processing, data conversion and validation.
2
3 Assume the following raw input data, which may for example come from the body
4 of a POST request, which basically represents a flat dictionary:
5
6 >>> data = {
7 ... 'people[0][name]': 'johnny', 'people[0][age]': '42',
8 ... 'people[1][name]': 'anna', 'people[1][age]': '23',
9 ... }
10
11 Decoding this data will produce a properly nested structure of dictionaries and
12 lists:
13
14 >>> data = decode(data)
15 >>> data
16 {'people': [{'age': '42', 'name': 'johnny'}, {'age': '23', 'name': 'anna'}]}
17
18 Note though that neither validation nor data type conversions have been
19 performed. That is the responsibility of the validators:
20
21 >>> validate = DictValidator(people=ListValidator(DictValidator(
22 ... name=TextValidator(),
23 ... age=IntValidator()
24 ... )))
25 >>> validate(data)
26 {'people': [{'age': 42, 'name': u'johnny'}, {'age': 23, 'name': u'anna'}]}
27
28 You can see that type conversions have been applied as requested.
29
30 If invalid data is encountered, a `ValidationError` will be raised:
31
32 >>> validate(decode({'people[0][age]': 'forty-two'}))
33 Traceback (most recent call last):
34 ...
35 ValidationErrors: 1 error
36
37 To get useful information out of such an exception, it needs to be "unpacked":
38
39 >>> try:
40 ... validate(decode({'people[0][age]': 'forty-two'}))
41 ... except ValidationError, e:
42 ... errors = e.unpack() #doctest: +ELLIPSIS
43 >>> print errors['people'][0]['age'][0]
44 Please enter a whole number.
45
46 The structure of the unpacked errors mirrors that of the decoded data and the
47 validators, using nested dictionary and lists.
48
49 On top of validators, there's a third abstraction layer: the `Form` class. The
50 example given above could also be defined and used as follows:
51
52 >>> class PeopleForm(Form):
53 ... people = [{
54 ... 'name': TextValidator(),
55 ... 'age': IntValidator()
56 ... }]
57
58 >>> form = PeopleForm()
59 >>> form.validate(data)
60 True
61 >>> form['people']
62 [{'age': 42, 'name': u'johnny'}, {'age': 23, 'name': u'anna'}]
63 """
64
65 from cgi import parse_header
66 import logging
67 from md5 import md5
68 from random import random
69 import re
70 from types import FunctionType
71
72 from genshi.builder import tag
73 from genshi.core import Stream
74 from genshi.filters import HTMLFormFiller, Transformer
75
76 from webapp import app
77 from webapp.http import abort
78 from webapp.i18n import gettext, ngettext
79
80 __all__ = ['Form', 'BoolValidator', 'DictValidator', 'IntValidator',
81 'ListValidator', 'TextValidator', 'decode', 'encode']
82 __docformat__ = 'restructuredtext en'
83
84 log = logging.getLogger('webapp.forms')
85
86
87 _decode = re.compile(r'\[(\w+)\]').findall
88
89 def decode(data):
90 """Decodes the flat dictionary d into a nested structure.
91
92 >>> decode({'foo': 'bar'})
93 {'foo': 'bar'}
94
95 >>> decode({'foo[0]': 'bar', 'foo[1]': 'baz'})
96 {'foo': ['bar', 'baz']}
97
98 >>> data = decode({'foo[bar]': '1', 'foo[baz]': '2'})
99 >>> assert data == {'foo': {'bar': '1', 'baz': '2'}}
100
101 >>> decode({'foo[bar][0]': 'baz', 'foo[bar][1]': 'buzz'})
102 {'foo': {'bar': ['baz', 'buzz']}}
103
104 >>> decode({'foo[0][bar]': '23', 'foo[1][baz]': '42'})
105 {'foo': [{'bar': '23'}, {'baz': '42'}]}
106
107 >>> decode({'foo[0][0]': '23', 'foo[0][1]': '42'})
108 {'foo': [['23', '42']]}
109
110 >>> decode({'foo': ['23', '42']})
111 {'foo': ['23', '42']}
112 """
113 result = {}
114 lists = []
115
116 for key, value in data.items():
117 pos = key.find('[')
118 if pos == -1:
119 result[key] = value
120 else:
121 names = [key[:pos]] + _decode(key[pos:])
122 container = result
123 end = len(names) - 1
124 for idx in range(end):
125 curname = names[idx]
126 if curname not in container:
127 nextname = names[idx + 1]
128 container[curname] = {}
129 if nextname.isdigit():
130 container[curname]['__list__'] = True
131 container = container[curname]
132 container[names[-1]] = value
133
134 def _convert(data):
135 if type(data) is dict:
136 if '__list__' in data:
137 del data['__list__']
138 data = [_convert(v) for k, v in sorted(data.items())]
139 else:
140 data = dict((k, _convert(v)) for k, v in data.items())
141 return data
142
143 return _convert(result)
144
145 def encode(data):
146 """Encodes a nested structure into a flat dictionary.
147
148 >>> encode({'foo': 'bar'})
149 {'foo': 'bar'}
150
151 >>> encode({'foo': ['bar', 'baz']})
152 {'foo[0]': 'bar', 'foo[1]': 'baz'}
153
154 >>> encode({'foo': [{'bar': '42', 'baz': '43'}]})
155 {'foo[0][baz]': '43', 'foo[0][bar]': '42'}
156 """
157 def _encode(data=data, prefix='', result={}):
158 if isinstance(data, dict):
159 for key, value in data.items():
160 if key is None:
161 name = prefix
162 elif not prefix:
163 name = key
164 else:
165 name = "%s[%s]" % (prefix, key)
166 _encode(value, name, result)
167 elif isinstance(data, list):
168 for i in range(len(data)):
169 _encode(data[i], "%s[%d]" % (prefix, i), result=result)
170 else:
171 result[prefix] = data
172 return result
173 return _encode()
174
175
176 class ValidationError(Exception):
177 """Exception raised when invalid data is encountered."""
178
179 def unpack(self, key=None):
180 return {key: [self.args[0]]}
181
182
183 class Validator(object):
184 """Abstract validator base class."""
185
186 def __call__(self, string):
187 return unicode(string)
188
189
190 class DictValidator(Validator):
191 """Apply a set of validators to a dictionary of values.
192
193 >>> validator = DictValidator(name=TextValidator(), age=IntValidator())
194 >>> validator({'name': u'John Doe', 'age': u'42'})
195 {'age': 42, 'name': u'John Doe'}
196 """
197
198 class ValidationErrors(ValidationError):
199
200 def __init__(self, errors):
201 ValidationError.__init__(self, '%d error%s' % (
202 len(errors), len(errors) != 1 and 's' or ''
203 ))
204 self.errors = errors
205
206 def __unicode__(self):
207 return ', '.join([str(e) for e in self.errors.values()])
208
209 def unpack(self, key=None):
210 retval = {}
211 for name, error in self.errors.items():
212 retval.update(error.unpack(key=name))
213 return retval
214
215
216 def __init__(self, **validators):
217 self.validators = validators
218
219 def __call__(self, string):
220 errors = {}
221 result = {}
222 for name, validator in self.validators.items():
223 try:
224 result[name] = validator(string.get(name, ''))
225 except ValidationError, e:
226 errors[name] = e
227 if errors:
228 raise self.ValidationErrors(errors)
229 return result
230
231 def __repr__(self):
232 return '<%s %r>' % (self.__class__.__name__, self.validators)
233
234
235 class ListValidator(Validator):
236 """Apply a single validator to a sequence of values.
237
238 >>> validator = ListValidator(IntValidator())
239 >>> validator([u'1', u'2', u'3'])
240 [1, 2, 3]
241 """
242
243 class ValidationErrors(ValidationError):
244
245 def __init__(self, errors):
246 ValidationError.__init__(self, '%d error%s' % (
247 len(errors), len(errors) != 1 and 's' or ''
248 ))
249 self.errors = errors
250
251 def __unicode__(self):
252 return ', '.join([str(e) for e in self.errors])
253
254 def unpack(self, key=None):
255 return {key: [e.unpack() for e in self.errors]}
256
257
258 def __init__(self, validator, min_size=None, max_size=None):
259 self.validator = validator
260 self.min_size = min_size
261 self.max_size = max_size
262
263 def __call__(self, string):
264 errors = []
265 if self.min_size is not None and len(string) < self.min_size:
266 errors.append(ValidationError(
267 ngettext('Please provide at least %(num)s item.',
268 'Please provide at least %(num)s items.',
269 self.min_size)
270 ))
271 if self.max_size is not None and len(string) > self.max_size:
272 errors.append(ValidationError(
273 ngettext('Please provide no more than %(num)s item.',
274 'Please provide no more than %(num)s items.',
275 self.max_size)
276 ))
277 result = []
278 for item in string:
279 try:
280 result.append(self.validator(item))
281 except ValidationError, e:
282 errors.append(e)
283 if errors:
284 raise self.ValidationErrors(errors)
285 return result
286
287 def __repr__(self):
288 return '<%s %r>' % (self.__class__.__name__, self.validator)
289
290
291 class TextValidator(Validator):
292 """Validator for strings.
293
294 >>> validator = TextValidator(required=True, min_length=6)
295 >>> validator('foo bar')
296 u'foo bar'
297 >>> validator('')
298 Traceback (most recent call last):
299 ...
300 ValidationError: This field is required.
301
302 You can also specify a regular expression that the content must match using
303 the `pattern` parameter:
304
305 >>> validator = TextValidator(pattern=r'\w+')
306 >>> validator('foo bar')
307 Traceback (most recent call last):
308 ...
309 ValidationError: The value does not match pattern "^\w+$".
310
311 Because displaying regular expression patterns to the user is ugly almost
312 always unhelpful, you can specify a custom error message that should be
313 used when the pattern does not match:
314
315 >>> validator = TextValidator(pattern=r'\w+',
316 ... message='Only letters allowed here.')
317 >>> validator('foo bar')
318 Traceback (most recent call last):
319 ...
320 ValidationError: Only letters allowed here.
321 """
322
323 def __init__(self, required=False, min_length=None, max_length=None,
324 pattern=None, message=None):
325 self.required = required
326 self.min_length = min_length
327 self.max_length = max_length
328 if isinstance(pattern, basestring):
329 pattern = re.compile('^%s$' % pattern)
330 self.pattern = pattern
331 if pattern and not message:
332 message = gettext('The value does not match pattern "%(pattern)s".',
333 pattern=pattern.pattern)
334 self.message = message
335
336 def __call__(self, string):
337 string = unicode(string)
338 if self.required and not string:
339 raise ValidationError(gettext('This field is required.'))
340 if self.min_length is not None and len(string) < self.min_length:
341 raise ValidationError(
342 ngettext('Please enter at least %(num)d character.',
343 'Please enter at least %(num)d characters.',
344 self.min_length)
345 )
346 if self.max_length is not None and len(string) > self.max_length:
347 raise ValidationError(
348 ngettext('Please enter no more than %(num)d character.',
349 'Please enter no more than %(num)d characters.',
350 self.max_length)
351 )
352 if self.pattern is not None and not self.pattern.match(string):
353 raise ValidationError(self.message)
354 return string
355
356
357 class IntValidator(Validator):
358 """Validator for integers.
359
360 >>> validator = IntValidator(min_value=0, max_value=99)
361 >>> validator('13')
362 13
363
364 >>> validator('thirteen')
365 Traceback (most recent call last):
366 ...
367 ValidationError: Please enter a whole number.
368
369 >>> validator('193')
370 Traceback (most recent call last):
371 ...
372 ValidationError: Ensure this value is less than or equal to 99.
373 """
374
375 def __init__(self, required=False, min_value=None, max_value=None):
376 self.required = required
377 self.min_value = min_value
378 self.max_value = max_value
379
380 def __call__(self, string):
381 if not string:
382 if self.required:
383 raise ValidationError(gettext('This field is required.'))
384 return None
385 try:
386 value = int(string)
387 except ValueError:
388 raise ValidationError(gettext('Please enter a whole number.'))
389
390 if self.min_value is not None and value < self.min_value:
391 raise ValidationError(
392 gettext('Ensure this value is greater than or equal to '
393 '%(value)s.', value=self.min_value)
394 )
395 if self.max_value is not None and value > self.max_value:
396 raise ValidationError(
397 gettext('Ensure this value is less than or equal to '
398 '%(value)s.', value=self.max_value)
399 )
400
401 return int(value)
402
403
404 class BoolValidator(Validator):
405 """Validator for boolean values.
406
407 >>> validator = BoolValidator()
408 >>> validator('1')
409 True
410
411 >>> validator = BoolValidator()
412 >>> validator('')
413 False
414 """
415
416 def __call__(self, string):
417 return bool(string)
418
419
420 class FormMeta(type):
421 """Meta class for forms."""
422
423 def __new__(cls, name, bases, d):
424 validators = {}
425 validate_functions = []
426
427 for base in bases:
428 if hasattr(base, '_validator'):
429 validators.update(base._validator.validators)
430 if hasattr(base, '_validators'):
431 validate_functions.extend(base._validators)
432
433 def _walk(obj):
434 if isinstance(obj, Validator):
435 return obj
436 elif isinstance(obj, dict):
437 retval = {}
438 for key, value in obj.items():
439 validator = _walk(value)
440 if validator:
441 retval[key] = validator
442 if retval:
443 return DictValidator(**retval)
444 elif isinstance(obj, list) and len(obj) == 1:
445 validator = _walk(obj[0])
446 if validator:
447 return ListValidator(validator)
448
449 for key, value in d.items():
450 if type(value) is FunctionType and key.startswith('validate_'):
451 validate_functions.append(value)
452 else:
453 validator = _walk(value)
454 if validator:
455 validators[key] = validator
456
457 d['_validator'] = DictValidator(**validators)
458 d['_validators'] = validate_functions
459
460 return type.__new__(cls, name, bases, d)
461
462
463 class Form(object):
464 """Form base class.
465
466 >>> class PersonForm(Form):
467 ... name = TextValidator(required=True)
468 ... age = IntValidator()
469
470 >>> form = PersonForm()
471 >>> form.validate({'name': 'johnny', 'age': '42'})
472 True
473 >>> form['name']
474 u'johnny'
475 >>> form['age']
476 42
477
478 Let's cause a simple validation error:
479
480 >>> form = PersonForm()
481 >>> form.validate({'name': '', 'age': 'fourty-two'})
482 False
483 >>> print form.errors['age'][0]
484 Please enter a whole number.
485 >>> print form.errors['name'][0]
486 This field is required.
487
488 You can also add custom validation routines independent of individual
489 fields, by adding methods that start with the prefix ``validate_``, and
490 take the data dictionary as argument. For example:
491
492 >>> class PersonForm(Form):
493 ... name = TextValidator(required=True)
494 ... age = IntValidator()
495 ...
496 ... def validate_name_alpha(self, data):
497 ... if not data['name'].isalpha():
498 ... raise ValidationError('The value must only contain letters')
499
500 >>> form = PersonForm()
501 >>> form.validate({'name': 'mr.t', 'age': '42'})
502 False
503 >>> form.errors
504 {None: ['The value must only contain letters']}
505
506 Note that validation errors that are not related to a specific field can be
507 found under the key `None` of the errors dictionary.
508 """
509 __metaclass__ = FormMeta
510
511 def __init__(self, id=None, name=None, defaults=None):
512 self.id = id
513 self.name = name
514 self.data = defaults or {}
515 self.errors = {}
516 if hasattr(app, 'ctxt') and hasattr(app.ctxt, 'forms'):
517 app.ctxt.forms.append(self)
518
519 def __contains__(self, name):
520 return name in self.data
521
522 def __iter__(self):
523 return iter(self.data)
524
525 def __delitem__(self, name):
526 if name in self.data:
527 del self.data[name]
528
529 def __getitem__(self, name):
530 return self.data.get(name)
531
532 def __setitem__(self, name, value):
533 self.data[name] = value
534
535 @classmethod
536 def add_validator(cls, validator, name=None):
537 if name:
538 cls._validator.validators[name] = validator
539 else:
540 cls._validators.append(validator)
541
542 def validate(self, data):
543 d = self.data.copy()
544 d.update(decode(data))
545 errors = {}
546 try:
547 data = self._validator(data)
548 for f in self._validators:
549 f(self, data)
550 except ValidationError, e:
551 log.debug('Validation failed: %r', errors)
552 errors = e.unpack()
553 self.data.update(data)