diff options
author | Marc Abramowitz <marc@marc-abramowitz.com> | 2016-03-07 18:52:36 -0800 |
---|---|---|
committer | Marc Abramowitz <marc@marc-abramowitz.com> | 2016-03-07 18:52:36 -0800 |
commit | cc83e06efff71b81ca5a3ac6df65775971181295 (patch) | |
tree | d52fa3f1a93730f263c2c5ac8266de8e5fb12abf /paste/wsgiwrappers.py | |
download | paste-git-tox_coverage.tar.gz |
tox.ini: Measure test coveragetox_coverage
Diffstat (limited to 'paste/wsgiwrappers.py')
-rw-r--r-- | paste/wsgiwrappers.py | 590 |
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', +} |