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