1# -*- coding: utf-8 -*-
2"""Helpers to fill and submit forms."""
3
4import re
5import sys
6
7from bs4 import BeautifulSoup
8from webtest.compat import OrderedDict
9from webtest import utils
10
11
12class NoValue(object):
13    pass
14
15
16class 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
50class 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
99class 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
162class 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
236class 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
254class 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
290class Text(Field):
291    """Field representing ``<input type="text">``"""
292
293
294class 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
307class Textarea(Text):
308    """Field representing ``<textarea>``"""
309
310
311class Hidden(Text):
312    """Field representing ``<input type="hidden">``"""
313
314
315class 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
333Field.classes['submit'] = Submit
334
335Field.classes['button'] = Submit
336
337Field.classes['image'] = Submit
338
339Field.classes['multiple_select'] = MultipleSelect
340
341Field.classes['select'] = Select
342
343Field.classes['hidden'] = Hidden
344
345Field.classes['file'] = File
346
347Field.classes['text'] = Text
348
349Field.classes['password'] = Text
350
351Field.classes['checkbox'] = Checkbox
352
353Field.classes['textarea'] = Textarea
354
355Field.classes['radio'] = Radio
356
357
358class 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