Home | History | Annotate | Download | only in paste
      1 # (c) 2005 Ian Bicking and contributors; written for Paste (http://pythonpaste.org)
      2 # Licensed under the MIT license: http://www.opensource.org/licenses/mit-license.php
      3 """WSGI Wrappers for a Request and Response
      4 
      5 The WSGIRequest and WSGIResponse objects are light wrappers to make it easier
      6 to deal with an incoming request and sending a response.
      7 """
      8 import re
      9 import warnings
     10 from pprint import pformat
     11 try:
     12     # Python 3
     13     from http.cookies import SimpleCookie
     14 except ImportError:
     15     # Python 2
     16     from Cookie import SimpleCookie
     17 import six
     18 
     19 from paste.request import EnvironHeaders, get_cookie_dict, \
     20     parse_dict_querystring, parse_formvars
     21 from paste.util.multidict import MultiDict, UnicodeMultiDict
     22 from paste.registry import StackedObjectProxy
     23 from paste.response import HeaderDict
     24 from paste.wsgilib import encode_unicode_app_iter
     25 from paste.httpheaders import ACCEPT_LANGUAGE
     26 from paste.util.mimeparse import desired_matches
     27 
     28 __all__ = ['WSGIRequest', 'WSGIResponse']
     29 
     30 _CHARSET_RE = re.compile(r';\s*charset=([^;]*)', re.I)
     31 
     32 class DeprecatedSettings(StackedObjectProxy):
     33     def _push_object(self, obj):
     34         warnings.warn('paste.wsgiwrappers.settings is deprecated: Please use '
     35                       'paste.wsgiwrappers.WSGIRequest.defaults instead',
     36                       DeprecationWarning, 3)
     37         WSGIResponse.defaults._push_object(obj)
     38         StackedObjectProxy._push_object(self, obj)
     39 
     40 # settings is deprecated: use WSGIResponse.defaults instead
     41 settings = DeprecatedSettings(default=dict())
     42 
     43 class environ_getter(object):
     44     """For delegating an attribute to a key in self.environ."""
     45     # @@: Also __set__?  Should setting be allowed?
     46     def __init__(self, key, default='', default_factory=None):
     47         self.key = key
     48         self.default = default
     49         self.default_factory = default_factory
     50     def __get__(self, obj, type=None):
     51         if type is None:
     52             return self
     53         if self.key not in obj.environ:
     54             if self.default_factory:
     55                 val = obj.environ[self.key] = self.default_factory()
     56                 return val
     57             else:
     58                 return self.default
     59         return obj.environ[self.key]
     60 
     61     def __repr__(self):
     62         return '<Proxy for WSGI environ %r key>' % self.key
     63 
     64 class WSGIRequest(object):
     65     """WSGI Request API Object
     66 
     67     This object represents a WSGI request with a more friendly interface.
     68     This does not expose every detail of the WSGI environment, and attempts
     69     to express nothing beyond what is available in the environment
     70     dictionary.
     71 
     72     The only state maintained in this object is the desired ``charset``,
     73     its associated ``errors`` handler, and the ``decode_param_names``
     74     option.
     75 
     76     The incoming parameter values will be automatically coerced to unicode
     77     objects of the ``charset`` encoding when ``charset`` is set. The
     78     incoming parameter names are not decoded to unicode unless the
     79     ``decode_param_names`` option is enabled.
     80 
     81     When unicode is expected, ``charset`` will overridden by the the
     82     value of the ``Content-Type`` header's charset parameter if one was
     83     specified by the client.
     84 
     85     The class variable ``defaults`` specifies default values for
     86     ``charset``, ``errors``, and ``langauge``. These can be overridden for the
     87     current request via the registry.
     88 
     89     The ``language`` default value is considered the fallback during i18n
     90     translations to ensure in odd cases that mixed languages don't occur should
     91     the ``language`` file contain the string but not another language in the
     92     accepted languages list. The ``language`` value only applies when getting
     93     a list of accepted languages from the HTTP Accept header.
     94 
     95     This behavior is duplicated from Aquarium, and may seem strange but is
     96     very useful. Normally, everything in the code is in "en-us".  However,
     97     the "en-us" translation catalog is usually empty.  If the user requests
     98     ``["en-us", "zh-cn"]`` and a translation isn't found for a string in
     99     "en-us", you don't want gettext to fallback to "zh-cn".  You want it to
    100     just use the string itself.  Hence, if a string isn't found in the
    101     ``language`` catalog, the string in the source code will be used.
    102 
    103     *All* other state is kept in the environment dictionary; this is
    104     essential for interoperability.
    105 
    106     You are free to subclass this object.
    107 
    108     """
    109     defaults = StackedObjectProxy(default=dict(charset=None, errors='replace',
    110                                                decode_param_names=False,
    111                                                language='en-us'))
    112     def __init__(self, environ):
    113         self.environ = environ
    114         # This isn't "state" really, since the object is derivative:
    115         self.headers = EnvironHeaders(environ)
    116 
    117         defaults = self.defaults._current_obj()
    118         self.charset = defaults.get('charset')
    119         if self.charset:
    120             # There's a charset: params will be coerced to unicode. In that
    121             # case, attempt to use the charset specified by the browser
    122             browser_charset = self.determine_browser_charset()
    123             if browser_charset:
    124                 self.charset = browser_charset
    125         self.errors = defaults.get('errors', 'strict')
    126         self.decode_param_names = defaults.get('decode_param_names', False)
    127         self._languages = None
    128 
    129     body = environ_getter('wsgi.input')
    130     scheme = environ_getter('wsgi.url_scheme')
    131     method = environ_getter('REQUEST_METHOD')
    132     script_name = environ_getter('SCRIPT_NAME')
    133     path_info = environ_getter('PATH_INFO')
    134 
    135     def urlvars(self):
    136         """
    137         Return any variables matched in the URL (e.g.,
    138         ``wsgiorg.routing_args``).
    139         """
    140         if 'paste.urlvars' in self.environ:
    141             return self.environ['paste.urlvars']
    142         elif 'wsgiorg.routing_args' in self.environ:
    143             return self.environ['wsgiorg.routing_args'][1]
    144         else:
    145             return {}
    146     urlvars = property(urlvars, doc=urlvars.__doc__)
    147 
    148     def is_xhr(self):
    149         """Returns a boolean if X-Requested-With is present and a XMLHttpRequest"""
    150         return self.environ.get('HTTP_X_REQUESTED_WITH', '') == 'XMLHttpRequest'
    151     is_xhr = property(is_xhr, doc=is_xhr.__doc__)
    152 
    153     def host(self):
    154         """Host name provided in HTTP_HOST, with fall-back to SERVER_NAME"""
    155         return self.environ.get('HTTP_HOST', self.environ.get('SERVER_NAME'))
    156     host = property(host, doc=host.__doc__)
    157 
    158     def languages(self):
    159         """Return a list of preferred languages, most preferred first.
    160 
    161         The list may be empty.
    162         """
    163         if self._languages is not None:
    164             return self._languages
    165         acceptLanguage = self.environ.get('HTTP_ACCEPT_LANGUAGE')
    166         langs = ACCEPT_LANGUAGE.parse(self.environ)
    167         fallback = self.defaults.get('language', 'en-us')
    168         if not fallback:
    169             return langs
    170         if fallback not in langs:
    171             langs.append(fallback)
    172         index = langs.index(fallback)
    173         langs[index+1:] = []
    174         self._languages = langs
    175         return self._languages
    176     languages = property(languages, doc=languages.__doc__)
    177 
    178     def _GET(self):
    179         return parse_dict_querystring(self.environ)
    180 
    181     def GET(self):
    182         """
    183         Dictionary-like object representing the QUERY_STRING
    184         parameters. Always present, if possibly empty.
    185 
    186         If the same key is present in the query string multiple times, a
    187         list of its values can be retrieved from the ``MultiDict`` via
    188         the ``getall`` method.
    189 
    190         Returns a ``MultiDict`` container or a ``UnicodeMultiDict`` when
    191         ``charset`` is set.
    192         """
    193         params = self._GET()
    194         if self.charset:
    195             params = UnicodeMultiDict(params, encoding=self.charset,
    196                                       errors=self.errors,
    197                                       decode_keys=self.decode_param_names)
    198         return params
    199     GET = property(GET, doc=GET.__doc__)
    200 
    201     def _POST(self):
    202         return parse_formvars(self.environ, include_get_vars=False)
    203 
    204     def POST(self):
    205         """Dictionary-like object representing the POST body.
    206 
    207         Most values are encoded strings, or unicode strings when
    208         ``charset`` is set. There may also be FieldStorage objects
    209         representing file uploads. If this is not a POST request, or the
    210         body is not encoded fields (e.g., an XMLRPC request) then this
    211         will be empty.
    212 
    213         This will consume wsgi.input when first accessed if applicable,
    214         but the raw version will be put in
    215         environ['paste.parsed_formvars'].
    216 
    217         Returns a ``MultiDict`` container or a ``UnicodeMultiDict`` when
    218         ``charset`` is set.
    219         """
    220         params = self._POST()
    221         if self.charset:
    222             params = UnicodeMultiDict(params, encoding=self.charset,
    223                                       errors=self.errors,
    224                                       decode_keys=self.decode_param_names)
    225         return params
    226     POST = property(POST, doc=POST.__doc__)
    227 
    228     def params(self):
    229         """Dictionary-like object of keys from POST, GET, URL dicts
    230 
    231         Return a key value from the parameters, they are checked in the
    232         following order: POST, GET, URL
    233 
    234         Additional methods supported:
    235 
    236         ``getlist(key)``
    237             Returns a list of all the values by that key, collected from
    238             POST, GET, URL dicts
    239 
    240         Returns a ``MultiDict`` container or a ``UnicodeMultiDict`` when
    241         ``charset`` is set.
    242         """
    243         params = MultiDict()
    244         params.update(self._POST())
    245         params.update(self._GET())
    246         if self.charset:
    247             params = UnicodeMultiDict(params, encoding=self.charset,
    248                                       errors=self.errors,
    249                                       decode_keys=self.decode_param_names)
    250         return params
    251     params = property(params, doc=params.__doc__)
    252 
    253     def cookies(self):
    254         """Dictionary of cookies keyed by cookie name.
    255 
    256         Just a plain dictionary, may be empty but not None.
    257 
    258         """
    259         return get_cookie_dict(self.environ)
    260     cookies = property(cookies, doc=cookies.__doc__)
    261 
    262     def determine_browser_charset(self):
    263         """
    264         Determine the encoding as specified by the browser via the
    265         Content-Type's charset parameter, if one is set
    266         """
    267         charset_match = _CHARSET_RE.search(self.headers.get('Content-Type', ''))
    268         if charset_match:
    269             return charset_match.group(1)
    270 
    271     def match_accept(self, mimetypes):
    272         """Return a list of specified mime-types that the browser's HTTP Accept
    273         header allows in the order provided."""
    274         return desired_matches(mimetypes,
    275                                self.environ.get('HTTP_ACCEPT', '*/*'))
    276 
    277     def __repr__(self):
    278         """Show important attributes of the WSGIRequest"""
    279         pf = pformat
    280         msg = '<%s.%s object at 0x%x method=%s,' % \
    281             (self.__class__.__module__, self.__class__.__name__,
    282              id(self), pf(self.method))
    283         msg += '\nscheme=%s, host=%s, script_name=%s, path_info=%s,' % \
    284             (pf(self.scheme), pf(self.host), pf(self.script_name),
    285              pf(self.path_info))
    286         msg += '\nlanguages=%s,' % pf(self.languages)
    287         if self.charset:
    288             msg += ' charset=%s, errors=%s,' % (pf(self.charset),
    289                                                 pf(self.errors))
    290         msg += '\nGET=%s,' % pf(self.GET)
    291         msg += '\nPOST=%s,' % pf(self.POST)
    292         msg += '\ncookies=%s>' % pf(self.cookies)
    293         return msg
    294 
    295 class WSGIResponse(object):
    296     """A basic HTTP response with content, headers, and out-bound cookies
    297 
    298     The class variable ``defaults`` specifies default values for
    299     ``content_type``, ``charset`` and ``errors``. These can be overridden
    300     for the current request via the registry.
    301 
    302     """
    303     defaults = StackedObjectProxy(
    304         default=dict(content_type='text/html', charset='utf-8',
    305                      errors='strict', headers={'Cache-Control':'no-cache'})
    306         )
    307     def __init__(self, content=b'', mimetype=None, code=200):
    308         self._iter = None
    309         self._is_str_iter = True
    310 
    311         self.content = content
    312         self.headers = HeaderDict()
    313         self.cookies = SimpleCookie()
    314         self.status_code = code
    315 
    316         defaults = self.defaults._current_obj()
    317         if not mimetype:
    318             mimetype = defaults.get('content_type', 'text/html')
    319             charset = defaults.get('charset')
    320             if charset:
    321                 mimetype = '%s; charset=%s' % (mimetype, charset)
    322         self.headers.update(defaults.get('headers', {}))
    323         self.headers['Content-Type'] = mimetype
    324         self.errors = defaults.get('errors', 'strict')
    325 
    326     def __str__(self):
    327         """Returns a rendition of the full HTTP message, including headers.
    328 
    329         When the content is an iterator, the actual content is replaced with the
    330         output of str(iterator) (to avoid exhausting the iterator).
    331         """
    332         if self._is_str_iter:
    333             content = ''.join(self.get_content())
    334         else:
    335             content = str(self.content)
    336         return '\n'.join(['%s: %s' % (key, value)
    337             for key, value in self.headers.headeritems()]) \
    338             + '\n\n' + content
    339 
    340     def __call__(self, environ, start_response):
    341         """Convenience call to return output and set status information
    342 
    343         Conforms to the WSGI interface for calling purposes only.
    344 
    345         Example usage:
    346 
    347         .. code-block:: python
    348 
    349             def wsgi_app(environ, start_response):
    350                 response = WSGIResponse()
    351                 response.write("Hello world")
    352                 response.headers['Content-Type'] = 'latin1'
    353                 return response(environ, start_response)
    354 
    355         """
    356         status_text = STATUS_CODE_TEXT[self.status_code]
    357         status = '%s %s' % (self.status_code, status_text)
    358         response_headers = self.headers.headeritems()
    359         for c in self.cookies.values():
    360             response_headers.append(('Set-Cookie', c.output(header='')))
    361         start_response(status, response_headers)
    362         is_file = isinstance(self.content, file)
    363         if 'wsgi.file_wrapper' in environ and is_file:
    364             return environ['wsgi.file_wrapper'](self.content)
    365         elif is_file:
    366             return iter(lambda: self.content.read(), '')
    367         return self.get_content()
    368 
    369     def determine_charset(self):
    370         """
    371         Determine the encoding as specified by the Content-Type's charset
    372         parameter, if one is set
    373         """
    374         charset_match = _CHARSET_RE.search(self.headers.get('Content-Type', ''))
    375         if charset_match:
    376             return charset_match.group(1)
    377 
    378     def has_header(self, header):
    379         """
    380         Case-insensitive check for a header
    381         """
    382         warnings.warn('WSGIResponse.has_header is deprecated, use '
    383                       'WSGIResponse.headers.has_key instead', DeprecationWarning,
    384                       2)
    385         return self.headers.has_key(header)
    386 
    387     def set_cookie(self, key, value='', max_age=None, expires=None, path='/',
    388                    domain=None, secure=None, httponly=None):
    389         """
    390         Define a cookie to be sent via the outgoing HTTP headers
    391         """
    392         self.cookies[key] = value
    393         for var_name, var_value in [
    394             ('max_age', max_age), ('path', path), ('domain', domain),
    395             ('secure', secure), ('expires', expires), ('httponly', httponly)]:
    396             if var_value is not None and var_value is not False:
    397                 self.cookies[key][var_name.replace('_', '-')] = var_value
    398 
    399     def delete_cookie(self, key, path='/', domain=None):
    400         """
    401         Notify the browser the specified cookie has expired and should be
    402         deleted (via the outgoing HTTP headers)
    403         """
    404         self.cookies[key] = ''
    405         if path is not None:
    406             self.cookies[key]['path'] = path
    407         if domain is not None:
    408             self.cookies[key]['domain'] = domain
    409         self.cookies[key]['expires'] = 0
    410         self.cookies[key]['max-age'] = 0
    411 
    412     def _set_content(self, content):
    413         if not isinstance(content, (six.binary_type, six.text_type)):
    414             self._iter = content
    415             if isinstance(content, list):
    416                 self._is_str_iter = True
    417             else:
    418                 self._is_str_iter = False
    419         else:
    420             self._iter = [content]
    421             self._is_str_iter = True
    422     content = property(lambda self: self._iter, _set_content,
    423                        doc='Get/set the specified content, where content can '
    424                        'be: a string, a list of strings, a generator function '
    425                        'that yields strings, or an iterable object that '
    426                        'produces strings.')
    427 
    428     def get_content(self):
    429         """
    430         Returns the content as an iterable of strings, encoding each element of
    431         the iterator from a Unicode object if necessary.
    432         """
    433         charset = self.determine_charset()
    434         if charset:
    435             return encode_unicode_app_iter(self.content, charset, self.errors)
    436         else:
    437             return self.content
    438 
    439     def wsgi_response(self):
    440         """
    441         Return this WSGIResponse as a tuple of WSGI formatted data, including:
    442         (status, headers, iterable)
    443         """
    444         status_text = STATUS_CODE_TEXT[self.status_code]
    445         status = '%s %s' % (self.status_code, status_text)
    446         response_headers = self.headers.headeritems()
    447         for c in self.cookies.values():
    448             response_headers.append(('Set-Cookie', c.output(header='')))
    449         return status, response_headers, self.get_content()
    450 
    451     # The remaining methods partially implement the file-like object interface.
    452     # See http://docs.python.org/lib/bltin-file-objects.html
    453     def write(self, content):
    454         if not self._is_str_iter:
    455             raise IOError("This %s instance's content is not writable: (content "
    456                 'is an iterator)' % self.__class__.__name__)
    457         self.content.append(content)
    458 
    459     def flush(self):
    460         pass
    461 
    462     def tell(self):
    463         if not self._is_str_iter:
    464             raise IOError('This %s instance cannot tell its position: (content '
    465                 'is an iterator)' % self.__class__.__name__)
    466         return sum([len(chunk) for chunk in self._iter])
    467 
    468     ########################################
    469     ## Content-type and charset
    470 
    471     def charset__get(self):
    472         """
    473         Get/set the charset (in the Content-Type)
    474         """
    475         header = self.headers.get('content-type')
    476         if not header:
    477             return None
    478         match = _CHARSET_RE.search(header)
    479         if match:
    480             return match.group(1)
    481         return None
    482 
    483     def charset__set(self, charset):
    484         if charset is None:
    485             del self.charset
    486             return
    487         try:
    488             header = self.headers.pop('content-type')
    489         except KeyError:
    490             raise AttributeError(
    491                 "You cannot set the charset when no content-type is defined")
    492         match = _CHARSET_RE.search(header)
    493         if match:
    494             header = header[:match.start()] + header[match.end():]
    495         header += '; charset=%s' % charset
    496         self.headers['content-type'] = header
    497 
    498     def charset__del(self):
    499         try:
    500             header = self.headers.pop('content-type')
    501         except KeyError:
    502             # Don't need to remove anything
    503             return
    504         match = _CHARSET_RE.search(header)
    505         if match:
    506             header = header[:match.start()] + header[match.end():]
    507         self.headers['content-type'] = header
    508 
    509     charset = property(charset__get, charset__set, charset__del, doc=charset__get.__doc__)
    510 
    511     def content_type__get(self):
    512         """
    513         Get/set the Content-Type header (or None), *without* the
    514         charset or any parameters.
    515 
    516         If you include parameters (or ``;`` at all) when setting the
    517         content_type, any existing parameters will be deleted;
    518         otherwise they will be preserved.
    519         """
    520         header = self.headers.get('content-type')
    521         if not header:
    522             return None
    523         return header.split(';', 1)[0]
    524 
    525     def content_type__set(self, value):
    526         if ';' not in value:
    527             header = self.headers.get('content-type', '')
    528             if ';' in header:
    529                 params = header.split(';', 1)[1]
    530                 value += ';' + params
    531         self.headers['content-type'] = value
    532 
    533     def content_type__del(self):
    534         try:
    535             del self.headers['content-type']
    536         except KeyError:
    537             pass
    538 
    539     content_type = property(content_type__get, content_type__set,
    540                             content_type__del, doc=content_type__get.__doc__)
    541 
    542 ## @@ I'd love to remove this, but paste.httpexceptions.get_exception
    543 ##    doesn't seem to work...
    544 # See http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html
    545 STATUS_CODE_TEXT = {
    546     100: 'CONTINUE',
    547     101: 'SWITCHING PROTOCOLS',
    548     200: 'OK',
    549     201: 'CREATED',
    550     202: 'ACCEPTED',
    551     203: 'NON-AUTHORITATIVE INFORMATION',
    552     204: 'NO CONTENT',
    553     205: 'RESET CONTENT',
    554     206: 'PARTIAL CONTENT',
    555     226: 'IM USED',
    556     300: 'MULTIPLE CHOICES',
    557     301: 'MOVED PERMANENTLY',
    558     302: 'FOUND',
    559     303: 'SEE OTHER',
    560     304: 'NOT MODIFIED',
    561     305: 'USE PROXY',
    562     306: 'RESERVED',
    563     307: 'TEMPORARY REDIRECT',
    564     400: 'BAD REQUEST',
    565     401: 'UNAUTHORIZED',
    566     402: 'PAYMENT REQUIRED',
    567     403: 'FORBIDDEN',
    568     404: 'NOT FOUND',
    569     405: 'METHOD NOT ALLOWED',
    570     406: 'NOT ACCEPTABLE',
    571     407: 'PROXY AUTHENTICATION REQUIRED',
    572     408: 'REQUEST TIMEOUT',
    573     409: 'CONFLICT',
    574     410: 'GONE',
    575     411: 'LENGTH REQUIRED',
    576     412: 'PRECONDITION FAILED',
    577     413: 'REQUEST ENTITY TOO LARGE',
    578     414: 'REQUEST-URI TOO LONG',
    579     415: 'UNSUPPORTED MEDIA TYPE',
    580     416: 'REQUESTED RANGE NOT SATISFIABLE',
    581     417: 'EXPECTATION FAILED',
    582     429: 'TOO MANY REQUESTS',
    583     500: 'INTERNAL SERVER ERROR',
    584     501: 'NOT IMPLEMENTED',
    585     502: 'BAD GATEWAY',
    586     503: 'SERVICE UNAVAILABLE',
    587     504: 'GATEWAY TIMEOUT',
    588     505: 'HTTP VERSION NOT SUPPORTED',
    589 }
    590