forms.py
Go to the documentation of this file.
00001 # -*- coding: utf-8 -*-
00002 """Helpers to fill and submit forms."""
00003 
00004 import re
00005 import sys
00006 
00007 from bs4 import BeautifulSoup
00008 from webtest.compat import OrderedDict
00009 from webtest import utils
00010 
00011 
00012 class NoValue(object):
00013     pass
00014 
00015 
00016 class Upload(object):
00017     """
00018     A file to upload::
00019 
00020         >>> Upload('filename.txt', 'data', 'application/octet-stream')
00021         <Upload "filename.txt">
00022         >>> Upload('filename.txt', 'data')
00023         <Upload "filename.txt">
00024         >>> Upload("README.txt")
00025         <Upload "README.txt">
00026 
00027     :param filename: Name of the file to upload.
00028     :param content: Contents of the file.
00029     :param content_type: MIME type of the file.
00030 
00031     """
00032 
00033     def __init__(self, filename, content=None, content_type=None):
00034         self.filename = filename
00035         self.content = content
00036         self.content_type = content_type
00037 
00038     def __iter__(self):
00039         yield self.filename
00040         if self.content:
00041             yield self.content
00042             yield self.content_type
00043         # TODO: do we handle the case when we need to get
00044         # contents ourselves?
00045 
00046     def __repr__(self):
00047         return '<Upload "%s">' % self.filename
00048 
00049 
00050 class Field(object):
00051     """Base class for all Field objects.
00052 
00053     .. attribute:: classes
00054 
00055         Dictionary of field types (select, radio, etc)
00056 
00057     .. attribute:: value
00058 
00059         Set/get value of the field.
00060 
00061     """
00062 
00063     classes = {}
00064 
00065     def __init__(self, form, tag, name, pos,
00066                  value=None, id=None, **attrs):
00067         self.form = form
00068         self.tag = tag
00069         self.name = name
00070         self.pos = pos
00071         self._value = value
00072         self.id = id
00073         self.attrs = attrs
00074 
00075     def value__get(self):
00076         if self._value is None:
00077             return ''
00078         else:
00079             return self._value
00080 
00081     def value__set(self, value):
00082         self._value = value
00083 
00084     value = property(value__get, value__set)
00085 
00086     def force_value(self, value):
00087         """Like setting a value, except forces it (even for, say, hidden
00088         fields).
00089         """
00090         self._value = value
00091 
00092     def __repr__(self):
00093         value = '<%s name="%s"' % (self.__class__.__name__, self.name)
00094         if self.id:
00095             value += ' id="%s"' % self.id
00096         return value + '>'
00097 
00098 
00099 class Select(Field):
00100     """Field representing ``<select />`` form element."""
00101 
00102     def __init__(self, *args, **attrs):
00103         super(Select, self).__init__(*args, **attrs)
00104         self.options = []
00105         # Undetermined yet:
00106         self.selectedIndex = None
00107         # we have no forced value
00108         self._forced_value = NoValue
00109 
00110     def force_value(self, value):
00111         """Like setting a value, except forces it (even for, say, hidden
00112         fields).
00113         """
00114         self._forced_value = value
00115 
00116     def select(self, value=None, text=None):
00117         if value is not None and text is not None:
00118             raise ValueError("Specify only one of value and text.")
00119 
00120         if text is not None:
00121             value = self._get_value_for_text(text)
00122 
00123         self.value = value
00124 
00125     def _get_value_for_text(self, text):
00126         for i, (option_value, checked, option_text) in enumerate(self.options):
00127             if option_text == utils.stringify(text):
00128                 return option_value
00129 
00130         raise ValueError("Option with text %r not found (from %s)"
00131                          % (text, ', '.join(
00132                              [repr(t) for o, c, t in self.options])))
00133 
00134     def value__set(self, value):
00135         if self._forced_value is not NoValue:
00136             self._forced_value = NoValue
00137         for i, (option, checked, text) in enumerate(self.options):
00138             if option == utils.stringify(value):
00139                 self.selectedIndex = i
00140                 break
00141         else:
00142             raise ValueError(
00143                 "Option %r not found (from %s)"
00144                 % (value, ', '.join([repr(o) for o, c, t in self.options])))
00145 
00146     def value__get(self):
00147         if self._forced_value is not NoValue:
00148             return self._forced_value
00149         elif self.selectedIndex is not None:
00150             return self.options[self.selectedIndex][0]
00151         else:
00152             for option, checked, text in self.options:
00153                 if checked:
00154                     return option
00155             else:
00156                 if self.options:
00157                     return self.options[0][0]
00158 
00159     value = property(value__get, value__set)
00160 
00161 
00162 class MultipleSelect(Field):
00163     """Field representing ``<select multiple="multiple">``"""
00164 
00165     def __init__(self, *args, **attrs):
00166         super(MultipleSelect, self).__init__(*args, **attrs)
00167         self.options = []
00168         # Undetermined yet:
00169         self.selectedIndices = []
00170         self._forced_values = []
00171 
00172     def force_value(self, values):
00173         """Like setting a value, except forces it (even for, say, hidden
00174         fields).
00175         """
00176         self._forced_values = values
00177         self.selectedIndices = []
00178 
00179     def select_multiple(self, value=None, texts=None):
00180         if value is not None and texts is not None:
00181             raise ValueError("Specify only one of value and texts.")
00182 
00183         if texts is not None:
00184             value = self._get_value_for_texts(texts)
00185 
00186         self.value = value
00187 
00188     def _get_value_for_texts(self, texts):
00189         str_texts = [utils.stringify(text) for text in texts]
00190         value = []
00191         for i, (option, checked, text) in enumerate(self.options):
00192             if text in str_texts:
00193                 value.append(option)
00194                 str_texts.remove(text)
00195 
00196         if str_texts:
00197             raise ValueError(
00198                 "Option(s) %r not found (from %s)"
00199                 % (', '.join(str_texts),
00200                    ', '.join([repr(t) for o, c, t in self.options])))
00201 
00202         return value
00203 
00204     def value__set(self, values):
00205         str_values = [utils.stringify(value) for value in values]
00206         self.selectedIndices = []
00207         for i, (option, checked, text) in enumerate(self.options):
00208             if option in str_values:
00209                 self.selectedIndices.append(i)
00210                 str_values.remove(option)
00211         if str_values:
00212             raise ValueError(
00213                 "Option(s) %r not found (from %s)"
00214                 % (', '.join(str_values),
00215                    ', '.join([repr(o) for o, c, t in self.options])))
00216 
00217     def value__get(self):
00218         selected_values = []
00219         if self.selectedIndices:
00220             selected_values = [self.options[i][0]
00221                                for i in self.selectedIndices]
00222         elif not self._forced_values:
00223             selected_values = []
00224             for option, checked, text in self.options:
00225                 if checked:
00226                     selected_values.append(option)
00227         if self._forced_values:
00228             selected_values += self._forced_values
00229 
00230         if self.options and (not selected_values):
00231             selected_values = None
00232         return selected_values
00233     value = property(value__get, value__set)
00234 
00235 
00236 class Radio(Select):
00237     """Field representing ``<input type="radio">``"""
00238 
00239     def value__get(self):
00240         if self._forced_value is not NoValue:
00241             return self._forced_value
00242         elif self.selectedIndex is not None:
00243             return self.options[self.selectedIndex][0]
00244         else:
00245             for option, checked, text in self.options:
00246                 if checked:
00247                     return option
00248             else:
00249                 return None
00250 
00251     value = property(value__get, Select.value__set)
00252 
00253 
00254 class Checkbox(Field):
00255     """Field representing ``<input type="checkbox">``
00256 
00257     .. attribute:: checked
00258 
00259         Returns True if checkbox is checked.
00260 
00261     """
00262 
00263     def __init__(self, *args, **attrs):
00264         super(Checkbox, self).__init__(*args, **attrs)
00265         self._checked = 'checked' in attrs
00266 
00267     def value__set(self, value):
00268         self._checked = not not value
00269 
00270     def value__get(self):
00271         if self._checked:
00272             if self._value is None:
00273                 return 'on'
00274             else:
00275                 return self._value
00276         else:
00277             return None
00278 
00279     value = property(value__get, value__set)
00280 
00281     def checked__get(self):
00282         return bool(self._checked)
00283 
00284     def checked__set(self, value):
00285         self._checked = not not value
00286 
00287     checked = property(checked__get, checked__set)
00288 
00289 
00290 class Text(Field):
00291     """Field representing ``<input type="text">``"""
00292 
00293 
00294 class File(Field):
00295     """Field representing ``<input type="file">``"""
00296 
00297     # TODO: This doesn't actually handle file uploads and enctype
00298     def value__get(self):
00299         if self._value is None:
00300             return ''
00301         else:
00302             return self._value
00303 
00304     value = property(value__get, Field.value__set)
00305 
00306 
00307 class Textarea(Text):
00308     """Field representing ``<textarea>``"""
00309 
00310 
00311 class Hidden(Text):
00312     """Field representing ``<input type="hidden">``"""
00313 
00314 
00315 class Submit(Field):
00316     """Field representing ``<input type="submit">`` and ``<button>``"""
00317 
00318     def value__get(self):
00319         return None
00320 
00321     def value__set(self, value):
00322         raise AttributeError(
00323             "You cannot set the value of the <%s> field %r"
00324             % (self.tag, self.name))
00325 
00326     value = property(value__get, value__set)
00327 
00328     def value_if_submitted(self):
00329         # TODO: does this ever get set?
00330         return self._value
00331 
00332 
00333 Field.classes['submit'] = Submit
00334 
00335 Field.classes['button'] = Submit
00336 
00337 Field.classes['image'] = Submit
00338 
00339 Field.classes['multiple_select'] = MultipleSelect
00340 
00341 Field.classes['select'] = Select
00342 
00343 Field.classes['hidden'] = Hidden
00344 
00345 Field.classes['file'] = File
00346 
00347 Field.classes['text'] = Text
00348 
00349 Field.classes['password'] = Text
00350 
00351 Field.classes['checkbox'] = Checkbox
00352 
00353 Field.classes['textarea'] = Textarea
00354 
00355 Field.classes['radio'] = Radio
00356 
00357 
00358 class Form(object):
00359     """This object represents a form that has been found in a page.
00360 
00361     :param response: `webob.response.TestResponse` instance
00362     :param text: Unparsed html of the form
00363 
00364     .. attribute:: text
00365 
00366         the full HTML of the form.
00367 
00368     .. attribute:: action
00369 
00370         the relative URI of the action.
00371 
00372     .. attribute:: method
00373 
00374         the HTTP method (e.g., ``'GET'``).
00375 
00376     .. attribute:: id
00377 
00378         the id, or None if not given.
00379 
00380     .. attribute:: enctype
00381 
00382         encoding of the form submission
00383 
00384     .. attribute:: fields
00385 
00386         a dictionary of fields, each value is a list of fields by
00387         that name.  ``<input type=\"radio\">`` and ``<select>`` are
00388         both represented as single fields with multiple options.
00389 
00390     .. attribute:: field_order
00391 
00392         Ordered list of field names as found in the html.
00393 
00394     """
00395 
00396     # TODO: use BeautifulSoup4 for this
00397 
00398     _tag_re = re.compile(r'<(/?)([a-z0-9_\-]*)([^>]*?)>', re.I)
00399     _label_re = re.compile(
00400         '''<label\s+(?:[^>]*)for=(?:"|')([a-z0-9_\-]+)(?:"|')(?:[^>]*)>''',
00401         re.I)
00402 
00403     FieldClass = Field
00404 
00405     def __init__(self, response, text, parser_features='html.parser'):
00406         self.response = response
00407         self.text = text
00408         self.html = BeautifulSoup(self.text, parser_features)
00409 
00410         attrs = self.html('form')[0].attrs
00411         self.action = attrs.get('action', '')
00412         self.method = attrs.get('method', 'GET')
00413         self.id = attrs.get('id')
00414         self.enctype = attrs.get('enctype',
00415                                  'application/x-www-form-urlencoded')
00416 
00417         self._parse_fields()
00418 
00419     def _parse_fields(self):
00420         fields = OrderedDict()
00421         field_order = []
00422         tags = ('input', 'select', 'textarea', 'button')
00423         for pos, node in enumerate(self.html.findAll(tags)):
00424             attrs = dict(node.attrs)
00425             tag = node.name
00426             name = None
00427             if 'name' in attrs:
00428                 name = attrs.pop('name')
00429 
00430             if tag == 'textarea':
00431                 if node.text.startswith('\r\n'):  # pragma: no cover
00432                     text = node.text[2:]
00433                 elif node.text.startswith('\n'):
00434                     text = node.text[1:]
00435                 else:
00436                     text = node.text
00437                 attrs['value'] = text
00438 
00439             tag_type = attrs.get('type', 'text').lower()
00440             if tag == 'select':
00441                 tag_type = 'select'
00442             if tag_type == "select" and "multiple" in attrs:
00443                 tag_type = "multiple_select"
00444             if tag == 'button':
00445                 tag_type = 'submit'
00446 
00447             FieldClass = self.FieldClass.classes.get(tag_type,
00448                                                      self.FieldClass)
00449 
00450             # https://github.com/Pylons/webtest/issues/73
00451             if sys.version_info[:2] <= (2, 6):
00452                 attrs = dict((k.encode('utf-8') if isinstance(k, unicode)
00453                               else k, v) for k, v in attrs.items())
00454 
00455             if tag == 'input':
00456                 if tag_type == 'radio':
00457                     field = fields.get(name)
00458                     if not field:
00459                         field = FieldClass(self, tag, name, pos, **attrs)
00460                         fields.setdefault(name, []).append(field)
00461                         field_order.append((name, field))
00462                     else:
00463                         field = field[0]
00464                         assert isinstance(field,
00465                                           self.FieldClass.classes['radio'])
00466                     field.options.append((attrs.get('value'),
00467                                           'checked' in attrs,
00468                                           None))
00469                     continue
00470                 elif tag_type == 'file':
00471                     if 'value' in attrs:
00472                         del attrs['value']
00473 
00474             field = FieldClass(self, tag, name, pos, **attrs)
00475             fields.setdefault(name, []).append(field)
00476             field_order.append((name, field))
00477 
00478             if tag == 'select':
00479                 for option in node('option'):
00480                     field.options.append(
00481                         (option.attrs.get('value', option.text),
00482                          'selected' in option.attrs,
00483                          option.text))
00484 
00485         self.field_order = field_order
00486         self.fields = fields
00487 
00488     def __setitem__(self, name, value):
00489         """Set the value of the named field. If there is 0 or multiple fields
00490         by that name, it is an error.
00491 
00492         Multiple checkboxes of the same name are special-cased; a list may be
00493         assigned to them to check the checkboxes whose value is present in the
00494         list (and uncheck all others).
00495 
00496         Setting the value of a ``<select>`` selects the given option (and
00497         confirms it is an option). Setting radio fields does the same.
00498         Checkboxes get boolean values. You cannot set hidden fields or buttons.
00499 
00500         Use ``.set()`` if there is any ambiguity and you must provide an index.
00501         """
00502         fields = self.fields.get(name)
00503         assert fields is not None, (
00504             "No field by the name %r found (fields: %s)"
00505             % (name, ', '.join(map(repr, self.fields.keys()))))
00506         all_checkboxes = all(isinstance(f, Checkbox) for f in fields)
00507         if all_checkboxes and isinstance(value, list):
00508             values = set(utils.stringify(v) for v in value)
00509             for f in fields:
00510                 f.checked = f._value in values
00511         else:
00512             assert len(fields) == 1, (
00513                 "Multiple fields match %r: %s"
00514                 % (name, ', '.join(map(repr, fields))))
00515             fields[0].value = value
00516 
00517     def __getitem__(self, name):
00518         """Get the named field object (ambiguity is an error)."""
00519         fields = self.fields.get(name)
00520         assert fields is not None, (
00521             "No field by the name %r found" % name)
00522         assert len(fields) == 1, (
00523             "Multiple fields match %r: %s"
00524             % (name, ', '.join(map(repr, fields))))
00525         return fields[0]
00526 
00527     def lint(self):
00528         """
00529         Check that the html is valid:
00530 
00531         - each field must have an id
00532         - each field must have a label
00533 
00534         """
00535         labels = self._label_re.findall(self.text)
00536         for name, fields in self.fields.items():
00537             for field in fields:
00538                 if not isinstance(field, (Submit, Hidden)):
00539                     if not field.id:
00540                         raise AttributeError("%r as no id attribute" % field)
00541                     elif field.id not in labels:
00542                         raise AttributeError(
00543                             "%r as no associated label" % field)
00544 
00545     def set(self, name, value, index=None):
00546         """Set the given name, using ``index`` to disambiguate."""
00547         if index is None:
00548             self[name] = value
00549         else:
00550             fields = self.fields.get(name)
00551             assert fields is not None, (
00552                 "No fields found matching %r" % name)
00553             field = fields[index]
00554             field.value = value
00555 
00556     def get(self, name, index=None, default=utils.NoDefault):
00557         """
00558         Get the named/indexed field object, or ``default`` if no field is
00559         found. Throws an AssertionError if no field is found and no ``default``
00560         was given.
00561         """
00562         fields = self.fields.get(name)
00563         if fields is None:
00564             if default is utils.NoDefault:
00565                 raise AssertionError(
00566                     "No fields found matching %r (and no default given)"
00567                     % name)
00568             return default
00569         if index is None:
00570             return self[name]
00571         return fields[index]
00572 
00573     def select(self, name, value=None, text=None, index=None):
00574         """Like ``.set()``, except also confirms the target is a ``<select>``
00575         and allows selecting options by text.
00576         """
00577         field = self.get(name, index=index)
00578         assert isinstance(field, Select)
00579 
00580         field.select(value, text)
00581 
00582     def select_multiple(self, name, value=None, texts=None, index=None):
00583         """Like ``.set()``, except also confirms the target is a
00584         ``<select multiple>`` and allows selecting options by text.
00585         """
00586         field = self.get(name, index=index)
00587         assert isinstance(field, MultipleSelect)
00588 
00589         field.select_multiple(value, texts)
00590 
00591     def submit(self, name=None, index=None, value=None, **args):
00592         """Submits the form.  If ``name`` is given, then also select that
00593         button (using ``index`` or ``value`` to disambiguate)``.
00594 
00595         Any extra keyword arguments are passed to the
00596         :meth:`webtest.TestResponse.get` or
00597         :meth:`webtest.TestResponse.post` method.
00598 
00599         Returns a :class:`webtest.TestResponse` object.
00600 
00601         """
00602         fields = self.submit_fields(name, index=index, submit_value=value)
00603         if self.method.upper() != "GET":
00604             args.setdefault("content_type",  self.enctype)
00605         return self.response.goto(self.action, method=self.method,
00606                                   params=fields, **args)
00607 
00608     def upload_fields(self):
00609         """Return a list of file field tuples of the form::
00610 
00611             (field name, file name)
00612 
00613         or::
00614 
00615             (field name, file name, file contents).
00616 
00617         """
00618         uploads = []
00619         for name, fields in self.fields.items():
00620             for field in fields:
00621                 if isinstance(field, File) and field.value:
00622                     uploads.append([name] + list(field.value))
00623         return uploads
00624 
00625     def submit_fields(self, name=None, index=None, submit_value=None):
00626         """Return a list of ``[(name, value), ...]`` for the current state of
00627         the form.
00628 
00629         :param name: Same as for :meth:`submit`
00630         :param index: Same as for :meth:`submit`
00631 
00632         """
00633         submit = []
00634         # Use another name here so we can keep function param the same for BWC.
00635         submit_name = name
00636         if index is not None and submit_value is not None:
00637             raise ValueError("Can't specify both submit_value and index.")
00638 
00639         # If no particular button was selected, use the first one
00640         if index is None and submit_value is None:
00641             index = 0
00642 
00643         # This counts all fields with the submit name not just submit fields.
00644         current_index = 0
00645         for name, field in self.field_order:
00646             if name is None:  # pragma: no cover
00647                 continue
00648             if submit_name is not None and name == submit_name:
00649                 if index is not None and current_index == index:
00650                     submit.append((name, field.value_if_submitted()))
00651                 if submit_value is not None and \
00652                    field.value_if_submitted() == submit_value:
00653                     submit.append((name, field.value_if_submitted()))
00654                 current_index += 1
00655             else:
00656                 value = field.value
00657                 if value is None:
00658                     continue
00659                 if isinstance(field, File):
00660                     submit.append((name, field))
00661                     continue
00662                 if isinstance(value, list):
00663                     for item in value:
00664                         submit.append((name, item))
00665                 else:
00666                     submit.append((name, value))
00667         return submit
00668 
00669     def __repr__(self):
00670         value = '<Form'
00671         if self.id:
00672             value += ' id=%r' % str(self.id)
00673         return value + ' />'


webtest
Author(s): AlexV
autogenerated on Sat Mar 25 2017 03:32:05