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
5The WSGIRequest and WSGIResponse objects are light wrappers to make it easier
6to deal with an incoming request and sending a response.
7"""
8import re
9import warnings
10from pprint import pformat
11try:
12    # Python 3
13    from http.cookies import SimpleCookie
14except ImportError:
15    # Python 2
16    from Cookie import SimpleCookie
17import six
18
19from paste.request import EnvironHeaders, get_cookie_dict, \
20    parse_dict_querystring, parse_formvars
21from paste.util.multidict import MultiDict, UnicodeMultiDict
22from paste.registry import StackedObjectProxy
23from paste.response import HeaderDict
24from paste.wsgilib import encode_unicode_app_iter
25from paste.httpheaders import ACCEPT_LANGUAGE
26from paste.util.mimeparse import desired_matches
27
28__all__ = ['WSGIRequest', 'WSGIResponse']
29
30_CHARSET_RE = re.compile(r';\s*charset=([^;]*)', re.I)
31
32class 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
41settings = DeprecatedSettings(default=dict())
42
43class 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
64class 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
295class 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
545STATUS_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