1 # (c) 2005 Ian Bicking and contributors; written for Paste (http://pythonpaste.org) 2 # Licensed under the MIT license: http://www.opensource.org/licenses/mit-license.php 3 """ 4 Routines for testing WSGI applications. 5 6 Most interesting is the `TestApp <class-paste.fixture.TestApp.html>`_ 7 for testing WSGI applications, and the `TestFileEnvironment 8 <class-paste.fixture.TestFileEnvironment.html>`_ class for testing the 9 effects of command-line scripts. 10 """ 11 12 from __future__ import print_function 13 14 import sys 15 import random 16 import mimetypes 17 import time 18 import os 19 import shutil 20 import smtplib 21 import shlex 22 import re 23 import six 24 import subprocess 25 from six.moves import cStringIO as StringIO 26 from six.moves.urllib.parse import urlencode 27 from six.moves.urllib import parse as urlparse 28 try: 29 # Python 3 30 from http.cookies import BaseCookie 31 from urllib.parse import splittype, splithost 32 except ImportError: 33 # Python 2 34 from Cookie import BaseCookie 35 from urllib import splittype, splithost 36 37 from paste import wsgilib 38 from paste import lint 39 from paste.response import HeaderDict 40 41 def tempnam_no_warning(*args): 42 """ 43 An os.tempnam with the warning turned off, because sometimes 44 you just need to use this and don't care about the stupid 45 security warning. 46 """ 47 return os.tempnam(*args) 48 49 class NoDefault(object): 50 pass 51 52 def sorted(l): 53 l = list(l) 54 l.sort() 55 return l 56 57 class Dummy_smtplib(object): 58 59 existing = None 60 61 def __init__(self, server): 62 import warnings 63 warnings.warn( 64 'Dummy_smtplib is not maintained and is deprecated', 65 DeprecationWarning, 2) 66 assert not self.existing, ( 67 "smtplib.SMTP() called again before Dummy_smtplib.existing.reset() " 68 "called.") 69 self.server = server 70 self.open = True 71 self.__class__.existing = self 72 73 def quit(self): 74 assert self.open, ( 75 "Called %s.quit() twice" % self) 76 self.open = False 77 78 def sendmail(self, from_address, to_addresses, msg): 79 self.from_address = from_address 80 self.to_addresses = to_addresses 81 self.message = msg 82 83 def install(cls): 84 smtplib.SMTP = cls 85 86 install = classmethod(install) 87 88 def reset(self): 89 assert not self.open, ( 90 "SMTP connection not quit") 91 self.__class__.existing = None 92 93 class AppError(Exception): 94 pass 95 96 class TestApp(object): 97 98 # for py.test 99 disabled = True 100 101 def __init__(self, app, namespace=None, relative_to=None, 102 extra_environ=None, pre_request_hook=None, 103 post_request_hook=None): 104 """ 105 Wraps a WSGI application in a more convenient interface for 106 testing. 107 108 ``app`` may be an application, or a Paste Deploy app 109 URI, like ``'config:filename.ini#test'``. 110 111 ``namespace`` is a dictionary that will be written to (if 112 provided). This can be used with doctest or some other 113 system, and the variable ``res`` will be assigned everytime 114 you make a request (instead of returning the request). 115 116 ``relative_to`` is a directory, and filenames used for file 117 uploads are calculated relative to this. Also ``config:`` 118 URIs that aren't absolute. 119 120 ``extra_environ`` is a dictionary of values that should go 121 into the environment for each request. These can provide a 122 communication channel with the application. 123 124 ``pre_request_hook`` is a function to be called prior to 125 making requests (such as ``post`` or ``get``). This function 126 must take one argument (the instance of the TestApp). 127 128 ``post_request_hook`` is a function, similar to 129 ``pre_request_hook``, to be called after requests are made. 130 """ 131 if isinstance(app, (six.binary_type, six.text_type)): 132 from paste.deploy import loadapp 133 # @@: Should pick up relative_to from calling module's 134 # __file__ 135 app = loadapp(app, relative_to=relative_to) 136 self.app = app 137 self.namespace = namespace 138 self.relative_to = relative_to 139 if extra_environ is None: 140 extra_environ = {} 141 self.extra_environ = extra_environ 142 self.pre_request_hook = pre_request_hook 143 self.post_request_hook = post_request_hook 144 self.reset() 145 146 def reset(self): 147 """ 148 Resets the state of the application; currently just clears 149 saved cookies. 150 """ 151 self.cookies = {} 152 153 def _make_environ(self): 154 environ = self.extra_environ.copy() 155 environ['paste.throw_errors'] = True 156 return environ 157 158 def get(self, url, params=None, headers=None, extra_environ=None, 159 status=None, expect_errors=False): 160 """ 161 Get the given url (well, actually a path like 162 ``'/page.html'``). 163 164 ``params``: 165 A query string, or a dictionary that will be encoded 166 into a query string. You may also include a query 167 string on the ``url``. 168 169 ``headers``: 170 A dictionary of extra headers to send. 171 172 ``extra_environ``: 173 A dictionary of environmental variables that should 174 be added to the request. 175 176 ``status``: 177 The integer status code you expect (if not 200 or 3xx). 178 If you expect a 404 response, for instance, you must give 179 ``status=404`` or it will be an error. You can also give 180 a wildcard, like ``'3*'`` or ``'*'``. 181 182 ``expect_errors``: 183 If this is not true, then if anything is written to 184 ``wsgi.errors`` it will be an error. If it is true, then 185 non-200/3xx responses are also okay. 186 187 Returns a `response object 188 <class-paste.fixture.TestResponse.html>`_ 189 """ 190 if extra_environ is None: 191 extra_environ = {} 192 # Hide from py.test: 193 __tracebackhide__ = True 194 if params: 195 if not isinstance(params, (six.binary_type, six.text_type)): 196 params = urlencode(params, doseq=True) 197 if '?' in url: 198 url += '&' 199 else: 200 url += '?' 201 url += params 202 environ = self._make_environ() 203 url = str(url) 204 if '?' in url: 205 url, environ['QUERY_STRING'] = url.split('?', 1) 206 else: 207 environ['QUERY_STRING'] = '' 208 self._set_headers(headers, environ) 209 environ.update(extra_environ) 210 req = TestRequest(url, environ, expect_errors) 211 return self.do_request(req, status=status) 212 213 def _gen_request(self, method, url, params=b'', headers=None, extra_environ=None, 214 status=None, upload_files=None, expect_errors=False): 215 """ 216 Do a generic request. 217 """ 218 if headers is None: 219 headers = {} 220 if extra_environ is None: 221 extra_environ = {} 222 environ = self._make_environ() 223 # @@: Should this be all non-strings? 224 if isinstance(params, (list, tuple, dict)): 225 params = urlencode(params) 226 if hasattr(params, 'items'): 227 # Some other multi-dict like format 228 params = urlencode(params.items()) 229 if six.PY3: 230 params = params.encode('utf8') 231 if upload_files: 232 params = urlparse.parse_qsl(params, keep_blank_values=True) 233 content_type, params = self.encode_multipart( 234 params, upload_files) 235 environ['CONTENT_TYPE'] = content_type 236 elif params: 237 environ.setdefault('CONTENT_TYPE', 'application/x-www-form-urlencoded') 238 if '?' in url: 239 url, environ['QUERY_STRING'] = url.split('?', 1) 240 else: 241 environ['QUERY_STRING'] = '' 242 environ['CONTENT_LENGTH'] = str(len(params)) 243 environ['REQUEST_METHOD'] = method 244 environ['wsgi.input'] = six.BytesIO(params) 245 self._set_headers(headers, environ) 246 environ.update(extra_environ) 247 req = TestRequest(url, environ, expect_errors) 248 return self.do_request(req, status=status) 249 250 def post(self, url, params=b'', headers=None, extra_environ=None, 251 status=None, upload_files=None, expect_errors=False): 252 """ 253 Do a POST request. Very like the ``.get()`` method. 254 ``params`` are put in the body of the request. 255 256 ``upload_files`` is for file uploads. It should be a list of 257 ``[(fieldname, filename, file_content)]``. You can also use 258 just ``[(fieldname, filename)]`` and the file content will be 259 read from disk. 260 261 Returns a `response object 262 <class-paste.fixture.TestResponse.html>`_ 263 """ 264 return self._gen_request('POST', url, params=params, headers=headers, 265 extra_environ=extra_environ,status=status, 266 upload_files=upload_files, 267 expect_errors=expect_errors) 268 269 def put(self, url, params=b'', headers=None, extra_environ=None, 270 status=None, upload_files=None, expect_errors=False): 271 """ 272 Do a PUT request. Very like the ``.get()`` method. 273 ``params`` are put in the body of the request. 274 275 ``upload_files`` is for file uploads. It should be a list of 276 ``[(fieldname, filename, file_content)]``. You can also use 277 just ``[(fieldname, filename)]`` and the file content will be 278 read from disk. 279 280 Returns a `response object 281 <class-paste.fixture.TestResponse.html>`_ 282 """ 283 return self._gen_request('PUT', url, params=params, headers=headers, 284 extra_environ=extra_environ,status=status, 285 upload_files=upload_files, 286 expect_errors=expect_errors) 287 288 def delete(self, url, params=b'', headers=None, extra_environ=None, 289 status=None, expect_errors=False): 290 """ 291 Do a DELETE request. Very like the ``.get()`` method. 292 ``params`` are put in the body of the request. 293 294 Returns a `response object 295 <class-paste.fixture.TestResponse.html>`_ 296 """ 297 return self._gen_request('DELETE', url, params=params, headers=headers, 298 extra_environ=extra_environ,status=status, 299 upload_files=None, expect_errors=expect_errors) 300 301 302 303 304 def _set_headers(self, headers, environ): 305 """ 306 Turn any headers into environ variables 307 """ 308 if not headers: 309 return 310 for header, value in headers.items(): 311 if header.lower() == 'content-type': 312 var = 'CONTENT_TYPE' 313 elif header.lower() == 'content-length': 314 var = 'CONTENT_LENGTH' 315 else: 316 var = 'HTTP_%s' % header.replace('-', '_').upper() 317 environ[var] = value 318 319 def encode_multipart(self, params, files): 320 """ 321 Encodes a set of parameters (typically a name/value list) and 322 a set of files (a list of (name, filename, file_body)) into a 323 typical POST body, returning the (content_type, body). 324 """ 325 boundary = '----------a_BoUnDaRy%s$' % random.random() 326 content_type = 'multipart/form-data; boundary=%s' % boundary 327 if six.PY3: 328 boundary = boundary.encode('ascii') 329 330 lines = [] 331 for key, value in params: 332 lines.append(b'--'+boundary) 333 line = 'Content-Disposition: form-data; name="%s"' % key 334 if six.PY3: 335 line = line.encode('utf8') 336 lines.append(line) 337 lines.append(b'') 338 line = value 339 if six.PY3 and isinstance(line, six.text_type): 340 line = line.encode('utf8') 341 lines.append(line) 342 for file_info in files: 343 key, filename, value = self._get_file_info(file_info) 344 lines.append(b'--'+boundary) 345 line = ('Content-Disposition: form-data; name="%s"; filename="%s"' 346 % (key, filename)) 347 if six.PY3: 348 line = line.encode('utf8') 349 lines.append(line) 350 fcontent = mimetypes.guess_type(filename)[0] 351 line = ('Content-Type: %s' 352 % (fcontent or 'application/octet-stream')) 353 if six.PY3: 354 line = line.encode('utf8') 355 lines.append(line) 356 lines.append(b'') 357 lines.append(value) 358 lines.append(b'--' + boundary + b'--') 359 lines.append(b'') 360 body = b'\r\n'.join(lines) 361 return content_type, body 362 363 def _get_file_info(self, file_info): 364 if len(file_info) == 2: 365 # It only has a filename 366 filename = file_info[1] 367 if self.relative_to: 368 filename = os.path.join(self.relative_to, filename) 369 f = open(filename, 'rb') 370 content = f.read() 371 f.close() 372 return (file_info[0], filename, content) 373 elif len(file_info) == 3: 374 return file_info 375 else: 376 raise ValueError( 377 "upload_files need to be a list of tuples of (fieldname, " 378 "filename, filecontent) or (fieldname, filename); " 379 "you gave: %r" 380 % repr(file_info)[:100]) 381 382 def do_request(self, req, status): 383 """ 384 Executes the given request (``req``), with the expected 385 ``status``. Generally ``.get()`` and ``.post()`` are used 386 instead. 387 """ 388 if self.pre_request_hook: 389 self.pre_request_hook(self) 390 __tracebackhide__ = True 391 if self.cookies: 392 c = BaseCookie() 393 for name, value in self.cookies.items(): 394 c[name] = value 395 hc = '; '.join(['='.join([m.key, m.value]) for m in c.values()]) 396 req.environ['HTTP_COOKIE'] = hc 397 req.environ['paste.testing'] = True 398 req.environ['paste.testing_variables'] = {} 399 app = lint.middleware(self.app) 400 old_stdout = sys.stdout 401 out = CaptureStdout(old_stdout) 402 try: 403 sys.stdout = out 404 start_time = time.time() 405 raise_on_wsgi_error = not req.expect_errors 406 raw_res = wsgilib.raw_interactive( 407 app, req.url, 408 raise_on_wsgi_error=raise_on_wsgi_error, 409 **req.environ) 410 end_time = time.time() 411 finally: 412 sys.stdout = old_stdout 413 sys.stderr.write(out.getvalue()) 414 res = self._make_response(raw_res, end_time - start_time) 415 res.request = req 416 for name, value in req.environ['paste.testing_variables'].items(): 417 if hasattr(res, name): 418 raise ValueError( 419 "paste.testing_variables contains the variable %r, but " 420 "the response object already has an attribute by that " 421 "name" % name) 422 setattr(res, name, value) 423 if self.namespace is not None: 424 self.namespace['res'] = res 425 if not req.expect_errors: 426 self._check_status(status, res) 427 self._check_errors(res) 428 res.cookies_set = {} 429 for header in res.all_headers('set-cookie'): 430 c = BaseCookie(header) 431 for key, morsel in c.items(): 432 self.cookies[key] = morsel.value 433 res.cookies_set[key] = morsel.value 434 if self.post_request_hook: 435 self.post_request_hook(self) 436 if self.namespace is None: 437 # It's annoying to return the response in doctests, as it'll 438 # be printed, so we only return it is we couldn't assign 439 # it anywhere 440 return res 441 442 def _check_status(self, status, res): 443 __tracebackhide__ = True 444 if status == '*': 445 return 446 if isinstance(status, (list, tuple)): 447 if res.status not in status: 448 raise AppError( 449 "Bad response: %s (not one of %s for %s)\n%s" 450 % (res.full_status, ', '.join(map(str, status)), 451 res.request.url, res.body)) 452 return 453 if status is None: 454 if res.status >= 200 and res.status < 400: 455 return 456 body = res.body 457 if six.PY3: 458 body = body.decode('utf8', 'xmlcharrefreplace') 459 raise AppError( 460 "Bad response: %s (not 200 OK or 3xx redirect for %s)\n%s" 461 % (res.full_status, res.request.url, 462 body)) 463 if status != res.status: 464 raise AppError( 465 "Bad response: %s (not %s)" % (res.full_status, status)) 466 467 def _check_errors(self, res): 468 if res.errors: 469 raise AppError( 470 "Application had errors logged:\n%s" % res.errors) 471 472 def _make_response(self, resp, total_time): 473 status, headers, body, errors = resp 474 return TestResponse(self, status, headers, body, errors, 475 total_time) 476 477 class CaptureStdout(object): 478 479 def __init__(self, actual): 480 self.captured = StringIO() 481 self.actual = actual 482 483 def write(self, s): 484 self.captured.write(s) 485 self.actual.write(s) 486 487 def flush(self): 488 self.actual.flush() 489 490 def writelines(self, lines): 491 for item in lines: 492 self.write(item) 493 494 def getvalue(self): 495 return self.captured.getvalue() 496 497 class TestResponse(object): 498 499 # for py.test 500 disabled = True 501 502 """ 503 Instances of this class are return by `TestApp 504 <class-paste.fixture.TestApp.html>`_ 505 """ 506 507 def __init__(self, test_app, status, headers, body, errors, 508 total_time): 509 self.test_app = test_app 510 self.status = int(status.split()[0]) 511 self.full_status = status 512 self.headers = headers 513 self.header_dict = HeaderDict.fromlist(self.headers) 514 self.body = body 515 self.errors = errors 516 self._normal_body = None 517 self.time = total_time 518 self._forms_indexed = None 519 520 def forms__get(self): 521 """ 522 Returns a dictionary of ``Form`` objects. Indexes are both in 523 order (from zero) and by form id (if the form is given an id). 524 """ 525 if self._forms_indexed is None: 526 self._parse_forms() 527 return self._forms_indexed 528 529 forms = property(forms__get, 530 doc=""" 531 A list of <form>s found on the page (instances of 532 `Form <class-paste.fixture.Form.html>`_) 533 """) 534 535 def form__get(self): 536 forms = self.forms 537 if not forms: 538 raise TypeError( 539 "You used response.form, but no forms exist") 540 if 1 in forms: 541 # There is more than one form 542 raise TypeError( 543 "You used response.form, but more than one form exists") 544 return forms[0] 545 546 form = property(form__get, 547 doc=""" 548 Returns a single `Form 549 <class-paste.fixture.Form.html>`_ instance; it 550 is an error if there are multiple forms on the 551 page. 552 """) 553 554 _tag_re = re.compile(r'<(/?)([:a-z0-9_\-]*)(.*?)>', re.S|re.I) 555 556 def _parse_forms(self): 557 forms = self._forms_indexed = {} 558 form_texts = [] 559 started = None 560 for match in self._tag_re.finditer(self.body): 561 end = match.group(1) == '/' 562 tag = match.group(2).lower() 563 if tag != 'form': 564 continue 565 if end: 566 assert started, ( 567 "</form> unexpected at %s" % match.start()) 568 form_texts.append(self.body[started:match.end()]) 569 started = None 570 else: 571 assert not started, ( 572 "Nested form tags at %s" % match.start()) 573 started = match.start() 574 assert not started, ( 575 "Danging form: %r" % self.body[started:]) 576 for i, text in enumerate(form_texts): 577 form = Form(self, text) 578 forms[i] = form 579 if form.id: 580 forms[form.id] = form 581 582 def header(self, name, default=NoDefault): 583 """ 584 Returns the named header; an error if there is not exactly one 585 matching header (unless you give a default -- always an error 586 if there is more than one header) 587 """ 588 found = None 589 for cur_name, value in self.headers: 590 if cur_name.lower() == name.lower(): 591 assert not found, ( 592 "Ambiguous header: %s matches %r and %r" 593 % (name, found, value)) 594 found = value 595 if found is None: 596 if default is NoDefault: 597 raise KeyError( 598 "No header found: %r (from %s)" 599 % (name, ', '.join([n for n, v in self.headers]))) 600 else: 601 return default 602 return found 603 604 def all_headers(self, name): 605 """ 606 Gets all headers by the ``name``, returns as a list 607 """ 608 found = [] 609 for cur_name, value in self.headers: 610 if cur_name.lower() == name.lower(): 611 found.append(value) 612 return found 613 614 def follow(self, **kw): 615 """ 616 If this request is a redirect, follow that redirect. It 617 is an error if this is not a redirect response. Returns 618 another response object. 619 """ 620 assert self.status >= 300 and self.status < 400, ( 621 "You can only follow redirect responses (not %s)" 622 % self.full_status) 623 location = self.header('location') 624 type, rest = splittype(location) 625 host, path = splithost(rest) 626 # @@: We should test that it's not a remote redirect 627 return self.test_app.get(location, **kw) 628 629 def click(self, description=None, linkid=None, href=None, 630 anchor=None, index=None, verbose=False): 631 """ 632 Click the link as described. Each of ``description``, 633 ``linkid``, and ``url`` are *patterns*, meaning that they are 634 either strings (regular expressions), compiled regular 635 expressions (objects with a ``search`` method), or callables 636 returning true or false. 637 638 All the given patterns are ANDed together: 639 640 * ``description`` is a pattern that matches the contents of the 641 anchor (HTML and all -- everything between ``<a...>`` and 642 ``</a>``) 643 644 * ``linkid`` is a pattern that matches the ``id`` attribute of 645 the anchor. It will receive the empty string if no id is 646 given. 647 648 * ``href`` is a pattern that matches the ``href`` of the anchor; 649 the literal content of that attribute, not the fully qualified 650 attribute. 651 652 * ``anchor`` is a pattern that matches the entire anchor, with 653 its contents. 654 655 If more than one link matches, then the ``index`` link is 656 followed. If ``index`` is not given and more than one link 657 matches, or if no link matches, then ``IndexError`` will be 658 raised. 659 660 If you give ``verbose`` then messages will be printed about 661 each link, and why it does or doesn't match. If you use 662 ``app.click(verbose=True)`` you'll see a list of all the 663 links. 664 665 You can use multiple criteria to essentially assert multiple 666 aspects about the link, e.g., where the link's destination is. 667 """ 668 __tracebackhide__ = True 669 found_html, found_desc, found_attrs = self._find_element( 670 tag='a', href_attr='href', 671 href_extract=None, 672 content=description, 673 id=linkid, 674 href_pattern=href, 675 html_pattern=anchor, 676 index=index, verbose=verbose) 677 return self.goto(found_attrs['uri']) 678 679 def clickbutton(self, description=None, buttonid=None, href=None, 680 button=None, index=None, verbose=False): 681 """ 682 Like ``.click()``, except looks for link-like buttons. 683 This kind of button should look like 684 ``<button onclick="...location.href='url'...">``. 685 """ 686 __tracebackhide__ = True 687 found_html, found_desc, found_attrs = self._find_element( 688 tag='button', href_attr='onclick', 689 href_extract=re.compile(r"location\.href='(.*?)'"), 690 content=description, 691 id=buttonid, 692 href_pattern=href, 693 html_pattern=button, 694 index=index, verbose=verbose) 695 return self.goto(found_attrs['uri']) 696 697 def _find_element(self, tag, href_attr, href_extract, 698 content, id, 699 href_pattern, 700 html_pattern, 701 index, verbose): 702 content_pat = _make_pattern(content) 703 id_pat = _make_pattern(id) 704 href_pat = _make_pattern(href_pattern) 705 html_pat = _make_pattern(html_pattern) 706 707 _tag_re = re.compile(r'<%s\s+(.*?)>(.*?)</%s>' % (tag, tag), 708 re.I+re.S) 709 710 def printlog(s): 711 if verbose: 712 print(s) 713 714 found_links = [] 715 total_links = 0 716 for match in _tag_re.finditer(self.body): 717 el_html = match.group(0) 718 el_attr = match.group(1) 719 el_content = match.group(2) 720 attrs = _parse_attrs(el_attr) 721 if verbose: 722 printlog('Element: %r' % el_html) 723 if not attrs.get(href_attr): 724 printlog(' Skipped: no %s attribute' % href_attr) 725 continue 726 el_href = attrs[href_attr] 727 if href_extract: 728 m = href_extract.search(el_href) 729 if not m: 730 printlog(" Skipped: doesn't match extract pattern") 731 continue 732 el_href = m.group(1) 733 attrs['uri'] = el_href 734 if el_href.startswith('#'): 735 printlog(' Skipped: only internal fragment href') 736 continue 737 if el_href.startswith('javascript:'): 738 printlog(' Skipped: cannot follow javascript:') 739 continue 740 total_links += 1 741 if content_pat and not content_pat(el_content): 742 printlog(" Skipped: doesn't match description") 743 continue 744 if id_pat and not id_pat(attrs.get('id', '')): 745 printlog(" Skipped: doesn't match id") 746 continue 747 if href_pat and not href_pat(el_href): 748 printlog(" Skipped: doesn't match href") 749 continue 750 if html_pat and not html_pat(el_html): 751 printlog(" Skipped: doesn't match html") 752 continue 753 printlog(" Accepted") 754 found_links.append((el_html, el_content, attrs)) 755 if not found_links: 756 raise IndexError( 757 "No matching elements found (from %s possible)" 758 % total_links) 759 if index is None: 760 if len(found_links) > 1: 761 raise IndexError( 762 "Multiple links match: %s" 763 % ', '.join([repr(anc) for anc, d, attr in found_links])) 764 found_link = found_links[0] 765 else: 766 try: 767 found_link = found_links[index] 768 except IndexError: 769 raise IndexError( 770 "Only %s (out of %s) links match; index %s out of range" 771 % (len(found_links), total_links, index)) 772 return found_link 773 774 def goto(self, href, method='get', **args): 775 """ 776 Go to the (potentially relative) link ``href``, using the 777 given method (``'get'`` or ``'post'``) and any extra arguments 778 you want to pass to the ``app.get()`` or ``app.post()`` 779 methods. 780 781 All hostnames and schemes will be ignored. 782 """ 783 scheme, host, path, query, fragment = urlparse.urlsplit(href) 784 # We 785 scheme = host = fragment = '' 786 href = urlparse.urlunsplit((scheme, host, path, query, fragment)) 787 href = urlparse.urljoin(self.request.full_url, href) 788 method = method.lower() 789 assert method in ('get', 'post'), ( 790 'Only "get" or "post" are allowed for method (you gave %r)' 791 % method) 792 if method == 'get': 793 method = self.test_app.get 794 else: 795 method = self.test_app.post 796 return method(href, **args) 797 798 _normal_body_regex = re.compile(br'[ \n\r\t]+') 799 800 def normal_body__get(self): 801 if self._normal_body is None: 802 self._normal_body = self._normal_body_regex.sub( 803 b' ', self.body) 804 return self._normal_body 805 806 normal_body = property(normal_body__get, 807 doc=""" 808 Return the whitespace-normalized body 809 """) 810 811 def __contains__(self, s): 812 """ 813 A response 'contains' a string if it is present in the body 814 of the response. Whitespace is normalized when searching 815 for a string. 816 """ 817 if not isinstance(s, (six.binary_type, six.text_type)): 818 s = str(s) 819 if isinstance(s, six.text_type): 820 ## FIXME: we don't know that this response uses utf8: 821 s = s.encode('utf8') 822 return (self.body.find(s) != -1 823 or self.normal_body.find(s) != -1) 824 825 def mustcontain(self, *strings, **kw): 826 """ 827 Assert that the response contains all of the strings passed 828 in as arguments. 829 830 Equivalent to:: 831 832 assert string in res 833 """ 834 if 'no' in kw: 835 no = kw['no'] 836 del kw['no'] 837 if isinstance(no, (six.binary_type, six.text_type)): 838 no = [no] 839 else: 840 no = [] 841 if kw: 842 raise TypeError( 843 "The only keyword argument allowed is 'no'") 844 for s in strings: 845 if not s in self: 846 print("Actual response (no %r):" % s, file=sys.stderr) 847 print(self, file=sys.stderr) 848 raise IndexError( 849 "Body does not contain string %r" % s) 850 for no_s in no: 851 if no_s in self: 852 print("Actual response (has %r)" % no_s, file=sys.stderr) 853 print(self, file=sys.stderr) 854 raise IndexError( 855 "Body contains string %r" % s) 856 857 def __repr__(self): 858 body = self.body 859 if six.PY3: 860 body = body.decode('utf8', 'xmlcharrefreplace') 861 body = body[:20] 862 return '<Response %s %r>' % (self.full_status, body) 863 864 def __str__(self): 865 simple_body = b'\n'.join([l for l in self.body.splitlines() 866 if l.strip()]) 867 if six.PY3: 868 simple_body = simple_body.decode('utf8', 'xmlcharrefreplace') 869 return 'Response: %s\n%s\n%s' % ( 870 self.status, 871 '\n'.join(['%s: %s' % (n, v) for n, v in self.headers]), 872 simple_body) 873 874 def showbrowser(self): 875 """ 876 Show this response in a browser window (for debugging purposes, 877 when it's hard to read the HTML). 878 """ 879 import webbrowser 880 fn = tempnam_no_warning(None, 'paste-fixture') + '.html' 881 f = open(fn, 'wb') 882 f.write(self.body) 883 f.close() 884 url = 'file:' + fn.replace(os.sep, '/') 885 webbrowser.open_new(url) 886 887 class TestRequest(object): 888 889 # for py.test 890 disabled = True 891 892 """ 893 Instances of this class are created by `TestApp 894 <class-paste.fixture.TestApp.html>`_ with the ``.get()`` and 895 ``.post()`` methods, and are consumed there by ``.do_request()``. 896 897 Instances are also available as a ``.req`` attribute on 898 `TestResponse <class-paste.fixture.TestResponse.html>`_ instances. 899 900 Useful attributes: 901 902 ``url``: 903 The url (actually usually the path) of the request, without 904 query string. 905 906 ``environ``: 907 The environment dictionary used for the request. 908 909 ``full_url``: 910 The url/path, with query string. 911 """ 912 913 def __init__(self, url, environ, expect_errors=False): 914 if url.startswith('http://localhost'): 915 url = url[len('http://localhost'):] 916 self.url = url 917 self.environ = environ 918 if environ.get('QUERY_STRING'): 919 self.full_url = url + '?' + environ['QUERY_STRING'] 920 else: 921 self.full_url = url 922 self.expect_errors = expect_errors 923 924 925 class Form(object): 926 927 """ 928 This object represents a form that has been found in a page. 929 This has a couple useful attributes: 930 931 ``text``: 932 the full HTML of the form. 933 934 ``action``: 935 the relative URI of the action. 936 937 ``method``: 938 the method (e.g., ``'GET'``). 939 940 ``id``: 941 the id, or None if not given. 942 943 ``fields``: 944 a dictionary of fields, each value is a list of fields by 945 that name. ``<input type=\"radio\">`` and ``<select>`` are 946 both represented as single fields with multiple options. 947 """ 948 949 # @@: This really should be using Mechanize/ClientForm or 950 # something... 951 952 _tag_re = re.compile(r'<(/?)([:a-z0-9_\-]*)([^>]*?)>', re.I) 953 954 def __init__(self, response, text): 955 self.response = response 956 self.text = text 957 self._parse_fields() 958 self._parse_action() 959 960 def _parse_fields(self): 961 in_select = None 962 in_textarea = None 963 fields = {} 964 for match in self._tag_re.finditer(self.text): 965 end = match.group(1) == '/' 966 tag = match.group(2).lower() 967 if tag not in ('input', 'select', 'option', 'textarea', 968 'button'): 969 continue 970 if tag == 'select' and end: 971 assert in_select, ( 972 '%r without starting select' % match.group(0)) 973 in_select = None 974 continue 975 if tag == 'textarea' and end: 976 assert in_textarea, ( 977 "</textarea> with no <textarea> at %s" % match.start()) 978 in_textarea[0].value = html_unquote(self.text[in_textarea[1]:match.start()]) 979 in_textarea = None 980 continue 981 if end: 982 continue 983 attrs = _parse_attrs(match.group(3)) 984 if 'name' in attrs: 985 name = attrs.pop('name') 986 else: 987 name = None 988 if tag == 'option': 989 in_select.options.append((attrs.get('value'), 990 'selected' in attrs)) 991 continue 992 if tag == 'input' and attrs.get('type') == 'radio': 993 field = fields.get(name) 994 if not field: 995 field = Radio(self, tag, name, match.start(), **attrs) 996 fields.setdefault(name, []).append(field) 997 else: 998 field = field[0] 999 assert isinstance(field, Radio) 1000 field.options.append((attrs.get('value'), 1001 'checked' in attrs)) 1002 continue 1003 tag_type = tag 1004 if tag == 'input': 1005 tag_type = attrs.get('type', 'text').lower() 1006 FieldClass = Field.classes.get(tag_type, Field) 1007 field = FieldClass(self, tag, name, match.start(), **attrs) 1008 if tag == 'textarea': 1009 assert not in_textarea, ( 1010 "Nested textareas: %r and %r" 1011 % (in_textarea, match.group(0))) 1012 in_textarea = field, match.end() 1013 elif tag == 'select': 1014 assert not in_select, ( 1015 "Nested selects: %r and %r" 1016 % (in_select, match.group(0))) 1017 in_select = field 1018 fields.setdefault(name, []).append(field) 1019 self.fields = fields 1020 1021 def _parse_action(self): 1022 self.action = None 1023 for match in self._tag_re.finditer(self.text): 1024 end = match.group(1) == '/' 1025 tag = match.group(2).lower() 1026 if tag != 'form': 1027 continue 1028 if end: 1029 break 1030 attrs = _parse_attrs(match.group(3)) 1031 self.action = attrs.get('action', '') 1032 self.method = attrs.get('method', 'GET') 1033 self.id = attrs.get('id') 1034 # @@: enctype? 1035 else: 1036 assert 0, "No </form> tag found" 1037 assert self.action is not None, ( 1038 "No <form> tag found") 1039 1040 def __setitem__(self, name, value): 1041 """ 1042 Set the value of the named field. If there is 0 or multiple 1043 fields by that name, it is an error. 1044 1045 Setting the value of a ``<select>`` selects the given option 1046 (and confirms it is an option). Setting radio fields does the 1047 same. Checkboxes get boolean values. You cannot set hidden 1048 fields or buttons. 1049 1050 Use ``.set()`` if there is any ambiguity and you must provide 1051 an index. 1052 """ 1053 fields = self.fields.get(name) 1054 assert fields is not None, ( 1055 "No field by the name %r found (fields: %s)" 1056 % (name, ', '.join(map(repr, self.fields.keys())))) 1057 assert len(fields) == 1, ( 1058 "Multiple fields match %r: %s" 1059 % (name, ', '.join(map(repr, fields)))) 1060 fields[0].value = value 1061 1062 def __getitem__(self, name): 1063 """ 1064 Get the named field object (ambiguity is an error). 1065 """ 1066 fields = self.fields.get(name) 1067 assert fields is not None, ( 1068 "No field by the name %r found" % name) 1069 assert len(fields) == 1, ( 1070 "Multiple fields match %r: %s" 1071 % (name, ', '.join(map(repr, fields)))) 1072 return fields[0] 1073 1074 def set(self, name, value, index=None): 1075 """ 1076 Set the given name, using ``index`` to disambiguate. 1077 """ 1078 if index is None: 1079 self[name] = value 1080 else: 1081 fields = self.fields.get(name) 1082 assert fields is not None, ( 1083 "No fields found matching %r" % name) 1084 field = fields[index] 1085 field.value = value 1086 1087 def get(self, name, index=None, default=NoDefault): 1088 """ 1089 Get the named/indexed field object, or ``default`` if no field 1090 is found. 1091 """ 1092 fields = self.fields.get(name) 1093 if fields is None and default is not NoDefault: 1094 return default 1095 if index is None: 1096 return self[name] 1097 else: 1098 fields = self.fields.get(name) 1099 assert fields is not None, ( 1100 "No fields found matching %r" % name) 1101 field = fields[index] 1102 return field 1103 1104 def select(self, name, value, index=None): 1105 """ 1106 Like ``.set()``, except also confirms the target is a 1107 ``<select>``. 1108 """ 1109 field = self.get(name, index=index) 1110 assert isinstance(field, Select) 1111 field.value = value 1112 1113 def submit(self, name=None, index=None, **args): 1114 """ 1115 Submits the form. If ``name`` is given, then also select that 1116 button (using ``index`` to disambiguate)``. 1117 1118 Any extra keyword arguments are passed to the ``.get()`` or 1119 ``.post()`` method. 1120 1121 Returns a response object. 1122 """ 1123 fields = self.submit_fields(name, index=index) 1124 return self.response.goto(self.action, method=self.method, 1125 params=fields, **args) 1126 1127 def submit_fields(self, name=None, index=None): 1128 """ 1129 Return a list of ``[(name, value), ...]`` for the current 1130 state of the form. 1131 """ 1132 submit = [] 1133 if name is not None: 1134 field = self.get(name, index=index) 1135 submit.append((field.name, field.value_if_submitted())) 1136 for name, fields in self.fields.items(): 1137 if name is None: 1138 continue 1139 for field in fields: 1140 value = field.value 1141 if value is None: 1142 continue 1143 submit.append((name, value)) 1144 return submit 1145 1146 1147 _attr_re = re.compile(r'([^= \n\r\t]+)[ \n\r\t]*(?:=[ \n\r\t]*(?:"([^"]*)"|([^"][^ \n\r\t>]*)))?', re.S) 1148 1149 def _parse_attrs(text): 1150 attrs = {} 1151 for match in _attr_re.finditer(text): 1152 attr_name = match.group(1).lower() 1153 attr_body = match.group(2) or match.group(3) 1154 attr_body = html_unquote(attr_body or '') 1155 attrs[attr_name] = attr_body 1156 return attrs 1157 1158 class Field(object): 1159 1160 """ 1161 Field object. 1162 """ 1163 1164 # Dictionary of field types (select, radio, etc) to classes 1165 classes = {} 1166 1167 settable = True 1168 1169 def __init__(self, form, tag, name, pos, 1170 value=None, id=None, **attrs): 1171 self.form = form 1172 self.tag = tag 1173 self.name = name 1174 self.pos = pos 1175 self._value = value 1176 self.id = id 1177 self.attrs = attrs 1178 1179 def value__set(self, value): 1180 if not self.settable: 1181 raise AttributeError( 1182 "You cannot set the value of the <%s> field %r" 1183 % (self.tag, self.name)) 1184 self._value = value 1185 1186 def force_value(self, value): 1187 """ 1188 Like setting a value, except forces it even for, say, hidden 1189 fields. 1190 """ 1191 self._value = value 1192 1193 def value__get(self): 1194 return self._value 1195 1196 value = property(value__get, value__set) 1197 1198 class Select(Field): 1199 1200 """ 1201 Field representing ``<select>`` 1202 """ 1203 1204 def __init__(self, *args, **attrs): 1205 super(Select, self).__init__(*args, **attrs) 1206 self.options = [] 1207 self.multiple = attrs.get('multiple') 1208 assert not self.multiple, ( 1209 "<select multiple> not yet supported") 1210 # Undetermined yet: 1211 self.selectedIndex = None 1212 1213 def value__set(self, value): 1214 for i, (option, checked) in enumerate(self.options): 1215 if option == str(value): 1216 self.selectedIndex = i 1217 break 1218 else: 1219 raise ValueError( 1220 "Option %r not found (from %s)" 1221 % (value, ', '.join( 1222 [repr(o) for o, c in self.options]))) 1223 1224 def value__get(self): 1225 if self.selectedIndex is not None: 1226 return self.options[self.selectedIndex][0] 1227 else: 1228 for option, checked in self.options: 1229 if checked: 1230 return option 1231 else: 1232 if self.options: 1233 return self.options[0][0] 1234 else: 1235 return None 1236 1237 value = property(value__get, value__set) 1238 1239 Field.classes['select'] = Select 1240 1241 class Radio(Select): 1242 1243 """ 1244 Field representing ``<input type="radio">`` 1245 """ 1246 1247 Field.classes['radio'] = Radio 1248 1249 class Checkbox(Field): 1250 1251 """ 1252 Field representing ``<input type="checkbox">`` 1253 """ 1254 1255 def __init__(self, *args, **attrs): 1256 super(Checkbox, self).__init__(*args, **attrs) 1257 self.checked = 'checked' in attrs 1258 1259 def value__set(self, value): 1260 self.checked = not not value 1261 1262 def value__get(self): 1263 if self.checked: 1264 if self._value is None: 1265 return 'on' 1266 else: 1267 return self._value 1268 else: 1269 return None 1270 1271 value = property(value__get, value__set) 1272 1273 Field.classes['checkbox'] = Checkbox 1274 1275 class Text(Field): 1276 """ 1277 Field representing ``<input type="text">`` 1278 """ 1279 def __init__(self, form, tag, name, pos, 1280 value='', id=None, **attrs): 1281 #text fields default to empty string 1282 Field.__init__(self, form, tag, name, pos, 1283 value=value, id=id, **attrs) 1284 1285 Field.classes['text'] = Text 1286 1287 class Textarea(Text): 1288 """ 1289 Field representing ``<textarea>`` 1290 """ 1291 1292 Field.classes['textarea'] = Textarea 1293 1294 class Hidden(Text): 1295 """ 1296 Field representing ``<input type="hidden">`` 1297 """ 1298 1299 Field.classes['hidden'] = Hidden 1300 1301 class Submit(Field): 1302 """ 1303 Field representing ``<input type="submit">`` and ``<button>`` 1304 """ 1305 1306 settable = False 1307 1308 def value__get(self): 1309 return None 1310 1311 value = property(value__get) 1312 1313 def value_if_submitted(self): 1314 return self._value 1315 1316 Field.classes['submit'] = Submit 1317 1318 Field.classes['button'] = Submit 1319 1320 Field.classes['image'] = Submit 1321 1322 ############################################################ 1323 ## Command-line testing 1324 ############################################################ 1325 1326 1327 class TestFileEnvironment(object): 1328 1329 """ 1330 This represents an environment in which files will be written, and 1331 scripts will be run. 1332 """ 1333 1334 # for py.test 1335 disabled = True 1336 1337 def __init__(self, base_path, template_path=None, 1338 script_path=None, 1339 environ=None, cwd=None, start_clear=True, 1340 ignore_paths=None, ignore_hidden=True): 1341 """ 1342 Creates an environment. ``base_path`` is used as the current 1343 working directory, and generally where changes are looked for. 1344 1345 ``template_path`` is the directory to look for *template* 1346 files, which are files you'll explicitly add to the 1347 environment. This is done with ``.writefile()``. 1348 1349 ``script_path`` is the PATH for finding executables. Usually 1350 grabbed from ``$PATH``. 1351 1352 ``environ`` is the operating system environment, 1353 ``os.environ`` if not given. 1354 1355 ``cwd`` is the working directory, ``base_path`` by default. 1356 1357 If ``start_clear`` is true (default) then the ``base_path`` 1358 will be cleared (all files deleted) when an instance is 1359 created. You can also use ``.clear()`` to clear the files. 1360 1361 ``ignore_paths`` is a set of specific filenames that should be 1362 ignored when created in the environment. ``ignore_hidden`` 1363 means, if true (default) that filenames and directories 1364 starting with ``'.'`` will be ignored. 1365 """ 1366 self.base_path = base_path 1367 self.template_path = template_path 1368 if environ is None: 1369 environ = os.environ.copy() 1370 self.environ = environ 1371 if script_path is None: 1372 if sys.platform == 'win32': 1373 script_path = environ.get('PATH', '').split(';') 1374 else: 1375 script_path = environ.get('PATH', '').split(':') 1376 self.script_path = script_path 1377 if cwd is None: 1378 cwd = base_path 1379 self.cwd = cwd 1380 if start_clear: 1381 self.clear() 1382 elif not os.path.exists(base_path): 1383 os.makedirs(base_path) 1384 self.ignore_paths = ignore_paths or [] 1385 self.ignore_hidden = ignore_hidden 1386 1387 def run(self, script, *args, **kw): 1388 """ 1389 Run the command, with the given arguments. The ``script`` 1390 argument can have space-separated arguments, or you can use 1391 the positional arguments. 1392 1393 Keywords allowed are: 1394 1395 ``expect_error``: (default False) 1396 Don't raise an exception in case of errors 1397 ``expect_stderr``: (default ``expect_error``) 1398 Don't raise an exception if anything is printed to stderr 1399 ``stdin``: (default ``""``) 1400 Input to the script 1401 ``printresult``: (default True) 1402 Print the result after running 1403 ``cwd``: (default ``self.cwd``) 1404 The working directory to run in 1405 1406 Returns a `ProcResponse 1407 <class-paste.fixture.ProcResponse.html>`_ object. 1408 """ 1409 __tracebackhide__ = True 1410 expect_error = _popget(kw, 'expect_error', False) 1411 expect_stderr = _popget(kw, 'expect_stderr', expect_error) 1412 cwd = _popget(kw, 'cwd', self.cwd) 1413 stdin = _popget(kw, 'stdin', None) 1414 printresult = _popget(kw, 'printresult', True) 1415 args = list(map(str, args)) 1416 assert not kw, ( 1417 "Arguments not expected: %s" % ', '.join(kw.keys())) 1418 if ' ' in script: 1419 assert not args, ( 1420 "You cannot give a multi-argument script (%r) " 1421 "and arguments (%s)" % (script, args)) 1422 script, args = script.split(None, 1) 1423 args = shlex.split(args) 1424 script = self._find_exe(script) 1425 all = [script] + args 1426 files_before = self._find_files() 1427 proc = subprocess.Popen(all, stdin=subprocess.PIPE, 1428 stderr=subprocess.PIPE, 1429 stdout=subprocess.PIPE, 1430 cwd=cwd, 1431 env=self.environ) 1432 stdout, stderr = proc.communicate(stdin) 1433 files_after = self._find_files() 1434 result = ProcResult( 1435 self, all, stdin, stdout, stderr, 1436 returncode=proc.returncode, 1437 files_before=files_before, 1438 files_after=files_after) 1439 if printresult: 1440 print(result) 1441 print('-'*40) 1442 if not expect_error: 1443 result.assert_no_error() 1444 if not expect_stderr: 1445 result.assert_no_stderr() 1446 return result 1447 1448 def _find_exe(self, script_name): 1449 if self.script_path is None: 1450 script_name = os.path.join(self.cwd, script_name) 1451 if not os.path.exists(script_name): 1452 raise OSError( 1453 "Script %s does not exist" % script_name) 1454 return script_name 1455 for path in self.script_path: 1456 fn = os.path.join(path, script_name) 1457 if os.path.exists(fn): 1458 return fn 1459 raise OSError( 1460 "Script %s could not be found in %s" 1461 % (script_name, ':'.join(self.script_path))) 1462 1463 def _find_files(self): 1464 result = {} 1465 for fn in os.listdir(self.base_path): 1466 if self._ignore_file(fn): 1467 continue 1468 self._find_traverse(fn, result) 1469 return result 1470 1471 def _ignore_file(self, fn): 1472 if fn in self.ignore_paths: 1473 return True 1474 if self.ignore_hidden and os.path.basename(fn).startswith('.'): 1475 return True 1476 return False 1477 1478 def _find_traverse(self, path, result): 1479 full = os.path.join(self.base_path, path) 1480 if os.path.isdir(full): 1481 result[path] = FoundDir(self.base_path, path) 1482 for fn in os.listdir(full): 1483 fn = os.path.join(path, fn) 1484 if self._ignore_file(fn): 1485 continue 1486 self._find_traverse(fn, result) 1487 else: 1488 result[path] = FoundFile(self.base_path, path) 1489 1490 def clear(self): 1491 """ 1492 Delete all the files in the base directory. 1493 """ 1494 if os.path.exists(self.base_path): 1495 shutil.rmtree(self.base_path) 1496 os.mkdir(self.base_path) 1497 1498 def writefile(self, path, content=None, 1499 frompath=None): 1500 """ 1501 Write a file to the given path. If ``content`` is given then 1502 that text is written, otherwise the file in ``frompath`` is 1503 used. ``frompath`` is relative to ``self.template_path`` 1504 """ 1505 full = os.path.join(self.base_path, path) 1506 if not os.path.exists(os.path.dirname(full)): 1507 os.makedirs(os.path.dirname(full)) 1508 f = open(full, 'wb') 1509 if content is not None: 1510 f.write(content) 1511 if frompath is not None: 1512 if self.template_path: 1513 frompath = os.path.join(self.template_path, frompath) 1514 f2 = open(frompath, 'rb') 1515 f.write(f2.read()) 1516 f2.close() 1517 f.close() 1518 return FoundFile(self.base_path, path) 1519 1520 class ProcResult(object): 1521 1522 """ 1523 Represents the results of running a command in 1524 `TestFileEnvironment 1525 <class-paste.fixture.TestFileEnvironment.html>`_. 1526 1527 Attributes to pay particular attention to: 1528 1529 ``stdout``, ``stderr``: 1530 What is produced 1531 1532 ``files_created``, ``files_deleted``, ``files_updated``: 1533 Dictionaries mapping filenames (relative to the ``base_dir``) 1534 to `FoundFile <class-paste.fixture.FoundFile.html>`_ or 1535 `FoundDir <class-paste.fixture.FoundDir.html>`_ objects. 1536 """ 1537 1538 def __init__(self, test_env, args, stdin, stdout, stderr, 1539 returncode, files_before, files_after): 1540 self.test_env = test_env 1541 self.args = args 1542 self.stdin = stdin 1543 self.stdout = stdout 1544 self.stderr = stderr 1545 self.returncode = returncode 1546 self.files_before = files_before 1547 self.files_after = files_after 1548 self.files_deleted = {} 1549 self.files_updated = {} 1550 self.files_created = files_after.copy() 1551 for path, f in files_before.items(): 1552 if path not in files_after: 1553 self.files_deleted[path] = f 1554 continue 1555 del self.files_created[path] 1556 if f.mtime < files_after[path].mtime: 1557 self.files_updated[path] = files_after[path] 1558 1559 def assert_no_error(self): 1560 __tracebackhide__ = True 1561 assert self.returncode == 0, ( 1562 "Script returned code: %s" % self.returncode) 1563 1564 def assert_no_stderr(self): 1565 __tracebackhide__ = True 1566 if self.stderr: 1567 print('Error output:') 1568 print(self.stderr) 1569 raise AssertionError("stderr output not expected") 1570 1571 def __str__(self): 1572 s = ['Script result: %s' % ' '.join(self.args)] 1573 if self.returncode: 1574 s.append(' return code: %s' % self.returncode) 1575 if self.stderr: 1576 s.append('-- stderr: --------------------') 1577 s.append(self.stderr) 1578 if self.stdout: 1579 s.append('-- stdout: --------------------') 1580 s.append(self.stdout) 1581 for name, files, show_size in [ 1582 ('created', self.files_created, True), 1583 ('deleted', self.files_deleted, True), 1584 ('updated', self.files_updated, True)]: 1585 if files: 1586 s.append('-- %s: -------------------' % name) 1587 files = files.items() 1588 files.sort() 1589 last = '' 1590 for path, f in files: 1591 t = ' %s' % _space_prefix(last, path, indent=4, 1592 include_sep=False) 1593 last = path 1594 if show_size and f.size != 'N/A': 1595 t += ' (%s bytes)' % f.size 1596 s.append(t) 1597 return '\n'.join(s) 1598 1599 class FoundFile(object): 1600 1601 """ 1602 Represents a single file found as the result of a command. 1603 1604 Has attributes: 1605 1606 ``path``: 1607 The path of the file, relative to the ``base_path`` 1608 1609 ``full``: 1610 The full path 1611 1612 ``stat``: 1613 The results of ``os.stat``. Also ``mtime`` and ``size`` 1614 contain the ``.st_mtime`` and ``st_size`` of the stat. 1615 1616 ``bytes``: 1617 The contents of the file. 1618 1619 You may use the ``in`` operator with these objects (tested against 1620 the contents of the file), and the ``.mustcontain()`` method. 1621 """ 1622 1623 file = True 1624 dir = False 1625 1626 def __init__(self, base_path, path): 1627 self.base_path = base_path 1628 self.path = path 1629 self.full = os.path.join(base_path, path) 1630 self.stat = os.stat(self.full) 1631 self.mtime = self.stat.st_mtime 1632 self.size = self.stat.st_size 1633 self._bytes = None 1634 1635 def bytes__get(self): 1636 if self._bytes is None: 1637 f = open(self.full, 'rb') 1638 self._bytes = f.read() 1639 f.close() 1640 return self._bytes 1641 bytes = property(bytes__get) 1642 1643 def __contains__(self, s): 1644 return s in self.bytes 1645 1646 def mustcontain(self, s): 1647 __tracebackhide__ = True 1648 bytes_ = self.bytes 1649 if s not in bytes_: 1650 print('Could not find %r in:' % s) 1651 print(bytes_) 1652 assert s in bytes_ 1653 1654 def __repr__(self): 1655 return '<%s %s:%s>' % ( 1656 self.__class__.__name__, 1657 self.base_path, self.path) 1658 1659 class FoundDir(object): 1660 1661 """ 1662 Represents a directory created by a command. 1663 """ 1664 1665 file = False 1666 dir = True 1667 1668 def __init__(self, base_path, path): 1669 self.base_path = base_path 1670 self.path = path 1671 self.full = os.path.join(base_path, path) 1672 self.size = 'N/A' 1673 self.mtime = 'N/A' 1674 1675 def __repr__(self): 1676 return '<%s %s:%s>' % ( 1677 self.__class__.__name__, 1678 self.base_path, self.path) 1679 1680 def _popget(d, key, default=None): 1681 """ 1682 Pop the key if found (else return default) 1683 """ 1684 if key in d: 1685 return d.pop(key) 1686 return default 1687 1688 def _space_prefix(pref, full, sep=None, indent=None, include_sep=True): 1689 """ 1690 Anything shared by pref and full will be replaced with spaces 1691 in full, and full returned. 1692 """ 1693 if sep is None: 1694 sep = os.path.sep 1695 pref = pref.split(sep) 1696 full = full.split(sep) 1697 padding = [] 1698 while pref and full and pref[0] == full[0]: 1699 if indent is None: 1700 padding.append(' ' * (len(full[0]) + len(sep))) 1701 else: 1702 padding.append(' ' * indent) 1703 full.pop(0) 1704 pref.pop(0) 1705 if padding: 1706 if include_sep: 1707 return ''.join(padding) + sep + sep.join(full) 1708 else: 1709 return ''.join(padding) + sep.join(full) 1710 else: 1711 return sep.join(full) 1712 1713 def _make_pattern(pat): 1714 if pat is None: 1715 return None 1716 if isinstance(pat, (six.binary_type, six.text_type)): 1717 pat = re.compile(pat) 1718 if hasattr(pat, 'search'): 1719 return pat.search 1720 if callable(pat): 1721 return pat 1722 assert 0, ( 1723 "Cannot make callable pattern object out of %r" % pat) 1724 1725 def setup_module(module=None): 1726 """ 1727 This is used by py.test if it is in the module, so you can 1728 import this directly. 1729 1730 Use like:: 1731 1732 from paste.fixture import setup_module 1733 """ 1734 # Deprecated June 2008 1735 import warnings 1736 warnings.warn( 1737 'setup_module is deprecated', 1738 DeprecationWarning, 2) 1739 if module is None: 1740 # The module we were called from must be the module... 1741 module = sys._getframe().f_back.f_globals['__name__'] 1742 if isinstance(module, (six.binary_type, six.text_type)): 1743 module = sys.modules[module] 1744 if hasattr(module, 'reset_state'): 1745 module.reset_state() 1746 1747 def html_unquote(v): 1748 """ 1749 Unquote (some) entities in HTML. (incomplete) 1750 """ 1751 for ent, repl in [(' ', ' '), ('>', '>'), 1752 ('<', '<'), ('"', '"'), 1753 ('&', '&')]: 1754 v = v.replace(ent, repl) 1755 return v 1756