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