Attached is a patch that implements my proposal. I didn't test the medusa and Twisted servers but I think the general idea is workable. Oleg and Graham, what do you think? Neil Index: http_response.py =================================================================== --- http_response.py (revision 22335) +++ http_response.py (working copy) @@ -190,18 +190,20 @@ self.headers["content-type"] = ctype def set_body (self, body): - """set_body(body : string) + """set_body(body : any) - Sets the return body equal to the (string) argument "body". Also - updates the "Content-length" return header. If the - "Content-type" header has not yet been set, it is set to + Sets the return body equal to the argument "body". Also updates the + "Content-length" header if the length is of the body is known. If + the "Content-type" header has not yet been set, it is set to "text/html". """ - if type(body) is not StringType: - raise TypeError("'body' must be a string") - - self.body = body - self.set_header('content-length', len(body)) + if isinstance(body, Stream): + self.body = body + if body.length is not None: + self.set_header('content-length', body.length) + else: + self.body = str(body) + self.set_header('content-length', len(self.body)) if not self.headers.has_key('content-type'): self.set_header('content-type', 'text/html; charset=iso-8859-1') @@ -317,13 +319,49 @@ elif name == 'secure' and val: chunks.append("secure") - cookie = "Set-Cookie: " + ("; ".join(chunks)) - cookie_list.append(cookie) + cookie_list.append(("Set-Cookie", ("; ".join(chunks)))) # Should really check size of cookies here! return cookie_list + def generate_headers (self): + """generate_headers() -> [(name:string, value:string)] + + Generate a list of headers to be returned as part of the response. + """ + headers = [] + + # "Status" header must come first. + headers.append(("Status", "%03d %s" % (self.status_code, + self.reason_phrase))) + + for name, value in self.headers.items(): + headers.append((name.title(), value)) + + # All the "Set-Cookie" headers. + if self.cookies: + headers.extend(self._gen_cookie_headers()) + + # Date header + now = time.time() + if not self.headers.has_key("date"): + headers.append(("Date", formatdate(now))) + + # Cache directives + if self.cache is None: + pass # don't mess with the expires header + elif not self.headers.has_key("expires"): + 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 + headers.append(("Expires", expire_date)) + + return headers + + def write (self, file): """write(file : file) @@ -334,7 +372,6 @@ is expected that this response is parsed by the web server and turned into a complete HTTP response. """ - # XXX currently we write a response like this: # Status: 200 OK # Content-type: text/html; charset=iso-8859-1 @@ -352,41 +389,36 @@ # "non-parsed header" mode, where the CGI script is responsible # for generating a complete HTTP response with no help from the # server. + for name, value in self.generate_headers(): + file.write("%s: %s\r\n" % (name, value)) + file.write("\r\n") + if self.body is not None: + if type(self.body) is StringType: + file.write(self.body) + else: + # it's a stream + for chunk in self.body: + file.write(chunk) - headers = self.headers - # "Status" header must come first. - file.write("Status: %03d %s\r\n" % (self.status_code, - self.reason_phrase)) +class Stream: + """ + A wrapper around response data that can be streamed. The 'iterable' + argument must support the iteration protocol. Items returned by 'next()' + must be strings. Beware that exceptions raised while writing the stream + will not be handled gracefully. - # Now the bulk of the headers (anything set with 'set_header()', - # most likely "Content-type" and "Content-length"). - for (key, val) in headers.items(): - file.write("%s: %s\r\n" % (key.title(), val)) + Instance attributes: + iterable : any + an object that supports the iteration protocol. The items produced + by the stream must be strings. + 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. + """ + def __init__(self, iterable, length=None): + self.iterable = iterable + self.length = length - # All the "Set-Cookie" headers. - if self.cookies: - for hdr in self._gen_cookie_headers(): - file.write(hdr + "\r\n") - - # Date header - now = time.time() - if not headers.has_key("date"): - file.write("Date: %s\r\n" % formatdate(now)) - - # Cache directives - if self.cache is None: - pass # don't mess with the expires header - elif not headers.has_key("expires"): - 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 - file.write("Expires: %s\r\n" % expire_date) - - - # And the response body. - file.write("\r\n") - if self.body is not None: - file.write(self.body) + def __iter__(self): + return iter(self.iterable) Index: util.py =================================================================== --- util.py (revision 22335) +++ util.py (working copy) @@ -20,6 +20,7 @@ import sys, xmlrpclib import os, mimetypes, urllib from quixote import errors, html +from quixote.http_response import Stream from cStringIO import StringIO from rfc822 import formatdate @@ -59,6 +60,24 @@ return result +class FileStream(Stream): + + CHUNK_SIZE = 20000 + + def __init__(self, fp, size=None): + self.fp = fp + self.length = size + + def __iter__(self): + return self + + def next(self): + chunk = self.fp.read(self.CHUNK_SIZE) + if not chunk: + raise StopIteration + return chunk + + class StaticFile: """ @@ -99,7 +118,8 @@ self.cache_time = cache_time def __call__(self, request): - last_modified = formatdate(os.stat(self.path).st_mtime) + stat = os.stat(self.path) + last_modified = formatdate(stat.st_mtime) if last_modified == request.get_header('If-Modified-Since'): # handle exact match of If-Modified-Since header request.response.set_status(304) @@ -109,9 +129,6 @@ request.response.set_content_type(self.mime_type) if self.encoding: request.response.set_header("Content-Encoding", self.encoding) - fsfile = open(self.path, 'rb') - contents = fsfile.read() - fsfile.close() request.response.set_header('Last-Modified', last_modified) @@ -124,7 +141,7 @@ # to contact the server request.response.cache = self.cache_time - return contents + return FileStream(open(self.path, 'rb'), stat.st_size) class StaticDirectory: @@ -218,9 +235,4 @@ raise errors.TraversalError if self.use_cache: self.cache[name] = item - if isinstance(item, StaticDirectory): - return item - else: - return item(request) - - + return item Index: server/medusa_http.py =================================================================== --- server/medusa_http.py (revision 22335) +++ server/medusa_http.py (working copy) @@ -10,12 +10,25 @@ # A simple HTTP server, using Medusa, that publishes a Quixote application. import asyncore, rfc822, socket +from types import StringType from StringIO import StringIO from medusa import http_server, xmlrpc_handler from quixote.http_request import HTTPRequest from quixote.publish import Publisher from quixote.errors import PublishError + +class StreamProducer: + def __init__(self, stream): + self.iterator = iter(stream) + + def more(self): + try: + return self.iterator.next() + except StopIteration: + return '' + + class QuixoteHandler: def __init__ (self, publisher, server_name, server): """QuixoteHandler(publisher:Publisher, server_name:string, @@ -87,31 +100,25 @@ except: output = self.publisher.finish_failed_request(qreq) + qresponse = qreq.response if output: - qreq.response.set_body(str(output)) + qresponse.set_body(output) - output_file = StringIO() - qreq.response.write(output_file) - output_file.seek(0) - msg = rfc822.Message(output_file) - msg.rewindbody() - output = output_file.read() - # Copy headers from Quixote's HTTP response - for hdr in msg.keys(): - values = msg.getheaders(hdr) + for name, value in qresponse.generate_headers(): # XXX Medusa's HTTP request is buggy, and only allows unique # headers. - for v in values: - request[hdr.title()] = v + request[name] = value + + request.response(qresponse.status_code) - request.response(qreq.response.status_code) - # XXX should we set a default Last-Modified time? - request['Content-Length'] = '0' - if output: - request.push(output) - request['Content-Length'] = str(len(output)) + if qresponse.body is not None: + if type(qresponse.body) is StringType: + request.push(output) + else: + # it's a stream + request.push(StreamProducer(qresponse.body)) request.done() Index: server/twisted_http.py =================================================================== --- server/twisted_http.py (revision 22335) +++ server/twisted_http.py (working copy) @@ -15,16 +15,13 @@ # standard Quixote response headers (expires, date) +from types import StringType from twisted.internet.app import Application from twisted.protocols import http from twisted.web import server -from rfc822 import formatdate -import time - import quixote quixote.enable_ptl() -import quixote.demo from quixote import errors from quixote.publish import Publisher @@ -37,43 +34,19 @@ self.content.seek(0,0) qxrequest = self.publisher.create_request(self.content, environ) self.quixote_publish(qxrequest, environ) - self.massage_response(qxrequest) - self.write(qxrequest.response.body) - self.finish() - - - def massage_response(self, qxrequest): - """ - Does the work of reponse.write(), without actually writing, - just massages the response into a Twisted fashion. - """ resp = qxrequest.response self.setResponseCode(resp.status_code) - - for hdr, value in resp.headers.items(): + for hdr, value in resp.generate_headers(): self.setHeader(hdr, value) + if resp.body is not None: + if type(resp.body) is StringType: + self.write(resp.body) + else: + # XXX someone who understands Twisted must fix this. + raise RuntimeError, "can't handle Stream" + self.finish() - now = time.time() - if not resp.headers.has_key('date'): - resp.headers['date'] = '%s\r\n' % formatdate(now) - if not resp.headers.has_key('expires'): - if resp.cache > 0: - expire_date = formatdate(now + resp.cache) - else: - expire_date = "-1" # allowed by HTTP spec, may work - # better with some clients - resp.headers['expires'] = '%s\r\n' % expire_date - - for k, d in resp.cookies.items(): - value = d['value'] - del d['value'] - # this might be a bit risky, the addCookie signature is - # addCookie(self, k, v, expires=None, domain=None, - # path=None, max_age=None, comment=None, secure=None) - self.addCookie(k, value, **d) - - def quixote_publish(self, qxrequest, env): """ Warning, this sidesteps the Publisher.publish method, @@ -95,7 +68,7 @@ # don't write out the output, just set the response body # the calling method will do the rest. if output: - qxrequest.response.set_body(str(output)) + qxrequest.response.set_body(output) pub._clear_request() @@ -157,6 +130,7 @@ def run (): + import quixote.demo # Ports this server will listen on http_port = 8080 namespace = quixote.demo Index: publish.py =================================================================== --- publish.py (revision 22335) +++ publish.py (working copy) @@ -540,7 +540,7 @@ # Output results from Response object if output: - request.response.set_body(str(output)) + request.response.set_body(output) try: request.response.write(stdout) except IOError, exc: