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: