1 WebOb File-Serving Example 2 ========================== 3 4 This document shows how you can make a static-file-serving application 5 using WebOb. We'll quickly build this up from minimal functionality 6 to 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 23 First we'll setup a really simple shim around our application, which 24 we 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 42 Now we can make different definitions of ``make_response``. The 43 simplest 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 52 Let's give it a go. We'll test it out with a file ``test-file.txt`` 53 in 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 69 Well, that worked. But it's not a very fancy object. First, it reads 70 everything 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 98 And 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 110 Well, that doesn't *look* different, but lets *imagine* that it's 111 different because we know we changed some code. Now to add some basic 112 metadata 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 126 Now, 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 150 We can even do Range requests, but it will currently involve iterating 151 through the file unnecessarily. When there's a range request (and you 152 set ``conditional_response=True``) the application will satisfy that 153 request. But with an arbitrary iterator the only way to do that is to 154 run through the beginning of the iterator until you get to the chunk 155 that 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 158 So we'll add an extra method, ``app_iter_range``, that ``Response`` 159 looks 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 199 Now 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