0001# (c) 2005 Ian Bicking and contributors; written for Paste (http://pythonpaste.org)
0002# Licensed under the MIT license: http://www.opensource.org/licenses/mit-license.php
0003"""
0004Routines for testing WSGI applications.
0005
0006Most interesting is TestApp
0007"""
0008
0009import sys
0010import random
0011import urllib
0012import urlparse
0013import mimetypes
0014import time
0015import cgi
0016import os
0017import webbrowser
0018from Cookie import SimpleCookie
0019try:
0020    from cStringIO import StringIO
0021except ImportError:
0022    from StringIO import StringIO
0023import re
0024from webob import Response, Request
0025from wsgiref.validate import validator
0026
0027__all__ = ['TestApp']
0028
0029def tempnam_no_warning(*args):
0030    """
0031    An os.tempnam with the warning turned off, because sometimes
0032    you just need to use this and don't care about the stupid
0033    security warning.
0034    """
0035    return os.tempnam(*args)
0036
0037class NoDefault(object):
0038    pass
0039
0040try:
0041    sorted
0042except NameError:
0043    def sorted(l):
0044        l = list(l)
0045        l.sort()
0046        return l
0047
0048class AppError(Exception):
0049    pass
0050
0051class TestApp(object):
0052
0053    # for py.test
0054    disabled = True
0055
0056    def __init__(self, app, extra_environ=None, relative_to=None):
0057        """
0058        Wraps a WSGI application in a more convenient interface for
0059        testing.
0060
0061        ``app`` may be an application, or a Paste Deploy app
0062        URI, like ``'config:filename.ini#test'``.
0063
0064        ``extra_environ`` is a dictionary of values that should go
0065        into the environment for each request.  These can provide a
0066        communication channel with the application.
0067
0068        ``relative_to`` is a directory, and filenames used for file
0069        uploads are calculated relative to this.  Also ``config:``
0070        URIs that aren't absolute.
0071        """
0072        if isinstance(app, (str, unicode)):
0073            from paste.deploy import loadapp
0074            # @@: Should pick up relative_to from calling module's
0075            # __file__
0076            app = loadapp(app, relative_to=relative_to)
0077        self.app = app
0078        self.relative_to = relative_to
0079        if extra_environ is None:
0080            extra_environ = {}
0081        self.extra_environ = extra_environ
0082        self.reset()
0083
0084    def reset(self):
0085        """
0086        Resets the state of the application; currently just clears
0087        saved cookies.
0088        """
0089        self.cookies = {}
0090
0091    def _make_environ(self, extra_environ=None):
0092        environ = self.extra_environ.copy()
0093        environ['paste.throw_errors'] = True
0094        if extra_environ:
0095            environ.update(extra_environ)
0096        return environ
0097
0098    def get(self, url, params=None, headers=None, extra_environ=None,
0099            status=None, expect_errors=False):
0100        """
0101        Get the given url (well, actually a path like
0102        ``'/page.html'``).
0103
0104        ``params``:
0105            A query string, or a dictionary that will be encoded
0106            into a query string.  You may also include a query
0107            string on the ``url``.
0108
0109        ``headers``:
0110            A dictionary of extra headers to send.
0111
0112        ``extra_environ``:
0113            A dictionary of environmental variables that should
0114            be added to the request.
0115
0116        ``status``:
0117            The integer status code you expect (if not 200 or 3xx).
0118            If you expect a 404 response, for instance, you must give
0119            ``status=404`` or it will be an error.  You can also give
0120            a wildcard, like ``'3*'`` or ``'*'``.
0121
0122        ``expect_errors``:
0123            If this is not true, then if anything is written to
0124            ``wsgi.errors`` it will be an error.  If it is true, then
0125            non-200/3xx responses are also okay.
0126
0127        Returns a ``webob.Response`` object.
0128        """
0129        environ = self._make_environ(extra_environ)
0130        # Hide from py.test:
0131        __tracebackhide__ = True
0132        if params:
0133            if not isinstance(params, (str, unicode)):
0134                params = urllib.urlencode(params, doseq=True)
0135            if '?' in url:
0136                url += '&'
0137            else:
0138                url += '?'
0139            url += params
0140        url = str(url)
0141        if '?' in url:
0142            url, environ['QUERY_STRING'] = url.split('?', 1)
0143        else:
0144            environ['QUERY_STRING'] = ''
0145        req = TestRequest.blank(url, environ)
0146        if headers:
0147            req.headers.update(headers)
0148        return self.do_request(req, status=status,
0149                               expect_errors=expect_errors)
0150
0151    def _gen_request(self, method, url, params='', headers=None, extra_environ=None,
0152             status=None, upload_files=None, expect_errors=False):
0153        """
0154        Do a generic request.  
0155        """
0156        environ = self._make_environ(extra_environ)
0157        # @@: Should this be all non-strings?
0158        if isinstance(params, (list, tuple, dict)):
0159            params = urllib.urlencode(params)
0160        if upload_files:
0161            params = cgi.parse_qsl(params, keep_blank_values=True)
0162            content_type, params = self.encode_multipart(
0163                params, upload_files)
0164            environ['CONTENT_TYPE'] = content_type
0165        elif params:
0166            environ.setdefault('CONTENT_TYPE', 'application/x-www-form-urlencoded')
0167        if '?' in url:
0168            url, environ['QUERY_STRING'] = url.split('?', 1)
0169        else:
0170            environ['QUERY_STRING'] = ''
0171        environ['CONTENT_LENGTH'] = str(len(params))
0172        environ['REQUEST_METHOD'] = method
0173        environ['wsgi.input'] = StringIO(params)
0174        req = TestRequest.blank(url, environ)
0175        if headers:
0176            req.headers.update(headers)
0177        return self.do_request(req, status=status,
0178                               expect_errors=expect_errors)
0179
0180    def post(self, url, params='', headers=None, extra_environ=None,
0181             status=None, upload_files=None, expect_errors=False):
0182        """
0183        Do a POST request.  Very like the ``.get()`` method.
0184        ``params`` are put in the body of the request.
0185
0186        ``upload_files`` is for file uploads.  It should be a list of
0187        ``[(fieldname, filename, file_content)]``.  You can also use
0188        just ``[(fieldname, filename)]`` and the file content will be
0189        read from disk.
0190
0191        Returns a ``webob.Response`` object.
0192        """
0193        return self._gen_request('POST', url, params=params, headers=headers,
0194                                 extra_environ=extra_environ,status=status,
0195                                 upload_files=upload_files,
0196                                 expect_errors=expect_errors)
0197
0198    def put(self, url, params='', headers=None, extra_environ=None,
0199             status=None, upload_files=None, expect_errors=False):
0200        """
0201        Do a PUT request.  Very like the ``.get()`` method.
0202        ``params`` are put in the body of the request.
0203
0204        ``upload_files`` is for file uploads.  It should be a list of
0205        ``[(fieldname, filename, file_content)]``.  You can also use
0206        just ``[(fieldname, filename)]`` and the file content will be
0207        read from disk.
0208
0209        Returns a ``webob.Response`` object.
0210        """
0211        return self._gen_request('PUT', url, params=params, headers=headers,
0212                                 extra_environ=extra_environ,status=status,
0213                                 upload_files=upload_files,
0214                                 expect_errors=expect_errors)
0215
0216    def delete(self, url, headers=None, extra_environ=None,
0217               status=None, expect_errors=False):
0218        """
0219        Do a DELETE request.  Very like the ``.get()`` method.
0220        ``params`` are put in the body of the request.
0221
0222        Returns a ``webob.Response`` object.
0223        """
0224        return self._gen_request('DELETE', url, params=params, headers=headers,
0225                                 extra_environ=extra_environ,status=status,
0226                                 upload_files=None, expect_errors=expect_errors)
0227
0228    def encode_multipart(self, params, files):
0229        """
0230        Encodes a set of parameters (typically a name/value list) and
0231        a set of files (a list of (name, filename, file_body)) into a
0232        typical POST body, returning the (content_type, body).
0233        """
0234        boundary = '----------a_BoUnDaRy%s$' % random.random()
0235        lines = []
0236        for key, value in params:
0237            lines.append('--'+boundary)
0238            lines.append('Content-Disposition: form-data; name="%s"' % key)
0239            lines.append('')
0240            lines.append(value)
0241        for file_info in files:
0242            key, filename, value = self._get_file_info(file_info)
0243            lines.append('--'+boundary)
0244            lines.append('Content-Disposition: form-data; name="%s"; filename="%s"'
0245                         % (key, filename))
0246            fcontent = mimetypes.guess_type(filename)[0]
0247            lines.append('Content-Type: %s' %
0248                         fcontent or 'application/octet-stream')
0249            lines.append('')
0250            lines.append(value)
0251        lines.append('--' + boundary + '--')
0252        lines.append('')
0253        body = '\r\n'.join(lines)
0254        content_type = 'multipart/form-data; boundary=%s' % boundary
0255        return content_type, body
0256
0257    def _get_file_info(self, file_info):
0258        if len(file_info) == 2:
0259            # It only has a filename
0260            filename = file_info[1]
0261            if self.relative_to:
0262                filename = os.path.join(self.relative_to, filename)
0263            f = open(filename, 'rb')
0264            content = f.read()
0265            f.close()
0266            return (file_info[0], filename, content)
0267        elif len(file_info) == 3:
0268            return file_info
0269        else:
0270            raise ValueError(
0271                "upload_files need to be a list of tuples of (fieldname, "
0272                "filename, filecontent) or (fieldname, filename); "
0273                "you gave: %r"
0274                % repr(file_info)[:100])
0275
0276    def do_request(self, req, status, expect_errors):
0277        """
0278        Executes the given request (``req``), with the expected
0279        ``status``.  Generally ``.get()`` and ``.post()`` are used
0280        instead.
0281        """
0282        __tracebackhide__ = True
0283        errors = StringIO()
0284        req.environ['wsgi.errors'] = errors
0285        if self.cookies:
0286            c = SimpleCookie()
0287            for name, value in self.cookies.items():
0288                c[name] = value
0289            req.environ['HTTP_COOKIE'] = str(c).split(': ', 1)[1]
0290        req.environ['paste.testing'] = True
0291        req.environ['paste.testing_variables'] = {}
0292        app = validator(self.app)
0293        old_stdout = sys.stdout
0294        out = CaptureStdout(old_stdout)
0295        try:
0296            sys.stdout = out
0297            start_time = time.time()
0298            ## FIXME: should it be an option to not catch exc_info?
0299            res = req.get_response(app, catch_exc_info=True)
0300            end_time = time.time()
0301        finally:
0302            sys.stdout = old_stdout
0303            sys.stderr.write(out.getvalue())
0304        res.app = app
0305        res.test_app = self
0306        # We do this to make sure the app_iter is exausted:
0307        res.body
0308        res.errors = errors.getvalue()
0309        total_time = end_time - start_time
0310        for name, value in req.environ['paste.testing_variables'].items():
0311            if hasattr(res, name):
0312                raise ValueError(
0313                    "paste.testing_variables contains the variable %r, but "
0314                    "the response object already has an attribute by that "
0315                    "name" % name)
0316            setattr(res, name, value)
0317        if not expect_errors:
0318            self._check_status(status, res)
0319            self._check_errors(res)
0320        res.cookies_set = {}
0321        for header in res.headers.getall('set-cookie'):
0322            c = SimpleCookie(header)
0323            for key, morsel in c.items():
0324                self.cookies[key] = morsel.value
0325                res.cookies_set[key] = morsel.value
0326        return res
0327
0328    def _check_status(self, status, res):
0329        __tracebackhide__ = True
0330        if status == '*':
0331            return
0332        if isinstance(status, (list, tuple)):
0333            if res.status_int not in status:
0334                raise AppError(
0335                    "Bad response: %s (not one of %s for %s)\n%s"
0336                    % (res.status, ', '.join(map(str, status)),
0337                       res.request.url, res.body))
0338            return
0339        if status is None:
0340            if res.status_int >= 200 and res.status_int < 400:
0341                return
0342            raise AppError(
0343                "Bad response: %s (not 200 OK or 3xx redirect for %s)\n%s"
0344                % (res.status, res.request.url,
0345                   res.body))
0346        if status != res.status_int:
0347            raise AppError(
0348                "Bad response: %s (not %s)" % (res.status, status))
0349
0350    def _check_errors(self, res):
0351        errors = res.errors
0352        if errors:
0353            raise AppError(
0354                "Application had errors logged:\n%s" % errors)
0355
0356class CaptureStdout(object):
0357
0358    def __init__(self, actual):
0359        self.captured = StringIO()
0360        self.actual = actual
0361
0362    def write(self, s):
0363        self.captured.write(s)
0364        self.actual.write(s)
0365
0366    def flush(self):
0367        self.actual.flush()
0368
0369    def writelines(self, lines):
0370        for item in lines:
0371            self.write(item)
0372
0373    def getvalue(self):
0374        return self.captured.getvalue()
0375
0376class TestResponse(Response):
0377
0378    """
0379    Instances of this class are return by ``TestApp``
0380    """
0381
0382    _forms_indexed = None
0383
0384
0385    def forms__get(self):
0386        """
0387        Returns a dictionary of ``Form`` objects.  Indexes are both in
0388        order (from zero) and by form id (if the form is given an id).
0389        """
0390        if self._forms_indexed is None:
0391            self._parse_forms()
0392        return self._forms_indexed
0393
0394    forms = property(forms__get,
0395                     doc="""
0396                     A list of <form>s found on the page (instances of
0397                     ``Form``)
0398                     """)
0399
0400    def form__get(self):
0401        forms = self.forms
0402        if not forms:
0403            raise TypeError(
0404                "You used response.form, but no forms exist")
0405        if 1 in forms:
0406            # There is more than one form
0407            raise TypeError(
0408                "You used response.form, but more than one form exists")
0409        return forms[0]
0410
0411    form = property(form__get,
0412                    doc="""
0413                    Returns a single ``Form`` instance; it
0414                    is an error if there are multiple forms on the
0415                    page.
0416                    """)
0417
0418    _tag_re = re.compile(r'<(/?)([:a-z0-9_\-]*)(.*?)>', re.S|re.I)
0419
0420    def _parse_forms(self):
0421        forms = self._forms_indexed = {}
0422        form_texts = []
0423        started = None
0424        for match in self._tag_re.finditer(self.body):
0425            end = match.group(1) == '/'
0426            tag = match.group(2).lower()
0427            if tag != 'form':
0428                continue
0429            if end:
0430                assert started, (
0431                    "</form> unexpected at %s" % match.start())
0432                form_texts.append(self.body[started:match.end()])
0433                started = None
0434            else:
0435                assert not started, (
0436                    "Nested form tags at %s" % match.start())
0437                started = match.start()
0438        assert not started, (
0439            "Danging form: %r" % self.body[started:])
0440        for i, text in enumerate(form_texts):
0441            form = Form(self, text)
0442            forms[i] = form
0443            if form.id:
0444                forms[form.id] = form
0445
0446    def follow(self, **kw):
0447        """
0448        If this request is a redirect, follow that redirect.  It
0449        is an error if this is not a redirect response.  Returns
0450        another response object.
0451        """
0452        assert self.status_int >= 300 and self.status_int < 400, (
0453            "You can only follow redirect responses (not %s)"
0454            % self.status)
0455        location = self.headers['location']
0456        type, rest = urllib.splittype(location)
0457        host, path = urllib.splithost(rest)
0458        # @@: We should test that it's not a remote redirect
0459        return self.test_app.get(location, **kw)
0460
0461    def click(self, description=None, linkid=None, href=None,
0462              anchor=None, index=None, verbose=False):
0463        """
0464        Click the link as described.  Each of ``description``,
0465        ``linkid``, and ``url`` are *patterns*, meaning that they are
0466        either strings (regular expressions), compiled regular
0467        expressions (objects with a ``search`` method), or callables
0468        returning true or false.
0469
0470        All the given patterns are ANDed together:
0471
0472        * ``description`` is a pattern that matches the contents of the
0473          anchor (HTML and all -- everything between ``<a...>`` and
0474          ``</a>``)
0475
0476        * ``linkid`` is a pattern that matches the ``id`` attribute of
0477          the anchor.  It will receive the empty string if no id is
0478