durusmail: quixote-users: response streams [Was: Medusa producers]
Medusa producers
2003-08-21
Re: Medusa producers
2003-08-21
2003-08-21
2003-08-21
2003-08-21
2003-08-22
2003-08-22
2003-08-22
2003-08-22
2003-08-22
2003-08-22
response streams [Was: Medusa producers]
response streams
2003-08-27
Re: response streams [Was: Medusa producers]
2003-08-28
response streams [Was: Medusa producers]
Neil Schemenauer
2003-08-26
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:

reply