summaryrefslogtreecommitdiff
path: root/paste/wsgiwrappers.py
diff options
context:
space:
mode:
Diffstat (limited to 'paste/wsgiwrappers.py')
-rw-r--r--paste/wsgiwrappers.py590
1 files changed, 590 insertions, 0 deletions
diff --git a/paste/wsgiwrappers.py b/paste/wsgiwrappers.py
new file mode 100644
index 0000000..674054f
--- /dev/null
+++ b/paste/wsgiwrappers.py
@@ -0,0 +1,590 @@
+# (c) 2005 Ian Bicking and contributors; written for Paste (http://pythonpaste.org)
+# Licensed under the MIT license: http://www.opensource.org/licenses/mit-license.php
+"""WSGI Wrappers for a Request and Response
+
+The WSGIRequest and WSGIResponse objects are light wrappers to make it easier
+to deal with an incoming request and sending a response.
+"""
+import re
+import warnings
+from pprint import pformat
+try:
+ # Python 3
+ from http.cookies import SimpleCookie
+except ImportError:
+ # Python 2
+ from Cookie import SimpleCookie
+import six
+
+from paste.request import EnvironHeaders, get_cookie_dict, \
+ parse_dict_querystring, parse_formvars
+from paste.util.multidict import MultiDict, UnicodeMultiDict
+from paste.registry import StackedObjectProxy
+from paste.response import HeaderDict
+from paste.wsgilib import encode_unicode_app_iter
+from paste.httpheaders import ACCEPT_LANGUAGE
+from paste.util.mimeparse import desired_matches
+
+__all__ = ['WSGIRequest', 'WSGIResponse']
+
+_CHARSET_RE = re.compile(r';\s*charset=([^;]*)', re.I)
+
+class DeprecatedSettings(StackedObjectProxy):
+ def _push_object(self, obj):
+ warnings.warn('paste.wsgiwrappers.settings is deprecated: Please use '
+ 'paste.wsgiwrappers.WSGIRequest.defaults instead',
+ DeprecationWarning, 3)
+ WSGIResponse.defaults._push_object(obj)
+ StackedObjectProxy._push_object(self, obj)
+
+# settings is deprecated: use WSGIResponse.defaults instead
+settings = DeprecatedSettings(default=dict())
+
+class environ_getter(object):
+ """For delegating an attribute to a key in self.environ."""
+ # @@: Also __set__? Should setting be allowed?
+ def __init__(self, key, default='', default_factory=None):
+ self.key = key
+ self.default = default
+ self.default_factory = default_factory
+ def __get__(self, obj, type=None):
+ if type is None:
+ return self
+ if self.key not in obj.environ:
+ if self.default_factory:
+ val = obj.environ[self.key] = self.default_factory()
+ return val
+ else:
+ return self.default
+ return obj.environ[self.key]
+
+ def __repr__(self):
+ return '<Proxy for WSGI environ %r key>' % self.key
+
+class WSGIRequest(object):
+ """WSGI Request API Object
+
+ This object represents a WSGI request with a more friendly interface.
+ This does not expose every detail of the WSGI environment, and attempts
+ to express nothing beyond what is available in the environment
+ dictionary.
+
+ The only state maintained in this object is the desired ``charset``,
+ its associated ``errors`` handler, and the ``decode_param_names``
+ option.
+
+ The incoming parameter values will be automatically coerced to unicode
+ objects of the ``charset`` encoding when ``charset`` is set. The
+ incoming parameter names are not decoded to unicode unless the
+ ``decode_param_names`` option is enabled.
+
+ When unicode is expected, ``charset`` will overridden by the the
+ value of the ``Content-Type`` header's charset parameter if one was
+ specified by the client.
+
+ The class variable ``defaults`` specifies default values for
+ ``charset``, ``errors``, and ``langauge``. These can be overridden for the
+ current request via the registry.
+
+ The ``language`` default value is considered the fallback during i18n
+ translations to ensure in odd cases that mixed languages don't occur should
+ the ``language`` file contain the string but not another language in the
+ accepted languages list. The ``language`` value only applies when getting
+ a list of accepted languages from the HTTP Accept header.
+
+ This behavior is duplicated from Aquarium, and may seem strange but is
+ very useful. Normally, everything in the code is in "en-us". However,
+ the "en-us" translation catalog is usually empty. If the user requests
+ ``["en-us", "zh-cn"]`` and a translation isn't found for a string in
+ "en-us", you don't want gettext to fallback to "zh-cn". You want it to
+ just use the string itself. Hence, if a string isn't found in the
+ ``language`` catalog, the string in the source code will be used.
+
+ *All* other state is kept in the environment dictionary; this is
+ essential for interoperability.
+
+ You are free to subclass this object.
+
+ """
+ defaults = StackedObjectProxy(default=dict(charset=None, errors='replace',
+ decode_param_names=False,
+ language='en-us'))
+ def __init__(self, environ):
+ self.environ = environ
+ # This isn't "state" really, since the object is derivative:
+ self.headers = EnvironHeaders(environ)
+
+ defaults = self.defaults._current_obj()
+ self.charset = defaults.get('charset')
+ if self.charset:
+ # There's a charset: params will be coerced to unicode. In that
+ # case, attempt to use the charset specified by the browser
+ browser_charset = self.determine_browser_charset()
+ if browser_charset:
+ self.charset = browser_charset
+ self.errors = defaults.get('errors', 'strict')
+ self.decode_param_names = defaults.get('decode_param_names', False)
+ self._languages = None
+
+ body = environ_getter('wsgi.input')
+ scheme = environ_getter('wsgi.url_scheme')
+ method = environ_getter('REQUEST_METHOD')
+ script_name = environ_getter('SCRIPT_NAME')
+ path_info = environ_getter('PATH_INFO')
+
+ def urlvars(self):
+ """
+ Return any variables matched in the URL (e.g.,
+ ``wsgiorg.routing_args``).
+ """
+ if 'paste.urlvars' in self.environ:
+ return self.environ['paste.urlvars']
+ elif 'wsgiorg.routing_args' in self.environ:
+ return self.environ['wsgiorg.routing_args'][1]
+ else:
+ return {}
+ urlvars = property(urlvars, doc=urlvars.__doc__)
+
+ def is_xhr(self):
+ """Returns a boolean if X-Requested-With is present and a XMLHttpRequest"""
+ return self.environ.get('HTTP_X_REQUESTED_WITH', '') == 'XMLHttpRequest'
+ is_xhr = property(is_xhr, doc=is_xhr.__doc__)
+
+ def host(self):
+ """Host name provided in HTTP_HOST, with fall-back to SERVER_NAME"""
+ return self.environ.get('HTTP_HOST', self.environ.get('SERVER_NAME'))
+ host = property(host, doc=host.__doc__)
+
+ def languages(self):
+ """Return a list of preferred languages, most preferred first.
+
+ The list may be empty.
+ """
+ if self._languages is not None:
+ return self._languages
+ acceptLanguage = self.environ.get('HTTP_ACCEPT_LANGUAGE')
+ langs = ACCEPT_LANGUAGE.parse(self.environ)
+ fallback = self.defaults.get('language', 'en-us')
+ if not fallback:
+ return langs
+ if fallback not in langs:
+ langs.append(fallback)
+ index = langs.index(fallback)
+ langs[index+1:] = []
+ self._languages = langs
+ return self._languages
+ languages = property(languages, doc=languages.__doc__)
+
+ def _GET(self):
+ return parse_dict_querystring(self.environ)
+
+ def GET(self):
+ """
+ Dictionary-like object representing the QUERY_STRING
+ parameters. Always present, if possibly empty.
+
+ If the same key is present in the query string multiple times, a
+ list of its values can be retrieved from the ``MultiDict`` via
+ the ``getall`` method.
+
+ Returns a ``MultiDict`` container or a ``UnicodeMultiDict`` when
+ ``charset`` is set.
+ """
+ params = self._GET()
+ if self.charset:
+ params = UnicodeMultiDict(params, encoding=self.charset,
+ errors=self.errors,
+ decode_keys=self.decode_param_names)
+ return params
+ GET = property(GET, doc=GET.__doc__)
+
+ def _POST(self):
+ return parse_formvars(self.environ, include_get_vars=False,
+ encoding=self.charset, errors=self.errors)
+
+ def POST(self):
+ """Dictionary-like object representing the POST body.
+
+ Most values are encoded strings, or unicode strings when
+ ``charset`` is set. There may also be FieldStorage objects
+ representing file uploads. If this is not a POST request, or the
+ body is not encoded fields (e.g., an XMLRPC request) then this
+ will be empty.
+
+ This will consume wsgi.input when first accessed if applicable,
+ but the raw version will be put in
+ environ['paste.parsed_formvars'].
+
+ Returns a ``MultiDict`` container or a ``UnicodeMultiDict`` when
+ ``charset`` is set.
+ """
+ params = self._POST()
+ if self.charset:
+ params = UnicodeMultiDict(params, encoding=self.charset,
+ errors=self.errors,
+ decode_keys=self.decode_param_names)
+ return params
+ POST = property(POST, doc=POST.__doc__)
+
+ def params(self):
+ """Dictionary-like object of keys from POST, GET, URL dicts
+
+ Return a key value from the parameters, they are checked in the
+ following order: POST, GET, URL
+
+ Additional methods supported:
+
+ ``getlist(key)``
+ Returns a list of all the values by that key, collected from
+ POST, GET, URL dicts
+
+ Returns a ``MultiDict`` container or a ``UnicodeMultiDict`` when
+ ``charset`` is set.
+ """
+ params = MultiDict()
+ params.update(self._POST())
+ params.update(self._GET())
+ if self.charset:
+ params = UnicodeMultiDict(params, encoding=self.charset,
+ errors=self.errors,
+ decode_keys=self.decode_param_names)
+ return params
+ params = property(params, doc=params.__doc__)
+
+ def cookies(self):
+ """Dictionary of cookies keyed by cookie name.
+
+ Just a plain dictionary, may be empty but not None.
+
+ """
+ return get_cookie_dict(self.environ)
+ cookies = property(cookies, doc=cookies.__doc__)
+
+ def determine_browser_charset(self):
+ """
+ Determine the encoding as specified by the browser via the
+ Content-Type's charset parameter, if one is set
+ """
+ charset_match = _CHARSET_RE.search(self.headers.get('Content-Type', ''))
+ if charset_match:
+ return charset_match.group(1)
+
+ def match_accept(self, mimetypes):
+ """Return a list of specified mime-types that the browser's HTTP Accept
+ header allows in the order provided."""
+ return desired_matches(mimetypes,
+ self.environ.get('HTTP_ACCEPT', '*/*'))
+
+ def __repr__(self):
+ """Show important attributes of the WSGIRequest"""
+ pf = pformat
+ msg = '<%s.%s object at 0x%x method=%s,' % \
+ (self.__class__.__module__, self.__class__.__name__,
+ id(self), pf(self.method))
+ msg += '\nscheme=%s, host=%s, script_name=%s, path_info=%s,' % \
+ (pf(self.scheme), pf(self.host), pf(self.script_name),
+ pf(self.path_info))
+ msg += '\nlanguages=%s,' % pf(self.languages)
+ if self.charset:
+ msg += ' charset=%s, errors=%s,' % (pf(self.charset),
+ pf(self.errors))
+ msg += '\nGET=%s,' % pf(self.GET)
+ msg += '\nPOST=%s,' % pf(self.POST)
+ msg += '\ncookies=%s>' % pf(self.cookies)
+ return msg
+
+class WSGIResponse(object):
+ """A basic HTTP response with content, headers, and out-bound cookies
+
+ The class variable ``defaults`` specifies default values for
+ ``content_type``, ``charset`` and ``errors``. These can be overridden
+ for the current request via the registry.
+
+ """
+ defaults = StackedObjectProxy(
+ default=dict(content_type='text/html', charset='utf-8',
+ errors='strict', headers={'Cache-Control':'no-cache'})
+ )
+ def __init__(self, content=b'', mimetype=None, code=200):
+ self._iter = None
+ self._is_str_iter = True
+
+ self.content = content
+ self.headers = HeaderDict()
+ self.cookies = SimpleCookie()
+ self.status_code = code
+
+ defaults = self.defaults._current_obj()
+ if not mimetype:
+ mimetype = defaults.get('content_type', 'text/html')
+ charset = defaults.get('charset')
+ if charset:
+ mimetype = '%s; charset=%s' % (mimetype, charset)
+ self.headers.update(defaults.get('headers', {}))
+ self.headers['Content-Type'] = mimetype
+ self.errors = defaults.get('errors', 'strict')
+
+ def __str__(self):
+ """Returns a rendition of the full HTTP message, including headers.
+
+ When the content is an iterator, the actual content is replaced with the
+ output of str(iterator) (to avoid exhausting the iterator).
+ """
+ if self._is_str_iter:
+ content = ''.join(self.get_content())
+ else:
+ content = str(self.content)
+ return '\n'.join(['%s: %s' % (key, value)
+ for key, value in self.headers.headeritems()]) \
+ + '\n\n' + content
+
+ def __call__(self, environ, start_response):
+ """Convenience call to return output and set status information
+
+ Conforms to the WSGI interface for calling purposes only.
+
+ Example usage:
+
+ .. code-block:: python
+
+ def wsgi_app(environ, start_response):
+ response = WSGIResponse()
+ response.write("Hello world")
+ response.headers['Content-Type'] = 'latin1'
+ return response(environ, start_response)
+
+ """
+ status_text = STATUS_CODE_TEXT[self.status_code]
+ status = '%s %s' % (self.status_code, status_text)
+ response_headers = self.headers.headeritems()
+ for c in self.cookies.values():
+ response_headers.append(('Set-Cookie', c.output(header='')))
+ start_response(status, response_headers)
+ is_file = isinstance(self.content, file)
+ if 'wsgi.file_wrapper' in environ and is_file:
+ return environ['wsgi.file_wrapper'](self.content)
+ elif is_file:
+ return iter(lambda: self.content.read(), '')
+ return self.get_content()
+
+ def determine_charset(self):
+ """
+ Determine the encoding as specified by the Content-Type's charset
+ parameter, if one is set
+ """
+ charset_match = _CHARSET_RE.search(self.headers.get('Content-Type', ''))
+ if charset_match:
+ return charset_match.group(1)
+
+ def has_header(self, header):
+ """
+ Case-insensitive check for a header
+ """
+ warnings.warn('WSGIResponse.has_header is deprecated, use '
+ 'WSGIResponse.headers.has_key instead', DeprecationWarning,
+ 2)
+ return self.headers.has_key(header)
+
+ def set_cookie(self, key, value='', max_age=None, expires=None, path='/',
+ domain=None, secure=None, httponly=None):
+ """
+ Define a cookie to be sent via the outgoing HTTP headers
+ """
+ self.cookies[key] = value
+ for var_name, var_value in [
+ ('max_age', max_age), ('path', path), ('domain', domain),
+ ('secure', secure), ('expires', expires), ('httponly', httponly)]:
+ if var_value is not None and var_value is not False:
+ self.cookies[key][var_name.replace('_', '-')] = var_value
+
+ def delete_cookie(self, key, path='/', domain=None):
+ """
+ Notify the browser the specified cookie has expired and should be
+ deleted (via the outgoing HTTP headers)
+ """
+ self.cookies[key] = ''
+ if path is not None:
+ self.cookies[key]['path'] = path
+ if domain is not None:
+ self.cookies[key]['domain'] = domain
+ self.cookies[key]['expires'] = 0
+ self.cookies[key]['max-age'] = 0
+
+ def _set_content(self, content):
+ if not isinstance(content, (six.binary_type, six.text_type)):
+ self._iter = content
+ if isinstance(content, list):
+ self._is_str_iter = True
+ else:
+ self._is_str_iter = False
+ else:
+ self._iter = [content]
+ self._is_str_iter = True
+ content = property(lambda self: self._iter, _set_content,
+ doc='Get/set the specified content, where content can '
+ 'be: a string, a list of strings, a generator function '
+ 'that yields strings, or an iterable object that '
+ 'produces strings.')
+
+ def get_content(self):
+ """
+ Returns the content as an iterable of strings, encoding each element of
+ the iterator from a Unicode object if necessary.
+ """
+ charset = self.determine_charset()
+ if charset:
+ return encode_unicode_app_iter(self.content, charset, self.errors)
+ else:
+ return self.content
+
+ def wsgi_response(self):
+ """
+ Return this WSGIResponse as a tuple of WSGI formatted data, including:
+ (status, headers, iterable)
+ """
+ status_text = STATUS_CODE_TEXT[self.status_code]
+ status = '%s %s' % (self.status_code, status_text)
+ response_headers = self.headers.headeritems()
+ for c in self.cookies.values():
+ response_headers.append(('Set-Cookie', c.output(header='')))
+ return status, response_headers, self.get_content()
+
+ # The remaining methods partially implement the file-like object interface.
+ # See http://docs.python.org/lib/bltin-file-objects.html
+ def write(self, content):
+ if not self._is_str_iter:
+ raise IOError("This %s instance's content is not writable: (content "
+ 'is an iterator)' % self.__class__.__name__)
+ self.content.append(content)
+
+ def flush(self):
+ pass
+
+ def tell(self):
+ if not self._is_str_iter:
+ raise IOError('This %s instance cannot tell its position: (content '
+ 'is an iterator)' % self.__class__.__name__)
+ return sum([len(chunk) for chunk in self._iter])
+
+ ########################################
+ ## Content-type and charset
+
+ def charset__get(self):
+ """
+ Get/set the charset (in the Content-Type)
+ """
+ header = self.headers.get('content-type')
+ if not header:
+ return None
+ match = _CHARSET_RE.search(header)
+ if match:
+ return match.group(1)
+ return None
+
+ def charset__set(self, charset):
+ if charset is None:
+ del self.charset
+ return
+ try:
+ header = self.headers.pop('content-type')
+ except KeyError:
+ raise AttributeError(
+ "You cannot set the charset when no content-type is defined")
+ match = _CHARSET_RE.search(header)
+ if match:
+ header = header[:match.start()] + header[match.end():]
+ header += '; charset=%s' % charset
+ self.headers['content-type'] = header
+
+ def charset__del(self):
+ try:
+ header = self.headers.pop('content-type')
+ except KeyError:
+ # Don't need to remove anything
+ return
+ match = _CHARSET_RE.search(header)
+ if match:
+ header = header[:match.start()] + header[match.end():]
+ self.headers['content-type'] = header
+
+ charset = property(charset__get, charset__set, charset__del, doc=charset__get.__doc__)
+
+ def content_type__get(self):
+ """
+ Get/set the Content-Type header (or None), *without* the
+ charset or any parameters.
+
+ If you include parameters (or ``;`` at all) when setting the
+ content_type, any existing parameters will be deleted;
+ otherwise they will be preserved.
+ """
+ header = self.headers.get('content-type')
+ if not header:
+ return None
+ return header.split(';', 1)[0]
+
+ def content_type__set(self, value):
+ if ';' not in value:
+ header = self.headers.get('content-type', '')
+ if ';' in header:
+ params = header.split(';', 1)[1]
+ value += ';' + params
+ self.headers['content-type'] = value
+
+ def content_type__del(self):
+ try:
+ del self.headers['content-type']
+ except KeyError:
+ pass
+
+ content_type = property(content_type__get, content_type__set,
+ content_type__del, doc=content_type__get.__doc__)
+
+## @@ I'd love to remove this, but paste.httpexceptions.get_exception
+## doesn't seem to work...
+# See http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html
+STATUS_CODE_TEXT = {
+ 100: 'CONTINUE',
+ 101: 'SWITCHING PROTOCOLS',
+ 200: 'OK',
+ 201: 'CREATED',
+ 202: 'ACCEPTED',
+ 203: 'NON-AUTHORITATIVE INFORMATION',
+ 204: 'NO CONTENT',
+ 205: 'RESET CONTENT',
+ 206: 'PARTIAL CONTENT',
+ 226: 'IM USED',
+ 300: 'MULTIPLE CHOICES',
+ 301: 'MOVED PERMANENTLY',
+ 302: 'FOUND',
+ 303: 'SEE OTHER',
+ 304: 'NOT MODIFIED',
+ 305: 'USE PROXY',
+ 306: 'RESERVED',
+ 307: 'TEMPORARY REDIRECT',
+ 400: 'BAD REQUEST',
+ 401: 'UNAUTHORIZED',
+ 402: 'PAYMENT REQUIRED',
+ 403: 'FORBIDDEN',
+ 404: 'NOT FOUND',
+ 405: 'METHOD NOT ALLOWED',
+ 406: 'NOT ACCEPTABLE',
+ 407: 'PROXY AUTHENTICATION REQUIRED',
+ 408: 'REQUEST TIMEOUT',
+ 409: 'CONFLICT',
+ 410: 'GONE',
+ 411: 'LENGTH REQUIRED',
+ 412: 'PRECONDITION FAILED',
+ 413: 'REQUEST ENTITY TOO LARGE',
+ 414: 'REQUEST-URI TOO LONG',
+ 415: 'UNSUPPORTED MEDIA TYPE',
+ 416: 'REQUESTED RANGE NOT SATISFIABLE',
+ 417: 'EXPECTATION FAILED',
+ 429: 'TOO MANY REQUESTS',
+ 500: 'INTERNAL SERVER ERROR',
+ 501: 'NOT IMPLEMENTED',
+ 502: 'BAD GATEWAY',
+ 503: 'SERVICE UNAVAILABLE',
+ 504: 'GATEWAY TIMEOUT',
+ 505: 'HTTP VERSION NOT SUPPORTED',
+}