1WebOb File-Serving Example 2========================== 3 4This document shows how you can make a static-file-serving application 5using WebOb. We'll quickly build this up from minimal functionality 6to a high-quality file serving application. 7 8.. note:: Starting from 1.2b4, WebOb ships with a :mod:`webob.static` module 9 which implements a :class:`webob.static.FileApp` WSGI application similar to the 10 one described below. 11 12 This document stays as a didactic example how to serve files with WebOb, but 13 you should consider using applications from :mod:`webob.static` in 14 production. 15 16.. comment: 17 18 >>> import webob, os 19 >>> base_dir = os.path.dirname(os.path.dirname(webob.__file__)) 20 >>> doc_dir = os.path.join(base_dir, 'docs') 21 >>> from doctest import ELLIPSIS 22 23First we'll setup a really simple shim around our application, which 24we can use as we improve our application: 25 26.. code-block:: python 27 28 >>> from webob import Request, Response 29 >>> import os 30 >>> class FileApp(object): 31 ... def __init__(self, filename): 32 ... self.filename = filename 33 ... def __call__(self, environ, start_response): 34 ... res = make_response(self.filename) 35 ... return res(environ, start_response) 36 >>> import mimetypes 37 >>> def get_mimetype(filename): 38 ... type, encoding = mimetypes.guess_type(filename) 39 ... # We'll ignore encoding, even though we shouldn't really 40 ... return type or 'application/octet-stream' 41 42Now we can make different definitions of ``make_response``. The 43simplest version: 44 45.. code-block:: python 46 47 >>> def make_response(filename): 48 ... res = Response(content_type=get_mimetype(filename)) 49 ... res.body = open(filename, 'rb').read() 50 ... return res 51 52Let's give it a go. We'll test it out with a file ``test-file.txt`` 53in the WebOb doc directory: 54 55.. code-block:: python 56 57 >>> fn = os.path.join(doc_dir, 'test-file.txt') 58 >>> open(fn).read() 59 'This is a test. Hello test people!' 60 >>> app = FileApp(fn) 61 >>> req = Request.blank('/') 62 >>> print req.get_response(app) 63 200 OK 64 Content-Type: text/plain; charset=UTF-8 65 Content-Length: 35 66 <BLANKLINE> 67 This is a test. Hello test people! 68 69Well, that worked. But it's not a very fancy object. First, it reads 70everything into memory, and that's bad. We'll create an iterator instead: 71 72.. code-block:: python 73 74 >>> class FileIterable(object): 75 ... def __init__(self, filename): 76 ... self.filename = filename 77 ... def __iter__(self): 78 ... return FileIterator(self.filename) 79 >>> class FileIterator(object): 80 ... chunk_size = 4096 81 ... def __init__(self, filename): 82 ... self.filename = filename 83 ... self.fileobj = open(self.filename, 'rb') 84 ... def __iter__(self): 85 ... return self 86 ... def next(self): 87 ... chunk = self.fileobj.read(self.chunk_size) 88 ... if not chunk: 89 ... raise StopIteration 90 ... return chunk 91 ... __next__ = next # py3 compat 92 >>> def make_response(filename): 93 ... res = Response(content_type=get_mimetype(filename)) 94 ... res.app_iter = FileIterable(filename) 95 ... res.content_length = os.path.getsize(filename) 96 ... return res 97 98And testing: 99 100.. code-block:: python 101 102 >>> req = Request.blank('/') 103 >>> print req.get_response(app) 104 200 OK 105 Content-Type: text/plain; charset=UTF-8 106 Content-Length: 35 107 <BLANKLINE> 108 This is a test. Hello test people! 109 110Well, that doesn't *look* different, but lets *imagine* that it's 111different because we know we changed some code. Now to add some basic 112metadata to the response: 113 114.. code-block:: python 115 116 >>> def make_response(filename): 117 ... res = Response(content_type=get_mimetype(filename), 118 ... conditional_response=True) 119 ... res.app_iter = FileIterable(filename) 120 ... res.content_length = os.path.getsize(filename) 121 ... res.last_modified = os.path.getmtime(filename) 122 ... res.etag = '%s-%s-%s' % (os.path.getmtime(filename), 123 ... os.path.getsize(filename), hash(filename)) 124 ... return res 125 126Now, with ``conditional_response`` on, and with ``last_modified`` and 127``etag`` set, we can do conditional requests: 128 129.. code-block:: python 130 131 >>> req = Request.blank('/') 132 >>> res = req.get_response(app) 133 >>> print res 134 200 OK 135 Content-Type: text/plain; charset=UTF-8 136 Content-Length: 35 137 Last-Modified: ... GMT 138 ETag: ...-... 139 <BLANKLINE> 140 This is a test. Hello test people! 141 >>> req2 = Request.blank('/') 142 >>> req2.if_none_match = res.etag 143 >>> req2.get_response(app) 144 <Response ... 304 Not Modified> 145 >>> req3 = Request.blank('/') 146 >>> req3.if_modified_since = res.last_modified 147 >>> req3.get_response(app) 148 <Response ... 304 Not Modified> 149 150We can even do Range requests, but it will currently involve iterating 151through the file unnecessarily. When there's a range request (and you 152set ``conditional_response=True``) the application will satisfy that 153request. But with an arbitrary iterator the only way to do that is to 154run through the beginning of the iterator until you get to the chunk 155that the client asked for. We can do better because we can use 156``fileobj.seek(pos)`` to move around the file much more efficiently. 157 158So we'll add an extra method, ``app_iter_range``, that ``Response`` 159looks for: 160 161.. code-block:: python 162 163 >>> class FileIterable(object): 164 ... def __init__(self, filename, start=None, stop=None): 165 ... self.filename = filename 166 ... self.start = start 167 ... self.stop = stop 168 ... def __iter__(self): 169 ... return FileIterator(self.filename, self.start, self.stop) 170 ... def app_iter_range(self, start, stop): 171 ... return self.__class__(self.filename, start, stop) 172 >>> class FileIterator(object): 173 ... chunk_size = 4096 174 ... def __init__(self, filename, start, stop): 175 ... self.filename = filename 176 ... self.fileobj = open(self.filename, 'rb') 177 ... if start: 178 ... self.fileobj.seek(start) 179 ... if stop is not None: 180 ... self.length = stop - start 181 ... else: 182 ... self.length = None 183 ... def __iter__(self): 184 ... return self 185 ... def next(self): 186 ... if self.length is not None and self.length <= 0: 187 ... raise StopIteration 188 ... chunk = self.fileobj.read(self.chunk_size) 189 ... if not chunk: 190 ... raise StopIteration 191 ... if self.length is not None: 192 ... self.length -= len(chunk) 193 ... if self.length < 0: 194 ... # Chop off the extra: 195 ... chunk = chunk[:self.length] 196 ... return chunk 197 ... __next__ = next # py3 compat 198 199Now we'll test it out: 200 201.. code-block:: python 202 203 >>> req = Request.blank('/') 204 >>> res = req.get_response(app) 205 >>> req2 = Request.blank('/') 206 >>> # Re-fetch the first 5 bytes: 207 >>> req2.range = (0, 5) 208 >>> res2 = req2.get_response(app) 209 >>> res2 210 <Response ... 206 Partial Content> 211 >>> # Let's check it's our custom class: 212 >>> res2.app_iter 213 <FileIterable object at ...> 214 >>> res2.body 215 'This ' 216 >>> # Now, conditional range support: 217 >>> req3 = Request.blank('/') 218 >>> req3.if_range = res.etag 219 >>> req3.range = (0, 5) 220 >>> req3.get_response(app) 221 <Response ... 206 Partial Content> 222 >>> req3.if_range = 'invalid-etag' 223 >>> req3.get_response(app) 224 <Response ... 200 OK> 225