Merge lp:~jderose/degu/files-app into lp:degu
- files-app
- Merge into trunk
Proposed by
Jason Gerard DeRose
Status: | Merged |
---|---|
Merged at revision: | 475 |
Proposed branch: | lp:~jderose/degu/files-app |
Merge into: | lp:degu |
Diff against target: |
397 lines (+320/-2) 6 files modified
debian/changelog (+2/-2) degu/applib.py (+64/-0) degu/tests/helpers.py (+7/-0) degu/tests/test_applib.py (+218/-0) doc/changelog.rst (+6/-0) doc/degu.applib.rst (+23/-0) |
To merge this branch: | bzr merge lp:~jderose/degu/files-app |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
David Jordan | Approve | ||
Review via email: mp+376884@code.launchpad.net |
Commit message
Adds new FilesApp RGI application to applib.
This is a very minimal file server, but it does support both GET and HEAD requests, including with a Range header.
Description of the change
To post a comment you must log in.
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'debian/changelog' |
2 | --- debian/changelog 2017-07-14 19:51:47 +0000 |
3 | +++ debian/changelog 2019-12-17 03:38:39 +0000 |
4 | @@ -1,8 +1,8 @@ |
5 | -degu (0.19.0~alpha) xenial; urgency=low |
6 | +degu (0.19.0~alpha) focal; urgency=low |
7 | |
8 | * Upstream pre-release |
9 | |
10 | - -- Jason Gerard DeRose <jderose@novacut.com> Fri, 14 Jul 2017 13:46:47 -0600 |
11 | + -- Jason Gerard DeRose <jasonderose@gmail.com> Sat, 14 Dec 2019 21:47:14 -0700 |
12 | |
13 | degu (0.18.0) xenial; urgency=low |
14 | |
15 | |
16 | === modified file 'degu/applib.py' |
17 | --- degu/applib.py 2016-07-17 16:50:13 +0000 |
18 | +++ degu/applib.py 2019-12-17 03:38:39 +0000 |
19 | @@ -23,6 +23,9 @@ |
20 | A collection of RGI server applications for common scenarios. |
21 | """ |
22 | |
23 | +import os |
24 | +from mimetypes import guess_type |
25 | + |
26 | try: |
27 | from ._base import ( |
28 | Router, |
29 | @@ -89,3 +92,64 @@ |
30 | return self.app(session, request, api) |
31 | return (405, 'Method Not Allowed', {}, None) |
32 | |
33 | + |
34 | +class FilesApp: |
35 | + __slots__ = ('dir_name', 'dir_fd') |
36 | + |
37 | + def __init__(self, dir_name): |
38 | + self.dir_name = dir_name |
39 | + self.dir_fd = os.open(dir_name, os.O_DIRECTORY) |
40 | + |
41 | + def __repr__(self): |
42 | + return '{}({!r})'.format(self.__class__.__name__, self.dir_name) |
43 | + |
44 | + def __del__(self): |
45 | + if hasattr(self, 'dir_fd'): |
46 | + os.close(self.dir_fd) |
47 | + del self.dir_fd |
48 | + |
49 | + def __call__(self, session, request, api): |
50 | + if request.method not in {'GET', 'HEAD'}: |
51 | + return (405, 'Method Not Allowed', {}, None) |
52 | + name = (os.sep.join(request.path) if request.path else 'index.html') |
53 | + try: |
54 | + if request.method == 'GET': |
55 | + fp = open(name, 'rb', buffering=0, opener=self._opener) |
56 | + size = os.stat(fp.fileno()).st_size |
57 | + else: |
58 | + fp = None |
59 | + size = os.stat(name, dir_fd=self.dir_fd).st_size |
60 | + except FileNotFoundError: |
61 | + return (404, 'Not Found', {}, None) |
62 | + r = request.headers.get('range') |
63 | + if r is None: |
64 | + status = 200 |
65 | + reason = 'OK' |
66 | + headers = {'content-length': size} |
67 | + if fp is None: |
68 | + body = None |
69 | + else: |
70 | + body = api.Body(fp, size) |
71 | + else: |
72 | + if r.stop > size: |
73 | + return (416, 'Range Not Satisfiable', {}, None) |
74 | + length = r.stop - r.start |
75 | + status = 206 |
76 | + reason = 'Partial Content' |
77 | + headers = { |
78 | + 'content-length': length, |
79 | + 'content-range': api.ContentRange(r.start, r.stop, size), |
80 | + } |
81 | + if fp is None: |
82 | + body = None |
83 | + else: |
84 | + fp.seek(r.start) |
85 | + body = api.Body(fp, length) |
86 | + (ct, enc) = guess_type(name) |
87 | + if ct is not None: |
88 | + headers['content-type'] = ct |
89 | + return (status, reason, headers, body) |
90 | + |
91 | + def _opener(self, name, flags): |
92 | + return os.open(name, flags, dir_fd=self.dir_fd) |
93 | + |
94 | |
95 | === modified file 'degu/tests/helpers.py' |
96 | --- degu/tests/helpers.py 2016-10-16 09:07:00 +0000 |
97 | +++ degu/tests/helpers.py 2019-12-17 03:38:39 +0000 |
98 | @@ -39,6 +39,13 @@ |
99 | random = SystemRandom() |
100 | |
101 | |
102 | +def random_start_stop(total): |
103 | + assert total > 0 |
104 | + start = random.randrange(0, total) |
105 | + stop = random.randrange(start + 1, total + 1) |
106 | + return (start, stop) |
107 | + |
108 | + |
109 | def random_data(): |
110 | """ |
111 | Return random bytes between 1 and 34969 (inclusive) bytes long. |
112 | |
113 | === modified file 'degu/tests/test_applib.py' |
114 | --- degu/tests/test_applib.py 2016-09-18 10:58:29 +0000 |
115 | +++ degu/tests/test_applib.py 2019-12-17 03:38:39 +0000 |
116 | @@ -25,11 +25,15 @@ |
117 | |
118 | from unittest import TestCase |
119 | import os |
120 | +import io |
121 | from random import SystemRandom |
122 | |
123 | +from .helpers import TempDir, random_start_stop |
124 | + |
125 | from ..misc import mkreq |
126 | from ..misc import TempServer |
127 | from ..client import Client |
128 | +from ..base import api, EmptyPreambleError |
129 | from .. import applib |
130 | |
131 | |
132 | @@ -258,3 +262,217 @@ |
133 | self.assertEqual(r.body.read(), marker) |
134 | conn.close() |
135 | |
136 | + |
137 | +class TestFilesApp(TestCase): |
138 | + def test_init(self): |
139 | + tmp = TempDir() |
140 | + app = applib.FilesApp(tmp.dir) |
141 | + self.assertEqual(app.dir_name, tmp.dir) |
142 | + self.assertIsInstance(app.dir_fd, int) |
143 | + |
144 | + def test_repr(self): |
145 | + tmp = TempDir() |
146 | + app = applib.FilesApp(tmp.dir) |
147 | + self.assertEqual(str(app), 'FilesApp({!r})'.format(tmp.dir)) |
148 | + |
149 | + def test_call(self): |
150 | + tmp = TempDir() |
151 | + app = applib.FilesApp(tmp.dir) |
152 | + |
153 | + # Bad methods: |
154 | + for method in ('PUT', 'POST', 'DELETE'): |
155 | + r = mkreq(method, '/foo.txt') |
156 | + self.assertEqual(app(None, r, api), |
157 | + (405, 'Method Not Allowed', {}, None) |
158 | + ) |
159 | + |
160 | + # File doesn't exist: |
161 | + for uri in ('/foo.txt', '/', '/index.html'): |
162 | + for method in ('GET', 'HEAD'): |
163 | + r = mkreq(method, uri) |
164 | + self.assertEqual(app(None, r, api), |
165 | + (404, 'Not Found', {}, None) |
166 | + ) |
167 | + |
168 | + # HEAD request: |
169 | + data1 = os.urandom(1234) |
170 | + tmp.write(data1, 'foo.txt') |
171 | + r = mkreq('HEAD', '/foo.txt') |
172 | + (status, reason, headers, body) = app(None, r, api) |
173 | + self.assertEqual(status, 200) |
174 | + self.assertEqual(reason, 'OK') |
175 | + self.assertEqual(headers, |
176 | + {'content-length': 1234, 'content-type': 'text/plain'} |
177 | + ) |
178 | + self.assertIsNone(body) |
179 | + |
180 | + # GET request: |
181 | + r = mkreq('GET', '/foo.txt') |
182 | + (status, reason, headers, body) = app(None, r, api) |
183 | + self.assertEqual(status, 200) |
184 | + self.assertEqual(reason, 'OK') |
185 | + self.assertEqual(headers, # Server will add content-length |
186 | + {'content-length': 1234, 'content-type': 'text/plain'} |
187 | + ) |
188 | + self.assertIsInstance(body, api.Body) |
189 | + self.assertIsInstance(body.rfile, io.FileIO) |
190 | + self.assertEqual(body.rfile.tell(), 0) |
191 | + self.assertEqual(body.rfile.name, 'foo.txt') |
192 | + self.assertEqual(body.read(), data1) |
193 | + |
194 | + # '/' should map to '/index.html': |
195 | + data2 = os.urandom(2345) |
196 | + tmp.write(data2, 'index.html') |
197 | + for uri in ('/', '/index.html'): |
198 | + r = mkreq('HEAD', uri) |
199 | + (status, reason, headers, body) = app(None, r, api) |
200 | + self.assertEqual(status, 200) |
201 | + self.assertEqual(reason, 'OK') |
202 | + self.assertEqual(headers, |
203 | + {'content-length': 2345, 'content-type': 'text/html'} |
204 | + ) |
205 | + self.assertIsNone(body) |
206 | + |
207 | + r = mkreq('GET', uri) |
208 | + (status, reason, headers, body) = app(None, r, api) |
209 | + self.assertEqual(status, 200) |
210 | + self.assertEqual(reason, 'OK') |
211 | + self.assertEqual(headers, # Server will add content-length |
212 | + {'content-length': 2345, 'content-type': 'text/html'} |
213 | + ) |
214 | + self.assertIsInstance(body, api.Body) |
215 | + self.assertIsInstance(body.rfile, io.FileIO) |
216 | + self.assertEqual(body.rfile.tell(), 0) |
217 | + self.assertEqual(body.rfile.name, 'index.html') |
218 | + self.assertEqual(body.read(), data2) |
219 | + |
220 | + # Range requests: |
221 | + total = len(data2) |
222 | + for (start, stop) in [ |
223 | + (0, total + 1), |
224 | + (total - 1, total + 1), |
225 | + (total, total + 1), |
226 | + ]: |
227 | + _range = api.Range(start, stop) |
228 | + headers = {'range': _range} |
229 | + for method in ('GET', 'HEAD'): |
230 | + r = mkreq(method, uri, headers) |
231 | + self.assertEqual(app(None, r, api), |
232 | + (416, 'Range Not Satisfiable', {}, None) |
233 | + ) |
234 | + for (start, stop) in [ |
235 | + (0, 1), |
236 | + (0, total - 1), |
237 | + (1, total), |
238 | + (total - 1, total), |
239 | + (0, total), |
240 | + ]: |
241 | + length = stop - start |
242 | + _range = api.Range(start, stop) |
243 | + r = mkreq('HEAD', uri, {'range': _range}) |
244 | + (status, reason, headers, body) = app(None, r, api) |
245 | + self.assertEqual(status, 206) |
246 | + self.assertEqual(reason, 'Partial Content') |
247 | + self.assertEqual(headers, |
248 | + { |
249 | + 'content-range': api.ContentRange(start, stop, total), |
250 | + 'content-length': length, |
251 | + 'content-type': 'text/html', |
252 | + } |
253 | + ) |
254 | + self.assertIsNone(body) |
255 | + |
256 | + r = mkreq('GET', uri, {'range': _range}) |
257 | + (status, reason, headers, body) = app(None, r, api) |
258 | + self.assertEqual(status, 206) |
259 | + self.assertEqual(reason, 'Partial Content') |
260 | + self.assertEqual(headers, |
261 | + { |
262 | + 'content-range': api.ContentRange(start, stop, total), |
263 | + 'content-length': length, |
264 | + 'content-type': 'text/html', |
265 | + } |
266 | + ) |
267 | + self.assertIsInstance(body, api.Body) |
268 | + self.assertIsInstance(body.rfile, io.FileIO) |
269 | + self.assertEqual(body.rfile.tell(), start) |
270 | + self.assertEqual(body.rfile.name, 'index.html') |
271 | + self.assertEqual(body.read(), data2[start:stop]) |
272 | + |
273 | + def test_live(self): |
274 | + tmp = TempDir() |
275 | + app = applib.FilesApp(tmp.dir) |
276 | + server = TempServer(('127.0.0.1', 0), app) |
277 | + client = Client(server.address) |
278 | + |
279 | + uri = '/foo/bar.js' |
280 | + for method in ('PUT', 'POST', 'DELETE'): |
281 | + conn = client.connect() |
282 | + rsp = conn.request(method, uri, {}, None) |
283 | + self.assertEqual(rsp.status, 405) |
284 | + self.assertEqual(rsp.reason, 'Method Not Allowed') |
285 | + self.assertEqual(rsp.headers, {}) |
286 | + self.assertIsNone(rsp.body) |
287 | + # Connection should be closed after a 405 error: |
288 | + with self.assertRaises(EmptyPreambleError): |
289 | + conn.request(method, uri, {}, None) |
290 | + |
291 | + conn = client.connect() |
292 | + for method in ('GET', 'HEAD'): |
293 | + rsp = conn.request(method, uri, {}, None) |
294 | + self.assertEqual(rsp.status, 404) |
295 | + self.assertEqual(rsp.reason, 'Not Found') |
296 | + self.assertEqual(rsp.headers, {}) |
297 | + self.assertIsNone(rsp.body) |
298 | + |
299 | + total = 9876 |
300 | + (start, stop) = random_start_stop(total) |
301 | + r = api.Range(start, stop) |
302 | + data = os.urandom(total) |
303 | + tmp.mkdir('foo') |
304 | + tmp.write(data, 'foo', 'bar.js') |
305 | + for method in ('GET', 'HEAD'): |
306 | + rsp = conn.request(method, uri, {}, None) |
307 | + self.assertEqual(rsp.status, 200) |
308 | + self.assertEqual(rsp.reason, 'OK') |
309 | + self.assertEqual(rsp.headers, |
310 | + { |
311 | + 'content-length': total, |
312 | + 'content-type': 'application/javascript', |
313 | + } |
314 | + ) |
315 | + if method == 'GET': |
316 | + self.assertIsInstance(rsp.body, api.Body) |
317 | + self.assertEqual(rsp.body.read(), data) |
318 | + else: |
319 | + self.assertIsNone(rsp.body) |
320 | + |
321 | + rsp = conn.request(method, uri, {'range': r}, None) |
322 | + self.assertEqual(rsp.status, 206) |
323 | + self.assertEqual(rsp.reason, 'Partial Content') |
324 | + self.assertEqual(rsp.headers, |
325 | + { |
326 | + 'content-length': stop - start, |
327 | + 'content-type': 'application/javascript', |
328 | + 'content-range': api.ContentRange(start, stop, total), |
329 | + } |
330 | + ) |
331 | + if method == 'GET': |
332 | + self.assertIsInstance(rsp.body, api.Body) |
333 | + self.assertEqual(rsp.body.read(), data[start:stop]) |
334 | + else: |
335 | + self.assertIsNone(rsp.body) |
336 | + |
337 | + start = random.randrange(0, total) |
338 | + r = api.Range(start, total + 1) |
339 | + for method in ('GET', 'HEAD'): |
340 | + conn = client.connect() |
341 | + rsp = conn.request(method, uri, {'range': r}, None) |
342 | + self.assertEqual(rsp.status, 416) |
343 | + self.assertEqual(rsp.reason, 'Range Not Satisfiable') |
344 | + self.assertEqual(rsp.headers, {}) |
345 | + self.assertIsNone(rsp.body) |
346 | + # Connection should be closed after a 416 error: |
347 | + with self.assertRaises(EmptyPreambleError): |
348 | + conn.request(method, uri, {}, None) |
349 | + |
350 | |
351 | === modified file 'doc/changelog.rst' |
352 | --- doc/changelog.rst 2017-09-04 23:10:40 +0000 |
353 | +++ doc/changelog.rst 2019-12-17 03:38:39 +0000 |
354 | @@ -9,6 +9,12 @@ |
355 | `Download Degu 0.19`_ |
356 | |
357 | |
358 | +New API additions: |
359 | + |
360 | + * The :class:`degu.applib.FilesApp` application was added, a minimal |
361 | + file serving application |
362 | + |
363 | + |
364 | |
365 | .. _version-0.18: |
366 | |
367 | |
368 | === modified file 'doc/degu.applib.rst' |
369 | --- doc/degu.applib.rst 2016-07-23 17:15:20 +0000 |
370 | +++ doc/degu.applib.rst 2019-12-17 03:38:39 +0000 |
371 | @@ -177,3 +177,26 @@ |
372 | |
373 | This method returns a ``(status,reason,headers,body)`` 4-tuple. |
374 | |
375 | + |
376 | + |
377 | +:class:`FilesApp` |
378 | +----------------- |
379 | + |
380 | +.. class:: FilesApp(dir_name) |
381 | + Minimal file serving application. |
382 | + |
383 | + For example, to serve files within the ``'/var/www/html'`` directory: |
384 | + |
385 | + >>> from degu.applib import FilesApp |
386 | + >>> app = FilesApp('/var/www/html') #doctest: +SKIP |
387 | + |
388 | + .. versionadded:: 0.19 |
389 | + |
390 | + .. attribute:: dir_name |
391 | + The *dir_name* argument passed to the constructor |
392 | + |
393 | + .. method:: __call__(session, request, api) |
394 | + RGI callable. |
395 | + |
396 | + This method returns a ``(status,reason,headers,body)`` 4-tuple. |
397 | + |
Nice, looking forward to be able to use degu for serving userwebkit files!