0001
0002
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
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
0075
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
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
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
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
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
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
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
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