Merge lp:~jderose/degu/files-app into lp:degu

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
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.

To post a comment you must log in.
Revision history for this message
David Jordan (dmj726) wrote :

Nice, looking forward to be able to use degu for serving userwebkit files!

review: Approve

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+

Subscribers

People subscribed via source and target branches