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