1# (c) 2005 Ian Bicking and contributors; written for Paste 2# (http://pythonpaste.org) 3# Licensed under the MIT license: 4# http://www.opensource.org/licenses/mit-license.php 5""" 6Routines for testing WSGI applications. 7 8Most interesting is TestApp 9""" 10from __future__ import unicode_literals 11 12import os 13import re 14import json 15import random 16import fnmatch 17import mimetypes 18 19from base64 import b64encode 20 21from six import StringIO 22from six import BytesIO 23from six import string_types 24from six import binary_type 25from six import text_type 26from six.moves import http_cookiejar 27 28from webtest.compat import urlparse 29from webtest.compat import urlencode 30from webtest.compat import to_bytes 31from webtest.compat import escape_cookie_value 32from webtest.response import TestResponse 33from webtest import forms 34from webtest import lint 35from webtest import utils 36 37import webob 38 39 40__all__ = ['TestApp', 'TestRequest'] 41 42 43class AppError(Exception): 44 45 def __init__(self, message, *args): 46 if isinstance(message, binary_type): 47 message = message.decode('utf8') 48 str_args = () 49 for arg in args: 50 if isinstance(arg, webob.Response): 51 body = arg.body 52 if isinstance(body, binary_type): 53 if arg.charset: 54 arg = body.decode(arg.charset) 55 else: 56 arg = repr(body) 57 elif isinstance(arg, binary_type): 58 try: 59 arg = arg.decode('utf8') 60 except UnicodeDecodeError: 61 arg = repr(arg) 62 str_args += (arg,) 63 message = message % str_args 64 Exception.__init__(self, message) 65 66 67class CookiePolicy(http_cookiejar.DefaultCookiePolicy): 68 """A subclass of DefaultCookiePolicy to allow cookie set for 69 Domain=localhost.""" 70 71 def return_ok_domain(self, cookie, request): 72 if cookie.domain == '.localhost': 73 return True 74 return http_cookiejar.DefaultCookiePolicy.return_ok_domain( 75 self, cookie, request) 76 77 def set_ok_domain(self, cookie, request): 78 if cookie.domain == '.localhost': 79 return True 80 return http_cookiejar.DefaultCookiePolicy.set_ok_domain( 81 self, cookie, request) 82 83 84class TestRequest(webob.BaseRequest): 85 """A subclass of webob.Request""" 86 ResponseClass = TestResponse 87 88 89class TestApp(object): 90 """ 91 Wraps a WSGI application in a more convenient interface for 92 testing. It uses extended version of :class:`webob.BaseRequest` 93 and :class:`webob.Response`. 94 95 :param app: 96 May be an WSGI application or Paste Deploy app, 97 like ``'config:filename.ini#test'``. 98 99 .. versionadded:: 2.0 100 101 It can also be an actual full URL to an http server and webtest 102 will proxy requests with `WSGIProxy2 103 <https://pypi.org/project/WSGIProxy2/>`_. 104 :type app: 105 WSGI application 106 :param extra_environ: 107 A dictionary of values that should go 108 into the environment for each request. These can provide a 109 communication channel with the application. 110 :type extra_environ: 111 dict 112 :param relative_to: 113 A directory used for file 114 uploads are calculated relative to this. Also ``config:`` 115 URIs that aren't absolute. 116 :type relative_to: 117 string 118 :param cookiejar: 119 :class:`cookielib.CookieJar` alike API that keeps cookies 120 across requets. 121 :type cookiejar: 122 CookieJar instance 123 124 .. attribute:: cookies 125 126 A convenient shortcut for a dict of all cookies in 127 ``cookiejar``. 128 129 :param parser_features: 130 Passed to BeautifulSoup when parsing responses. 131 :type parser_features: 132 string or list 133 :param json_encoder: 134 Passed to json.dumps when encoding json 135 :type json_encoder: 136 A subclass of json.JSONEncoder 137 :param lint: 138 If True (default) then check that the application is WSGI compliant 139 :type lint: 140 A boolean 141 """ 142 143 RequestClass = TestRequest 144 145 def __init__(self, app, extra_environ=None, relative_to=None, 146 use_unicode=True, cookiejar=None, parser_features=None, 147 json_encoder=None, lint=True): 148 149 if 'WEBTEST_TARGET_URL' in os.environ: 150 app = os.environ['WEBTEST_TARGET_URL'] 151 if isinstance(app, string_types): 152 if app.startswith('http'): 153 try: 154 from wsgiproxy import HostProxy 155 except ImportError: # pragma: no cover 156 raise ImportError(( 157 'Using webtest with a real url requires WSGIProxy2. ' 158 'Please install it with: ' 159 'pip install WSGIProxy2')) 160 if '#' not in app: 161 app += '#httplib' 162 url, client = app.split('#', 1) 163 app = HostProxy(url, client=client) 164 else: 165 from paste.deploy import loadapp 166 # @@: Should pick up relative_to from calling module's 167 # __file__ 168 app = loadapp(app, relative_to=relative_to) 169 self.app = app 170 self.lint = lint 171 self.relative_to = relative_to 172 if extra_environ is None: 173 extra_environ = {} 174 self.extra_environ = extra_environ 175 self.use_unicode = use_unicode 176 if cookiejar is None: 177 cookiejar = http_cookiejar.CookieJar(policy=CookiePolicy()) 178 self.cookiejar = cookiejar 179 if parser_features is None: 180 parser_features = 'html.parser' 181 self.RequestClass.ResponseClass.parser_features = parser_features 182 if json_encoder is None: 183 json_encoder = json.JSONEncoder 184 self.JSONEncoder = json_encoder 185 186 def get_authorization(self): 187 """Allow to set the HTTP_AUTHORIZATION environ key. Value should look 188 like one of the following: 189 190 * ``('Basic', ('user', 'password'))`` 191 * ``('Bearer', 'mytoken')`` 192 * ``('JWT', 'myjwt')`` 193 194 If value is None the the HTTP_AUTHORIZATION is removed 195 """ 196 return self.authorization_value 197 198 def set_authorization(self, value): 199 self.authorization_value = value 200 if value is not None: 201 invalid_value = ( 202 "You should use a value like ('Basic', ('user', 'password'))" 203 " OR ('Bearer', 'token') OR ('JWT', 'token')" 204 ) 205 if isinstance(value, (list, tuple)) and len(value) == 2: 206 authtype, val = value 207 if authtype == 'Basic' and val and \ 208 isinstance(val, (list, tuple)): 209 val = ':'.join(list(val)) 210 val = b64encode(to_bytes(val)).strip() 211 val = val.decode('latin1') 212 elif authtype in ('Bearer', 'JWT') and val and \ 213 isinstance(val, (str, text_type)): 214 val = val.strip() 215 else: 216 raise ValueError(invalid_value) 217 value = str('%s %s' % (authtype, val)) 218 else: 219 raise ValueError(invalid_value) 220 self.extra_environ.update({ 221 'HTTP_AUTHORIZATION': value, 222 }) 223 else: 224 if 'HTTP_AUTHORIZATION' in self.extra_environ: 225 del self.extra_environ['HTTP_AUTHORIZATION'] 226 227 authorization = property(get_authorization, set_authorization) 228 229 @property 230 def cookies(self): 231 return dict([(cookie.name, cookie.value) for cookie in self.cookiejar]) 232 233 def set_cookie(self, name, value): 234 """ 235 Sets a cookie to be passed through with requests. 236 237 """ 238 cookie_domain = self.extra_environ.get('HTTP_HOST', '.localhost') 239 cookie_domain = cookie_domain.split(':', 1)[0] 240 if '.' not in cookie_domain: 241 cookie_domain = "%s.local" % cookie_domain 242 value = escape_cookie_value(value) 243 cookie = http_cookiejar.Cookie( 244 version=0, 245 name=name, 246 value=value, 247 port=None, 248 port_specified=False, 249 domain=cookie_domain, 250 domain_specified=True, 251 domain_initial_dot=False, 252 path='/', 253 path_specified=True, 254 secure=False, 255 expires=None, 256 discard=False, 257 comment=None, 258 comment_url=None, 259 rest=None 260 ) 261 self.cookiejar.set_cookie(cookie) 262 263 def reset(self): 264 """ 265 Resets the state of the application; currently just clears 266 saved cookies. 267 """ 268 self.cookiejar.clear() 269 270 def set_parser_features(self, parser_features): 271 """ 272 Changes the parser used by BeautifulSoup. See its documentation to 273 know the supported parsers. 274 """ 275 self.RequestClass.ResponseClass.parser_features = parser_features 276 277 def get(self, url, params=None, headers=None, extra_environ=None, 278 status=None, expect_errors=False, xhr=False): 279 """ 280 Do a GET request given the url path. 281 282 :param params: 283 A query string, or a dictionary that will be encoded 284 into a query string. You may also include a URL query 285 string on the ``url``. 286 :param headers: 287 Extra headers to send. 288 :type headers: 289 dictionary 290 :param extra_environ: 291 Environmental variables that should be added to the request. 292 :type extra_environ: 293 dictionary 294 :param status: 295 The HTTP status code you expect in response (if not 200 or 3xx). 296 You can also use a wildcard, like ``'3*'`` or ``'*'``. 297 :type status: 298 integer or string 299 :param expect_errors: 300 If this is False, then if anything is written to 301 environ ``wsgi.errors`` it will be an error. 302 If it is True, then non-200/3xx responses are also okay. 303 :type expect_errors: 304 boolean 305 :param xhr: 306 If this is true, then marks response as ajax. The same as 307 headers={'X-REQUESTED-WITH': 'XMLHttpRequest', } 308 :type xhr: 309 boolean 310 311 :returns: :class:`webtest.TestResponse` instance. 312 313 """ 314 environ = self._make_environ(extra_environ) 315 url = str(url) 316 url = self._remove_fragment(url) 317 if params: 318 if not isinstance(params, string_types): 319 params = urlencode(params, doseq=True) 320 if str('?') in url: 321 url += str('&') 322 else: 323 url += str('?') 324 url += params 325 if str('?') in url: 326 url, environ['QUERY_STRING'] = url.split(str('?'), 1) 327 else: 328 environ['QUERY_STRING'] = str('') 329 req = self.RequestClass.blank(url, environ) 330 if xhr: 331 headers = self._add_xhr_header(headers) 332 if headers: 333 req.headers.update(headers) 334 return self.do_request(req, status=status, 335 expect_errors=expect_errors) 336 337 def post(self, url, params='', headers=None, extra_environ=None, 338 status=None, upload_files=None, expect_errors=False, 339 content_type=None, xhr=False): 340 """ 341 Do a POST request. Similar to :meth:`~webtest.TestApp.get`. 342 343 :param params: 344 Are put in the body of the request. If params is an 345 iterator, it will be urlencoded. If it is a string, it will not 346 be encoded, but placed in the body directly. 347 348 Can be a :class:`python:collections.OrderedDict` with 349 :class:`webtest.forms.Upload` fields included:: 350 351 app.post('/myurl', collections.OrderedDict([ 352 ('textfield1', 'value1'), 353 ('uploadfield', webapp.Upload('filename.txt', 'contents'), 354 ('textfield2', 'value2')]))) 355 356 :param upload_files: 357 It should be a list of ``(fieldname, filename, file_content)``. 358 You can also use just ``(fieldname, filename)`` and the file 359 contents will be read from disk. 360 :type upload_files: 361 list 362 :param content_type: 363 HTTP content type, for example `application/json`. 364 :type content_type: 365 string 366 367 :param xhr: 368 If this is true, then marks response as ajax. The same as 369 headers={'X-REQUESTED-WITH': 'XMLHttpRequest', } 370 :type xhr: 371 boolean 372 373 :returns: :class:`webtest.TestResponse` instance. 374 375 """ 376 if xhr: 377 headers = self._add_xhr_header(headers) 378 return self._gen_request('POST', url, params=params, headers=headers, 379 extra_environ=extra_environ, status=status, 380 upload_files=upload_files, 381 expect_errors=expect_errors, 382 content_type=content_type) 383 384 def put(self, url, params='', headers=None, extra_environ=None, 385 status=None, upload_files=None, expect_errors=False, 386 content_type=None, xhr=False): 387 """ 388 Do a PUT request. Similar to :meth:`~webtest.TestApp.post`. 389 390 :returns: :class:`webtest.TestResponse` instance. 391 392 """ 393 if xhr: 394 headers = self._add_xhr_header(headers) 395 return self._gen_request('PUT', url, params=params, headers=headers, 396 extra_environ=extra_environ, status=status, 397 upload_files=upload_files, 398 expect_errors=expect_errors, 399 content_type=content_type, 400 ) 401 402 def patch(self, url, params='', headers=None, extra_environ=None, 403 status=None, upload_files=None, expect_errors=False, 404 content_type=None, xhr=False): 405 """ 406 Do a PATCH request. Similar to :meth:`~webtest.TestApp.post`. 407 408 :returns: :class:`webtest.TestResponse` instance. 409 410 """ 411 if xhr: 412 headers = self._add_xhr_header(headers) 413 return self._gen_request('PATCH', url, params=params, headers=headers, 414 extra_environ=extra_environ, status=status, 415 upload_files=upload_files, 416 expect_errors=expect_errors, 417 content_type=content_type) 418 419 def delete(self, url, params='', headers=None, 420 extra_environ=None, status=None, expect_errors=False, 421 content_type=None, xhr=False): 422 """ 423 Do a DELETE request. Similar to :meth:`~webtest.TestApp.get`. 424 425 :returns: :class:`webtest.TestResponse` instance. 426 427 """ 428 if xhr: 429 headers = self._add_xhr_header(headers) 430 return self._gen_request('DELETE', url, params=params, headers=headers, 431 extra_environ=extra_environ, status=status, 432 upload_files=None, 433 expect_errors=expect_errors, 434 content_type=content_type) 435 436 def options(self, url, headers=None, extra_environ=None, 437 status=None, expect_errors=False, xhr=False): 438 """ 439 Do a OPTIONS request. Similar to :meth:`~webtest.TestApp.get`. 440 441 :returns: :class:`webtest.TestResponse` instance. 442 443 """ 444 if xhr: 445 headers = self._add_xhr_header(headers) 446 return self._gen_request('OPTIONS', url, headers=headers, 447 extra_environ=extra_environ, status=status, 448 upload_files=None, 449 expect_errors=expect_errors) 450 451 def head(self, url, headers=None, extra_environ=None, 452 status=None, expect_errors=False, xhr=False): 453 """ 454 Do a HEAD request. Similar to :meth:`~webtest.TestApp.get`. 455 456 :returns: :class:`webtest.TestResponse` instance. 457 458 """ 459 if xhr: 460 headers = self._add_xhr_header(headers) 461 return self._gen_request('HEAD', url, headers=headers, 462 extra_environ=extra_environ, status=status, 463 upload_files=None, 464 expect_errors=expect_errors) 465 466 post_json = utils.json_method('POST') 467 put_json = utils.json_method('PUT') 468 patch_json = utils.json_method('PATCH') 469 delete_json = utils.json_method('DELETE') 470 471 def encode_multipart(self, params, files): 472 """ 473 Encodes a set of parameters (typically a name/value list) and 474 a set of files (a list of (name, filename, file_body, mimetype)) into a 475 typical POST body, returning the (content_type, body). 476 477 """ 478 boundary = to_bytes(str(random.random()))[2:] 479 boundary = b'----------a_BoUnDaRy' + boundary + b'$' 480 lines = [] 481 482 def _append_file(file_info): 483 key, filename, value, fcontent = self._get_file_info(file_info) 484 if isinstance(key, text_type): 485 try: 486 key = key.encode('ascii') 487 except: # pragma: no cover 488 raise # file name must be ascii 489 if isinstance(filename, text_type): 490 try: 491 filename = filename.encode('utf8') 492 except: # pragma: no cover 493 raise # file name must be ascii or utf8 494 if not fcontent: 495 fcontent = mimetypes.guess_type(filename.decode('utf8'))[0] 496 fcontent = to_bytes(fcontent) 497 fcontent = fcontent or b'application/octet-stream' 498 lines.extend([ 499 b'--' + boundary, 500 b'Content-Disposition: form-data; ' + 501 b'name="' + key + b'"; filename="' + filename + b'"', 502 b'Content-Type: ' + fcontent, b'', value]) 503 504 for key, value in params: 505 if isinstance(key, text_type): 506 try: 507 key = key.encode('ascii') 508 except: # pragma: no cover 509 raise # field name are always ascii 510 if isinstance(value, forms.File): 511 if value.value: 512 _append_file([key] + list(value.value)) 513 else: 514 # If no file was uploaded simulate an empty file with no 515 # name like real browsers do: 516 _append_file([key, b'', b'']) 517 elif isinstance(value, forms.Upload): 518 file_info = [key, value.filename] 519 if value.content is not None: 520 file_info.append(value.content) 521 if value.content_type is not None: 522 file_info.append(value.content_type) 523 _append_file(file_info) 524 else: 525 if isinstance(value, int): 526 value = str(value).encode('utf8') 527 elif isinstance(value, text_type): 528 value = value.encode('utf8') 529 elif not isinstance(value, (bytes, str)): 530 raise ValueError(( 531 'Value for field {0} is a {1} ({2}). ' 532 'It must be str, bytes or an int' 533 ).format(key, type(value), value)) 534 lines.extend([ 535 b'--' + boundary, 536 b'Content-Disposition: form-data; name="' + key + b'"', 537 b'', value]) 538 539 for file_info in files: 540 _append_file(file_info) 541 542 lines.extend([b'--' + boundary + b'--', b'']) 543 body = b'\r\n'.join(lines) 544 boundary = boundary.decode('ascii') 545 content_type = 'multipart/form-data; boundary=%s' % boundary 546 return content_type, body 547 548 def request(self, url_or_req, status=None, expect_errors=False, 549 **req_params): 550 """ 551 Creates and executes a request. You may either pass in an 552 instantiated :class:`TestRequest` object, or you may pass in a 553 URL and keyword arguments to be passed to 554 :meth:`TestRequest.blank`. 555 556 You can use this to run a request without the intermediary 557 functioning of :meth:`TestApp.get` etc. For instance, to 558 test a WebDAV method:: 559 560 resp = app.request('/new-col', method='MKCOL') 561 562 Note that the request won't have a body unless you specify it, 563 like:: 564 565 resp = app.request('/test.txt', method='PUT', body='test') 566 567 You can use :class:`webtest.TestRequest`:: 568 569 req = webtest.TestRequest.blank('/url/', method='GET') 570 resp = app.do_request(req) 571 572 """ 573 if isinstance(url_or_req, text_type): 574 url_or_req = str(url_or_req) 575 for (k, v) in req_params.items(): 576 if isinstance(v, text_type): 577 req_params[k] = str(v) 578 if isinstance(url_or_req, string_types): 579 req = self.RequestClass.blank(url_or_req, **req_params) 580 else: 581 req = url_or_req.copy() 582 for name, value in req_params.items(): 583 setattr(req, name, value) 584 req.environ['paste.throw_errors'] = True 585 for name, value in self.extra_environ.items(): 586 req.environ.setdefault(name, value) 587 return self.do_request(req, 588 status=status, 589 expect_errors=expect_errors, 590 ) 591 592 def do_request(self, req, status=None, expect_errors=None): 593 """ 594 Executes the given webob Request (``req``), with the expected 595 ``status``. Generally :meth:`~webtest.TestApp.get` and 596 :meth:`~webtest.TestApp.post` are used instead. 597 598 To use this:: 599 600 req = webtest.TestRequest.blank('url', ...args...) 601 resp = app.do_request(req) 602 603 .. note:: 604 605 You can pass any keyword arguments to 606 ``TestRequest.blank()``, which will be set on the request. 607 These can be arguments like ``content_type``, ``accept``, etc. 608 609 """ 610 611 errors = StringIO() 612 req.environ['wsgi.errors'] = errors 613 script_name = req.environ.get('SCRIPT_NAME', '') 614 if script_name and req.path_info.startswith(script_name): 615 req.path_info = req.path_info[len(script_name):] 616 617 # set framework hooks 618 req.environ['paste.testing'] = True 619 req.environ['paste.testing_variables'] = {} 620 621 # set request cookies 622 self.cookiejar.add_cookie_header(utils._RequestCookieAdapter(req)) 623 624 # verify wsgi compatibility 625 app = lint.middleware(self.app) if self.lint else self.app 626 627 # FIXME: should it be an option to not catch exc_info? 628 res = req.get_response(app, catch_exc_info=True) 629 630 # be sure to decode the content 631 res.decode_content() 632 633 # set a few handy attributes 634 res._use_unicode = self.use_unicode 635 res.request = req 636 res.app = app 637 res.test_app = self 638 639 # We do this to make sure the app_iter is exhausted: 640 try: 641 res.body 642 except TypeError: # pragma: no cover 643 pass 644 res.errors = errors.getvalue() 645 646 for name, value in req.environ['paste.testing_variables'].items(): 647 if hasattr(res, name): 648 raise ValueError( 649 "paste.testing_variables contains the variable %r, but " 650 "the response object already has an attribute by that " 651 "name" % name) 652 setattr(res, name, value) 653 if not expect_errors: 654 self._check_status(status, res) 655 self._check_errors(res) 656 657 # merge cookies back in 658 self.cookiejar.extract_cookies(utils._ResponseCookieAdapter(res), 659 utils._RequestCookieAdapter(req)) 660 661 return res 662 663 def _check_status(self, status, res): 664 if status == '*': 665 return 666 res_status = res.status 667 if (isinstance(status, string_types) and '*' in status): 668 if re.match(fnmatch.translate(status), res_status, re.I): 669 return 670 if isinstance(status, string_types): 671 if status == res_status: 672 return 673 if isinstance(status, (list, tuple)): 674 if res.status_int not in status: 675 raise AppError( 676 "Bad response: %s (not one of %s for %s)\n%s", 677 res_status, ', '.join(map(str, status)), 678 res.request.url, res) 679 return 680 if status is None: 681 if res.status_int >= 200 and res.status_int < 400: 682 return 683 raise AppError( 684 "Bad response: %s (not 200 OK or 3xx redirect for %s)\n%s", 685 res_status, res.request.url, 686 res) 687 if status != res.status_int: 688 raise AppError( 689 "Bad response: %s (not %s)\n%s", res_status, status, res) 690 691 def _check_errors(self, res): 692 errors = res.errors 693 if errors: 694 raise AppError( 695 "Application had errors logged:\n%s", errors) 696 697 def _make_environ(self, extra_environ=None): 698 environ = self.extra_environ.copy() 699 environ['paste.throw_errors'] = True 700 if extra_environ: 701 environ.update(extra_environ) 702 return environ 703 704 def _remove_fragment(self, url): 705 scheme, netloc, path, query, fragment = urlparse.urlsplit(url) 706 return urlparse.urlunsplit((scheme, netloc, path, query, "")) 707 708 def _gen_request(self, method, url, params=utils.NoDefault, 709 headers=None, extra_environ=None, status=None, 710 upload_files=None, expect_errors=False, 711 content_type=None): 712 """ 713 Do a generic request. 714 """ 715 716 environ = self._make_environ(extra_environ) 717 718 inline_uploads = [] 719 720 # this supports OrderedDict 721 if isinstance(params, dict) or hasattr(params, 'items'): 722 params = list(params.items()) 723 724 if isinstance(params, (list, tuple)): 725 inline_uploads = [v for (k, v) in params 726 if isinstance(v, (forms.File, forms.Upload))] 727 728 if len(inline_uploads) > 0: 729 content_type, params = self.encode_multipart( 730 params, upload_files or ()) 731 environ['CONTENT_TYPE'] = content_type 732 else: 733 params = utils.encode_params(params, content_type) 734 if upload_files or \ 735 (content_type and 736 to_bytes(content_type).startswith(b'multipart')): 737 params = urlparse.parse_qsl(params, keep_blank_values=True) 738 content_type, params = self.encode_multipart( 739 params, upload_files or ()) 740 environ['CONTENT_TYPE'] = content_type 741 elif params: 742 environ.setdefault('CONTENT_TYPE', 743 str('application/x-www-form-urlencoded')) 744 745 if content_type is not None: 746 environ['CONTENT_TYPE'] = content_type 747 environ['REQUEST_METHOD'] = str(method) 748 url = str(url) 749 url = self._remove_fragment(url) 750 req = self.RequestClass.blank(url, environ) 751 if isinstance(params, text_type): 752 params = params.encode(req.charset or 'utf8') 753 req.environ['wsgi.input'] = BytesIO(params) 754 req.content_length = len(params) 755 if headers: 756 req.headers.update(headers) 757 return self.do_request(req, status=status, 758 expect_errors=expect_errors) 759 760 def _get_file_info(self, file_info): 761 if len(file_info) == 2: 762 # It only has a filename 763 filename = file_info[1] 764 if self.relative_to: 765 filename = os.path.join(self.relative_to, filename) 766 f = open(filename, 'rb') 767 content = f.read() 768 f.close() 769 return (file_info[0], filename, content, None) 770 elif 3 <= len(file_info) <= 4: 771 content = file_info[2] 772 if not isinstance(content, binary_type): 773 raise ValueError('File content must be %s not %s' 774 % (binary_type, type(content))) 775 if len(file_info) == 3: 776 return tuple(file_info) + (None,) 777 else: 778 return file_info 779 else: 780 raise ValueError( 781 "upload_files need to be a list of tuples of (fieldname, " 782 "filename, filecontent, mimetype) or (fieldname, " 783 "filename, filecontent) or (fieldname, filename); " 784 "you gave: %r" 785 % repr(file_info)[:100]) 786 787 @staticmethod 788 def _add_xhr_header(headers): 789 headers = headers or {} 790 # if remove str we will be have an error in lint.middleware 791 headers.update({'X-REQUESTED-WITH': str('XMLHttpRequest')}) 792 return headers 793