Home | History | Annotate | Download | only in webtest
      1 # -*- coding: utf-8 -*-
      2 """Helpers to fill and submit forms."""
      3 
      4 import re
      5 import sys
      6 
      7 from bs4 import BeautifulSoup
      8 from webtest.compat import OrderedDict
      9 from webtest import utils
     10 
     11 
     12 class NoValue(object):
     13     pass
     14 
     15 
     16 class Upload(object):
     17     """
     18     A file to upload::
     19 
     20         >>> Upload('filename.txt', 'data', 'application/octet-stream')
     21         <Upload "filename.txt">
     22         >>> Upload('filename.txt', 'data')
     23         <Upload "filename.txt">
     24         >>> Upload("README.txt")
     25         <Upload "README.txt">
     26 
     27     :param filename: Name of the file to upload.
     28     :param content: Contents of the file.
     29     :param content_type: MIME type of the file.
     30 
     31     """
     32 
     33     def __init__(self, filename, content=None, content_type=None):
     34         self.filename = filename
     35         self.content = content
     36         self.content_type = content_type
     37 
     38     def __iter__(self):
     39         yield self.filename
     40         if self.content:
     41             yield self.content
     42             yield self.content_type
     43         # TODO: do we handle the case when we need to get
     44         # contents ourselves?
     45 
     46     def __repr__(self):
     47         return '<Upload "%s">' % self.filename
     48 
     49 
     50 class Field(object):
     51     """Base class for all Field objects.
     52 
     53     .. attribute:: classes
     54 
     55         Dictionary of field types (select, radio, etc)
     56 
     57     .. attribute:: value
     58 
     59         Set/get value of the field.
     60 
     61     """
     62 
     63     classes = {}
     64 
     65     def __init__(self, form, tag, name, pos,
     66                  value=None, id=None, **attrs):
     67         self.form = form
     68         self.tag = tag
     69         self.name = name
     70         self.pos = pos
     71         self._value = value
     72         self.id = id
     73         self.attrs = attrs
     74 
     75     def value__get(self):
     76         if self._value is None:
     77             return ''
     78         else:
     79             return self._value
     80 
     81     def value__set(self, value):
     82         self._value = value
     83 
     84     value = property(value__get, value__set)
     85 
     86     def force_value(self, value):
     87         """Like setting a value, except forces it (even for, say, hidden
     88         fields).
     89         """
     90         self._value = value
     91 
     92     def __repr__(self):
     93         value = '<%s name="%s"' % (self.__class__.__name__, self.name)
     94         if self.id:
     95             value += ' id="%s"' % self.id
     96         return value + '>'
     97 
     98 
     99 class Select(Field):
    100     """Field representing ``<select />`` form element."""
    101 
    102     def __init__(self, *args, **attrs):
    103         super(Select, self).__init__(*args, **attrs)
    104         self.options = []
    105         # Undetermined yet:
    106         self.selectedIndex = None
    107         # we have no forced value
    108         self._forced_value = NoValue
    109 
    110     def force_value(self, value):
    111         """Like setting a value, except forces it (even for, say, hidden
    112         fields).
    113         """
    114         self._forced_value = value
    115 
    116     def select(self, value=None, text=None):
    117         if value is not None and text is not None:
    118             raise ValueError("Specify only one of value and text.")
    119 
    120         if text is not None:
    121             value = self._get_value_for_text(text)
    122 
    123         self.value = value
    124 
    125     def _get_value_for_text(self, text):
    126         for i, (option_value, checked, option_text) in enumerate(self.options):
    127             if option_text == utils.stringify(text):
    128                 return option_value
    129 
    130         raise ValueError("Option with text %r not found (from %s)"
    131                          % (text, ', '.join(
    132                              [repr(t) for o, c, t in self.options])))
    133 
    134     def value__set(self, value):
    135         if self._forced_value is not NoValue:
    136             self._forced_value = NoValue
    137         for i, (option, checked, text) in enumerate(self.options):
    138             if option == utils.stringify(value):
    139                 self.selectedIndex = i
    140                 break
    141         else:
    142             raise ValueError(
    143                 "Option %r not found (from %s)"
    144                 % (value, ', '.join([repr(o) for o, c, t in self.options])))
    145 
    146     def value__get(self):
    147         if self._forced_value is not NoValue:
    148             return self._forced_value
    149         elif self.selectedIndex is not None:
    150             return self.options[self.selectedIndex][0]
    151         else:
    152             for option, checked, text in self.options:
    153                 if checked:
    154                     return option
    155             else:
    156                 if self.options:
    157                     return self.options[0][0]
    158 
    159     value = property(value__get, value__set)
    160 
    161 
    162 class MultipleSelect(Field):
    163     """Field representing ``<select multiple="multiple">``"""
    164 
    165     def __init__(self, *args, **attrs):
    166         super(MultipleSelect, self).__init__(*args, **attrs)
    167         self.options = []
    168         # Undetermined yet:
    169         self.selectedIndices = []
    170         self._forced_values = []
    171 
    172     def force_value(self, values):
    173         """Like setting a value, except forces it (even for, say, hidden
    174         fields).
    175         """
    176         self._forced_values = values
    177         self.selectedIndices = []
    178 
    179     def select_multiple(self, value=None, texts=None):
    180         if value is not None and texts is not None:
    181             raise ValueError("Specify only one of value and texts.")
    182 
    183         if texts is not None:
    184             value = self._get_value_for_texts(texts)
    185 
    186         self.value = value
    187 
    188     def _get_value_for_texts(self, texts):
    189         str_texts = [utils.stringify(text) for text in texts]
    190         value = []
    191         for i, (option, checked, text) in enumerate(self.options):
    192             if text in str_texts:
    193                 value.append(option)
    194                 str_texts.remove(text)
    195 
    196         if str_texts:
    197             raise ValueError(
    198                 "Option(s) %r not found (from %s)"
    199                 % (', '.join(str_texts),
    200                    ', '.join([repr(t) for o, c, t in self.options])))
    201 
    202         return value
    203 
    204     def value__set(self, values):
    205         str_values = [utils.stringify(value) for value in values]
    206         self.selectedIndices = []
    207         for i, (option, checked, text) in enumerate(self.options):
    208             if option in str_values:
    209                 self.selectedIndices.append(i)
    210                 str_values.remove(option)
    211         if str_values:
    212             raise ValueError(
    213                 "Option(s) %r not found (from %s)"
    214                 % (', '.join(str_values),
    215                    ', '.join([repr(o) for o, c, t in self.options])))
    216 
    217     def value__get(self):
    218         selected_values = []
    219         if self.selectedIndices:
    220             selected_values = [self.options[i][0]
    221                                for i in self.selectedIndices]
    222         elif not self._forced_values:
    223             selected_values = []
    224             for option, checked, text in self.options:
    225                 if checked:
    226                     selected_values.append(option)
    227         if self._forced_values:
    228             selected_values += self._forced_values
    229 
    230         if self.options and (not selected_values):
    231             selected_values = None
    232         return selected_values
    233     value = property(value__get, value__set)
    234 
    235 
    236 class Radio(Select):
    237     """Field representing ``<input type="radio">``"""
    238 
    239     def value__get(self):
    240         if self._forced_value is not NoValue:
    241             return self._forced_value
    242         elif self.selectedIndex is not None:
    243             return self.options[self.selectedIndex][0]
    244         else:
    245             for option, checked, text in self.options:
    246                 if checked:
    247                     return option
    248             else:
    249                 return None
    250 
    251     value = property(value__get, Select.value__set)
    252 
    253 
    254 class Checkbox(Field):
    255     """Field representing ``<input type="checkbox">``
    256 
    257     .. attribute:: checked
    258 
    259         Returns True if checkbox is checked.
    260 
    261     """
    262 
    263     def __init__(self, *args, **attrs):
    264         super(Checkbox, self).__init__(*args, **attrs)
    265         self._checked = 'checked' in attrs
    266 
    267     def value__set(self, value):
    268         self._checked = not not value
    269 
    270     def value__get(self):
    271         if self._checked:
    272             if self._value is None:
    273                 return 'on'
    274             else:
    275                 return self._value
    276         else:
    277             return None
    278 
    279     value = property(value__get, value__set)
    280 
    281     def checked__get(self):
    282         return bool(self._checked)
    283 
    284     def checked__set(self, value):
    285         self._checked = not not value
    286 
    287     checked = property(checked__get, checked__set)
    288 
    289 
    290 class Text(Field):
    291     """Field representing ``<input type="text">``"""
    292 
    293 
    294 class File(Field):
    295     """Field representing ``<input type="file">``"""
    296 
    297     # TODO: This doesn't actually handle file uploads and enctype
    298     def value__get(self):
    299         if self._value is None:
    300             return ''
    301         else:
    302             return self._value
    303 
    304     value = property(value__get, Field.value__set)
    305 
    306 
    307 class Textarea(Text):
    308     """Field representing ``<textarea>``"""
    309 
    310 
    311 class Hidden(Text):
    312     """Field representing ``<input type="hidden">``"""
    313 
    314 
    315 class Submit(Field):
    316     """Field representing ``<input type="submit">`` and ``<button>``"""
    317 
    318     def value__get(self):
    319         return None
    320 
    321     def value__set(self, value):
    322         raise AttributeError(
    323             "You cannot set the value of the <%s> field %r"
    324             % (self.tag, self.name))
    325 
    326     value = property(value__get, value__set)
    327 
    328     def value_if_submitted(self):
    329         # TODO: does this ever get set?
    330         return self._value
    331 
    332 
    333 Field.classes['submit'] = Submit
    334 
    335 Field.classes['button'] = Submit
    336 
    337 Field.classes['image'] = Submit
    338 
    339 Field.classes['multiple_select'] = MultipleSelect
    340 
    341 Field.classes['select'] = Select
    342 
    343 Field.classes['hidden'] = Hidden
    344 
    345 Field.classes['file'] = File
    346 
    347 Field.classes['text'] = Text
    348 
    349 Field.classes['password'] = Text
    350 
    351 Field.classes['checkbox'] = Checkbox
    352 
    353 Field.classes['textarea'] = Textarea
    354 
    355 Field.classes['radio'] = Radio
    356 
    357 
    358 class Form(object):
    359     """This object represents a form that has been found in a page.
    360 
    361     :param response: `webob.response.TestResponse` instance
    362     :param text: Unparsed html of the form
    363 
    364     .. attribute:: text
    365 
    366         the full HTML of the form.
    367 
    368     .. attribute:: action
    369 
    370         the relative URI of the action.
    371 
    372     .. attribute:: method
    373 
    374         the HTTP method (e.g., ``'GET'``).
    375 
    376     .. attribute:: id
    377 
    378         the id, or None if not given.
    379 
    380     .. attribute:: enctype
    381 
    382         encoding of the form submission
    383 
    384     .. attribute:: fields
    385 
    386         a dictionary of fields, each value is a list of fields by
    387         that name.  ``<input type=\"radio\">`` and ``<select>`` are
    388         both represented as single fields with multiple options.
    389 
    390     .. attribute:: field_order
    391 
    392         Ordered list of field names as found in the html.
    393 
    394     """
    395 
    396     # TODO: use BeautifulSoup4 for this
    397 
    398     _tag_re = re.compile(r'<(/?)([a-z0-9_\-]*)([^>]*?)>', re.I)
    399     _label_re = re.compile(
    400         '''<label\s+(?:[^>]*)for=(?:"|')([a-z0-9_\-]+)(?:"|')(?:[^>]*)>''',
    401         re.I)
    402 
    403     FieldClass = Field
    404 
    405     def __init__(self, response, text, parser_features='html.parser'):
    406         self.response = response
    407         self.text = text
    408         self.html = BeautifulSoup(self.text, parser_features)
    409 
    410         attrs = self.html('form')[0].attrs
    411         self.action = attrs.get('action', '')
    412         self.method = attrs.get('method', 'GET')
    413         self.id = attrs.get('id')
    414         self.enctype = attrs.get('enctype',
    415                                  'application/x-www-form-urlencoded')
    416 
    417         self._parse_fields()
    418 
    419     def _parse_fields(self):
    420         fields = OrderedDict()
    421         field_order = []
    422         tags = ('input', 'select', 'textarea', 'button')
    423         for pos, node in enumerate(self.html.findAll(tags)):
    424             attrs = dict(node.attrs)
    425             tag = node.name
    426             name = None
    427             if 'name' in attrs:
    428                 name = attrs.pop('name')
    429 
    430             if tag == 'textarea':
    431                 if node.text.startswith('\r\n'):  # pragma: no cover
    432                     text = node.text[2:]
    433                 elif node.text.startswith('\n'):
    434                     text = node.text[1:]
    435                 else:
    436                     text = node.text
    437                 attrs['value'] = text
    438 
    439             tag_type = attrs.get('type', 'text').lower()
    440             if tag == 'select':
    441                 tag_type = 'select'
    442             if tag_type == "select" and "multiple" in attrs:
    443                 tag_type = "multiple_select"
    444             if tag == 'button':
    445                 tag_type = 'submit'
    446 
    447             FieldClass = self.FieldClass.classes.get(tag_type,
    448                                                      self.FieldClass)
    449 
    450             # https://github.com/Pylons/webtest/issues/73
    451             if sys.version_info[:2] <= (2, 6):
    452                 attrs = dict((k.encode('utf-8') if isinstance(k, unicode)
    453                               else k, v) for k, v in attrs.items())
    454 
    455             # https://github.com/Pylons/webtest/issues/131
    456             reserved_attributes = ('form', 'tag', 'pos')
    457             for attr in reserved_attributes:
    458                 if attr in attrs:
    459                     del attrs[attr]
    460 
    461             if tag == 'input':
    462                 if tag_type == 'radio':
    463                     field = fields.get(name)
    464                     if not field:
    465                         field = FieldClass(self, tag, name, pos, **attrs)
    466                         fields.setdefault(name, []).append(field)
    467                         field_order.append((name, field))
    468                     else:
    469                         field = field[0]
    470                         assert isinstance(field,
    471                                           self.FieldClass.classes['radio'])
    472                     field.options.append((attrs.get('value'),
    473                                           'checked' in attrs,
    474                                           None))
    475                     continue
    476                 elif tag_type == 'file':
    477                     if 'value' in attrs:
    478                         del attrs['value']
    479 
    480             field = FieldClass(self, tag, name, pos, **attrs)
    481             fields.setdefault(name, []).append(field)
    482             field_order.append((name, field))
    483 
    484             if tag == 'select':
    485                 for option in node('option'):
    486                     field.options.append(
    487                         (option.attrs.get('value', option.text),
    488                          'selected' in option.attrs,
    489                          option.text))
    490 
    491         self.field_order = field_order
    492         self.fields = fields
    493 
    494     def __setitem__(self, name, value):
    495         """Set the value of the named field. If there is 0 or multiple fields
    496         by that name, it is an error.
    497 
    498         Multiple checkboxes of the same name are special-cased; a list may be
    499         assigned to them to check the checkboxes whose value is present in the
    500         list (and uncheck all others).
    501 
    502         Setting the value of a ``<select>`` selects the given option (and
    503         confirms it is an option). Setting radio fields does the same.
    504         Checkboxes get boolean values. You cannot set hidden fields or buttons.
    505 
    506         Use ``.set()`` if there is any ambiguity and you must provide an index.
    507         """
    508         fields = self.fields.get(name)
    509         assert fields is not None, (
    510             "No field by the name %r found (fields: %s)"
    511             % (name, ', '.join(map(repr, self.fields.keys()))))
    512         all_checkboxes = all(isinstance(f, Checkbox) for f in fields)
    513         if all_checkboxes and isinstance(value, list):
    514             values = set(utils.stringify(v) for v in value)
    515             for f in fields:
    516                 f.checked = f._value in values
    517         else:
    518             assert len(fields) == 1, (
    519                 "Multiple fields match %r: %s"
    520                 % (name, ', '.join(map(repr, fields))))
    521             fields[0].value = value
    522 
    523     def __getitem__(self, name):
    524         """Get the named field object (ambiguity is an error)."""
    525         fields = self.fields.get(name)
    526         assert fields is not None, (
    527             "No field by the name %r found" % name)
    528         assert len(fields) == 1, (
    529             "Multiple fields match %r: %s"
    530             % (name, ', '.join(map(repr, fields))))
    531         return fields[0]
    532 
    533     def lint(self):
    534         """
    535         Check that the html is valid:
    536 
    537         - each field must have an id
    538         - each field must have a label
    539 
    540         """
    541         labels = self._label_re.findall(self.text)
    542         for name, fields in self.fields.items():
    543             for field in fields:
    544                 if not isinstance(field, (Submit, Hidden)):
    545                     if not field.id:
    546                         raise AttributeError("%r as no id attribute" % field)
    547                     elif field.id not in labels:
    548                         raise AttributeError(
    549                             "%r as no associated label" % field)
    550 
    551     def set(self, name, value, index=None):
    552         """Set the given name, using ``index`` to disambiguate."""
    553         if index is None:
    554             self[name] = value
    555         else:
    556             fields = self.fields.get(name)
    557             assert fields is not None, (
    558                 "No fields found matching %r" % name)
    559             field = fields[index]
    560             field.value = value
    561 
    562     def get(self, name, index=None, default=utils.NoDefault):
    563         """
    564         Get the named/indexed field object, or ``default`` if no field is
    565         found. Throws an AssertionError if no field is found and no ``default``
    566         was given.
    567         """
    568         fields = self.fields.get(name)
    569         if fields is None:
    570             if default is utils.NoDefault:
    571                 raise AssertionError(
    572                     "No fields found matching %r (and no default given)"
    573                     % name)
    574             return default
    575         if index is None:
    576             return self[name]
    577         return fields[index]
    578 
    579     def select(self, name, value=None, text=None, index=None):
    580         """Like ``.set()``, except also confirms the target is a ``<select>``
    581         and allows selecting options by text.
    582         """
    583         field = self.get(name, index=index)
    584         assert isinstance(field, Select)
    585 
    586         field.select(value, text)
    587 
    588     def select_multiple(self, name, value=None, texts=None, index=None):
    589         """Like ``.set()``, except also confirms the target is a
    590         ``<select multiple>`` and allows selecting options by text.
    591         """
    592         field = self.get(name, index=index)
    593         assert isinstance(field, MultipleSelect)
    594 
    595         field.select_multiple(value, texts)
    596 
    597     def submit(self, name=None, index=None, value=None, **args):
    598         """Submits the form.  If ``name`` is given, then also select that
    599         button (using ``index`` or ``value`` to disambiguate)``.
    600 
    601         Any extra keyword arguments are passed to the
    602         :meth:`webtest.TestResponse.get` or
    603         :meth:`webtest.TestResponse.post` method.
    604 
    605         Returns a :class:`webtest.TestResponse` object.
    606 
    607         """
    608         fields = self.submit_fields(name, index=index, submit_value=value)
    609         if self.method.upper() != "GET":
    610             args.setdefault("content_type",  self.enctype)
    611         return self.response.goto(self.action, method=self.method,
    612                                   params=fields, **args)
    613 
    614     def upload_fields(self):
    615         """Return a list of file field tuples of the form::
    616 
    617             (field name, file name)
    618 
    619         or::
    620 
    621             (field name, file name, file contents).
    622 
    623         """
    624         uploads = []
    625         for name, fields in self.fields.items():
    626             for field in fields:
    627                 if isinstance(field, File) and field.value:
    628                     uploads.append([name] + list(field.value))
    629         return uploads
    630 
    631     def submit_fields(self, name=None, index=None, submit_value=None):
    632         """Return a list of ``[(name, value), ...]`` for the current state of
    633         the form.
    634 
    635         :param name: Same as for :meth:`submit`
    636         :param index: Same as for :meth:`submit`
    637 
    638         """
    639         submit = []
    640         # Use another name here so we can keep function param the same for BWC.
    641         submit_name = name
    642         if index is not None and submit_value is not None:
    643             raise ValueError("Can't specify both submit_value and index.")
    644 
    645         # If no particular button was selected, use the first one
    646         if index is None and submit_value is None:
    647             index = 0
    648 
    649         # This counts all fields with the submit name not just submit fields.
    650         current_index = 0
    651         for name, field in self.field_order:
    652             if name is None:  # pragma: no cover
    653                 continue
    654             if submit_name is not None and name == submit_name:
    655                 if index is not None and current_index == index:
    656                     submit.append((name, field.value_if_submitted()))
    657                 if submit_value is not None and \
    658                    field.value_if_submitted() == submit_value:
    659                     submit.append((name, field.value_if_submitted()))
    660                 current_index += 1
    661             else:
    662                 value = field.value
    663                 if value is None:
    664                     continue
    665                 if isinstance(field, File):
    666                     submit.append((name, field))
    667                     continue
    668                 if isinstance(value, list):
    669                     for item in value:
    670                         submit.append((name, item))
    671                 else:
    672                     submit.append((name, value))
    673         return submit
    674 
    675     def __repr__(self):
    676         value = '<Form'
    677         if self.id:
    678             value += ' id=%r' % str(self.id)
    679         return value + ' />'
    680