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