""" open/DurusWorks/qp/http/response.py """ from durus.utils import join_bytes, as_bytes from qp.lib.spec import unicode_string import struct import time import zlib import sys if sys.version < "3": from rfc822 import formatdate else: from email.utils import formatdate status_reasons = { 100: 'Continue', 101: 'Switching Protocols', 102: 'Processing', 200: 'OK', 201: 'Created', 202: 'Accepted', 203: 'Non-Authoritative Information', 204: 'No Content', 205: 'Reset Content', 206: 'Partial Content', 207: 'Multi-Status', 300: 'Multiple Choices', 301: 'Moved Permanently', 302: 'Moved Temporarily', 303: 'See Other', 304: 'Not Modified', 305: 'Use Proxy', 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 Time-out', 409: 'Conflict', 410: 'Gone', 411: 'Length Required', 412: 'Precondition Failed', 413: 'Request Entity Too Large', 414: 'Request-URI Too Large', 415: 'Unsupported Media Type', 416: 'Requested range not satisfiable', 417: 'Expectation Failed', 422: 'Unprocessable Entity', 423: 'Locked', 424: 'Failed Dependency', 500: 'Internal Server Error', 501: 'Not Implemented', 502: 'Bad Gateway', 503: 'Service Unavailable', 504: 'Gateway Time-out', 505: 'HTTP Version not supported', 507: 'Insufficient Storage', } _GZIP_HEADER = as_bytes("\037\213" # magic "\010" # compression method "\000" # flags "\000\000\000\000" # time, who cares? "\002" "\377") _GZIP_EXCLUDE = set(["application/pdf", "application/zip", "audio/mpeg", "image/gif", "image/jpeg", "image/png", "video/mpeg", "video/quicktime", "video/x-msvideo", ]) class HTTPResponse (object): """ An object representation of an HTTP response. The Response type encapsulates all possible responses to HTTP requests. Instance attributes: content_type : (mime_type:str, charset:str) status : (status_code:int, reason_phrase:str) headers : { string : string } most of the headers included with the response; every header set by 'set_header()' goes here. Does not include "Status" or "Set-Cookie" headers (unless someone uses set_header() to set them, but that would be foolish). body : str | Stream the response body, None by default. Note that if the body is not a stream then it is already encoded using 'charset'. buffered : bool if false, response data will be flushed as soon as it is written (the default is true). This is most useful for responses that use the Stream() protocol. Note that whether the client actually receives the partial response data is highly dependent on the web server cookies : { name:string : { attrname : value } } collection of cookies to set in this response; it is expected that the user-agent will remember the cookies and send them on future requests. The cookie value is stored as the "value" attribute. The other attributes are as specified by RFC 2109. cache : int | None the number of seconds the response may be cached. The default is 0, meaning don't cache at all. This variable is used to set the HTTP expires header. If set to None then the expires header will not be added. compress : bool should the body of the response be compressed? range : None | (int|None, int|None) """ def __init__(self): self.set_content_type('text/html', 'utf-8') self.set_status(200) self.set_compress(False) self.set_expires(0) self.set_buffered(True) self.headers = {} self.cookies = {} self.body = None self.range = None def set_content_type(self, mime_type, charset): """(mime_type:str, charset:str) """ self.content_type = (mime_type, charset) def get_mime_type(self): return self.content_type[0] def get_charset(self): return self.content_type[1] def set_compress(self, value): self.compress = value def get_compress(self): return self.compress def set_buffered(self, value): self.buffered = value def get_buffered(self): return self.buffered def set_range(self, range): self.range = range if range is not None: self.set_status(206) def get_range(self): return self.range def set_status(self, status_code, reason=None): """(status_code : int, reason : string = None) Sets the HTTP status code of the response. 'status_code' must be an integer in the range 100 .. 599. 'reason' must be a string; if not supplied, the default reason phrase for 'status_code' will be used. If 'status_code' is a non-standard status code, the generic reason phrase for its group of status codes will be used; eg. if status == 493, the reason for status 400 will be used. """ if not isinstance(status_code, int): raise TypeError("status_code must be an integer") if not (100 <= status_code <= 599): raise ValueError("status_code must be between 100 and 599") self.status = (status_code, (reason or status_reasons.get(status_code) or status_reasons.get(status_code - (status_code % 100)))) def get_status(self): return self.status def get_status_code(self): return self.status[0] def set_header(self, name, value): """(name : string, value : string)""" self.headers[name] = value def get_header(self, name, default=None): """(name : string, default=None) -> value : string Gets an HTTP return header "name". If none exists then 'default' is returned. """ lower_name = name.lower() for header in self.headers: if header.lower() == lower_name: return self.headers[header] return default def set_expires(self, seconds=0, minutes=0, hours=0, days=0): if seconds is None: self.cache = None # don't generate 'Expires' header else: self.cache = seconds + 60*(minutes + 60*(hours + 24*days)) def _compress_body(self, body): """(body: str) -> str Try to compress the body using gzip and return either the original body or the compressed body. If the compressed body is returned, the content-encoding header is set to 'gzip'. """ if not self.get_compress() or self.get_mime_type() in _GZIP_EXCLUDE: return body n = len(body) co = zlib.compressobj(6, zlib.DEFLATED, -zlib.MAX_WBITS, zlib.DEF_MEM_LEVEL, 0) chunks = [_GZIP_HEADER, co.compress(body), co.flush(), struct.pack(" 1.0: self.set_header("Content-Encoding", "gzip") return compressed_body else: return body def _encode_chunk(self, chunk): """(chunk:str|unicode_string) Returns the chunk encoded using the charset of the response. If the chunk is a str, it is assumed to be encoded already using the charset of the response. """ if isinstance(chunk, unicode_string): return chunk.encode(self.get_charset()) else: return chunk def set_body(self, body): """(body : Stream|unicode_string|byte_string) Sets the response body equal to the argument 'body'. If the argument is a str, or a Stream that generates str instances, it is the caller's responsibility to make sure that these str instances use the charset of the response. """ if isinstance(body, Stream): self.body = body self.body.set_range(self.range) else: encoded = self._encode_chunk(body) self.body = self._compress_body(encoded) def expire_cookie(self, name, **attrs): """ Cause an HTTP cookie to be removed from the browser The response will include an HTTP header that will remove the cookie corresponding to "name" on the client, if one exists. This is accomplished by sending a new cookie with an expiration date that has already passed. Note that some clients require a path to be specified - this path must exactly match the path given when creating the cookie. The path can be specified as a keyword argument. """ params = {'max_age': 0, 'expires': 'Thu, 01-Jan-1970 00:00:00 GMT'} params.update(attrs) self.set_cookie(name, "deleted", **params) def set_cookie(self, name, value, **attrs): """(name : string, value : string, **attrs) Set an HTTP cookie on the browser. The response will include an HTTP header that sets a cookie on cookie-enabled browsers with a key "name" and value "value". Cookie attributes such as "expires" and "domains" may be supplied as keyword arguments; see RFC 2109 for a full list. (For the "secure" attribute, use any true value.) This overrides any previous value for this cookie. Any previously-set attributes for the cookie are preserved, unless they are explicitly overridden with keyword arguments to this call. """ cookies = self.cookies if name in cookies: cookie = cookies[name] else: cookie = cookies[name] = {} cookie.update(attrs) cookie['value'] = value def get_content_length(self): if self.body is None: return None if isinstance(self.body, Stream): return self.body.get_length() # self.body is a string. length = len(self.body) if self.range is None: return length first_byte, last_byte = self.range if last_byte is None: return length - 1 elif first_byte is None: return min(length - 1, last_byte) else: return last_byte - first_byte + 1 def _gen_cookie_headers(self): """() -> [(str, str)] """ for name, attrs in self.cookies.items(): value = str(attrs['value']) if '"' in value: value = value.replace('"', '\\"') chunks = ['%s="%s"' % (name, value)] for name, val in attrs.items(): name = name.lower() if val is None: continue if name in ('expires', 'domain', 'path', 'max_age', 'comment'): name = name.replace('_', '-') chunks.append('%s=%s' % (name, val)) elif name == 'secure' and val: chunks.append("secure") yield ("Set-Cookie", '; '.join(chunks)) def generate_headers(self): """() -> [(name:string, value:string)] Generate a list of headers to be returned as part of the response. """ for name, value in self.headers.items(): yield (as_bytes(name.title()), as_bytes(value)) yield (as_bytes("Accept-Ranges"), as_bytes("bytes")) for name, value in self._gen_cookie_headers(): yield as_bytes(name), as_bytes(value) # Date header now = time.time() if "date" not in self.headers: yield (as_bytes("Date"), as_bytes(formatdate(now))) # Cache directives if self.cache is None: pass # don't mess with the expires header elif "expires" not in self.headers: if self.cache > 0: expire_date = formatdate(now + self.cache) else: expire_date = "-1" # allowed by HTTP spec and may work better # with some clients yield (as_bytes("Expires"), as_bytes(expire_date)) # Content-type if "content-type" not in self.headers: mime_type, charset = self.content_type value = mime_type if mime_type: if charset: value += '; charset=%s' % charset yield (as_bytes('Content-Type'), as_bytes(value)) # Content-Length if "content-length" not in self.headers: length = self.get_content_length() if length is not None: yield (as_bytes('Content-Length'), as_bytes(str(length))) # Content-Range if self.range is not None and self.get_status_code() == 206 and "content-range" not in self.headers: first_byte, last_byte = self.range if isinstance(self.body, Stream): length = self.body.get_real_length() else: length = len(self.body) if length is None: length = "*" elif last_byte is None or last_byte >= length: last_byte = length - 1 elif first_byte is None: first_byte = length - last_byte last_byte = length - 1 yield (as_bytes('Content-Range'), as_bytes("bytes %d-%d/%s" % (first_byte, last_byte, length))) def generate_body_chunks(self): """ Return a sequence of body chunks, encoded using 'charset'. Note that the chunks in the iteration of a Stream, if they are str instances, are assumed to be encoded already. """ if self.body is None: pass elif isinstance(self.body, Stream): for chunk in self.body: yield self._encode_chunk(chunk) else: if self.range is None: yield self.body # already encoded by set_body(). else: first_byte, last_byte = self.range length = len(self.body) if last_byte is None or last_byte >= length: last_byte = length - 1 if first_byte is None: first_byte = length - last_byte yield self.body[first_byte:last_byte + 1] def write(self, output, include_status=True, include_body=True): """(output:file, include_status:bool=True, include_body:bool=True) Write the HTTP response headers and body to 'output'. This is not a complete HTTP response, as it doesn't start with a response status line as specified by RFC 2616. By default, it does start with a "Status" header as described by the CGI spec. It is expected that this response is parsed by the web server and turned into a complete HTTP response. If include_body is False, only the headers are written to 'output'. This is used to support HTTP HEAD requests. """ flush_output = not self.get_buffered() and hasattr(output, 'flush') if include_status: # "Status" header must come first. output.write(as_bytes("Status: %03d %s\r\n" % self.status)) colon = as_bytes(": ") end = as_bytes("\r\n") for name, value in self.generate_headers(): output.write(name + colon + value + end) output.write(as_bytes("\r\n")) if flush_output: output.flush() if not include_body: return for chunk in self.generate_body_chunks(): output.write(chunk) if flush_output: output.flush() if flush_output: output.flush() class Stream (object): """ A wrapper around response data that can be streamed. The 'iterable' argument must support the iteration protocol. Beware that exceptions raised while writing the stream will not be handled gracefully. Instance attributes: iterable : any an object that supports the iteration protocol. The items produced by the stream must be be unicode_string or byte_string instances. If they are byte_string instances, the encoding is assumed to be that of the response. length: int | None the number of bytes that will be produced by the stream, None if it is not known. Used to set the Content-Length header. range: None | (int|None, int|None) """ def __init__(self, iterable, length=None): self.iterable = iterable self.length = length self.range = None def __iter__(self): if self.range is None: return iter(self.iterable) else: return self.iter_range() def get_length(self): if self.range is None: return self.length first_byte, last_byte = self.range if self.length is None: if first_byte is None or last_byte is None: raise ValueError("cannot satisfy open-ended range request with stream that has unknown length") else: if last_byte is None or last_byte >= self.length: last_byte = self.length - 1 if first_byte is None: first_byte = self.length - last_byte return last_byte - first_byte + 1 def iter_range(self): byte_position = 0 first_byte, last_byte = self.range length = self.length if (first_byte is None or last_byte is None) and length is None: raise ValueError("cannot satisfy open-ended range request with stream that has unknown length") if first_byte is None: first_byte = length - last_byte last_byte = length - 1 if first_byte is None or last_byte is None or (length is not None and last_byte >= length): last_byte = length - 1 for chunk in self.iterable: if first_byte < byte_position + len(chunk): # Case: this chunk contains some amount of the # requested range. if first_byte <= byte_position: # Case: this chunk starts in the middle of the # requested range. chunk_first_byte = 0 else: # Case: this range starts in the middle of the # chunk. chunk_first_byte = first_byte - byte_position else: # Case: no part of this chunk is called for because we # aren't far enough into the stream yet. byte_position += len(chunk) continue if last_byte >= byte_position: # Case: this chunk contains some amount of the # requested range. if last_byte >= byte_position + len(chunk): # Case: this chunk ends in the middle of the # requested range. chunk_last_byte = byte_position + len(chunk) - 1 else: # Case: this range ends in the middle of the # chunk. chunk_last_byte = last_byte - byte_position else: # Case: this range starts before this chunk - note # can this case really happen? byte_position += len(chunk) continue yield chunk[chunk_first_byte:chunk_last_byte + 1] if last_byte < byte_position + len(chunk): # we've consumed all we need to - ditch the rest return byte_position += len(chunk) def get_real_length(self): return self.length def set_range(self, range): self.range = range