Merge lp:~rharding/convoy/add_listmod_header into lp:convoy

Proposed by Richard Harding
Status: Needs review
Proposed branch: lp:~rharding/convoy/add_listmod_header
Merge into: lp:convoy
Diff against target: 304 lines (+140/-32)
2 files modified
convoy/combo.py (+34/-9)
convoy/tests/test_combo.py (+106/-23)
To merge this branch: bzr merge lp:~rharding/convoy/add_listmod_header

Commit message

Add a last-modified header to the response so browsers/web servers can help prevent sending unchanged files.

Description of the change

= Summary =
Convoy should return a last-modified header to help allow browsers in sending 304 responses to the same combo files already stored.

== Proposed Fix ==
Track the mtime of the files as we process them and return that as a Last-Modified header in the response.

== Implementation Details ==
This was a bit intrusive. Since the response is really just the looping through the files I had to pull out the list of files in order to find the latest mtime. This means we add an additional loop of the files to the overall process.

The tests then had to be updated since the combine_files now expexts full paths vs the partials. Each of the tests either had to update the file paths themself, or I just used the new build_filelist to do it.

== Tests ==
- Updated the tests to call build_filelist
- Added the Last-Modified to the header checks
- Added a check to verify the Last-Header is correct.

To post a comment you must log in.
Revision history for this message
Robert Collins (lifeless) wrote :

Personally I wouldn't send a last-mod, if you send a hash of the
content of the files - a strong validator - browsers will send an
if-none-match conditional, and you *won't* have to deal with the
fallout of fs datestamp skew (which would cause headache for LP, for
instance).

-Rob

Unmerged revisions

22. By Richard Harding

Garden

21. By Richard Harding

Update convoy to supply a last-modified header

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'convoy/combo.py'
2--- convoy/combo.py 2012-01-27 03:37:59 +0000
3+++ convoy/combo.py 2012-02-23 18:57:20 +0000
4@@ -19,7 +19,7 @@
5 import os
6 import re
7 import urlparse
8-
9+from datetime import datetime
10
11 CHUNK_SIZE = 2 << 12
12 URL_RE = re.compile("url\([ \"\']*([^ \"\']+)[ \"\']*\)")
13@@ -62,6 +62,26 @@
14 return tuple([param for param, value in params])
15
16
17+def build_filelist(root, fnames):
18+ """Build a list of full paths and return that list with last modified time
19+
20+ :return: (files, mtime)
21+
22+ """
23+ mtime = 0
24+ files = []
25+ for fname in fnames:
26+ full = os.path.abspath(os.path.join(root, fname))
27+ files.append(full)
28+
29+ # update the last modified time to the latest file
30+ if os.path.isfile(full):
31+ mod = os.path.getmtime(full)
32+ mtime = mod if mod > mtime else mtime
33+
34+ return (files, mtime)
35+
36+
37 def combine_files(fnames, root, resource_prefix="", rewrite_urls=True):
38 """Combine many files into one.
39
40@@ -71,18 +91,18 @@
41 """
42
43 resource_prefix = resource_prefix.rstrip("/")
44- for fname in fnames:
45- file_ext = os.path.splitext(fname)[-1]
46- basename = os.path.basename(fname)
47- full = os.path.abspath(os.path.join(root, fname))
48- yield "/* " + fname + " */\n"
49+ comment_cleanup = root if root.endswith('/') else root + '/'
50+ for full in fnames:
51+ yield "/* " + full.replace(comment_cleanup, '') + " */\n"
52 if not full.startswith(root) or not os.path.exists(full):
53 yield "/* [missing] */\n"
54 else:
55 with open(full, "r") as f:
56+ file_ext = os.path.splitext(full)[-1]
57 if file_ext == ".css" and rewrite_urls:
58 file_content = f.read()
59 src_dir = os.path.dirname(full)
60+ basename = os.path.basename(full)
61 relative_parts = relative_path(
62 os.path.join(root, basename), src_dir).split(
63 os.path.sep)
64@@ -134,11 +154,16 @@
65 else:
66 start_response("404 Not Found", [("Content-Type", content_type)])
67 return ("Not Found",)
68- start_response("200 OK", [("Content-Type", content_type)])
69-
70 # take any prefix in the url route into consideration for the root to
71 # find files at
72 updated_root = os.path.join(root, path_hint)
73- return combine_files(fnames, updated_root, resource_prefix,
74+
75+ files, mtime = build_filelist(updated_root, fnames)
76+ start_response("200 OK", [
77+ ("Content-Type", content_type),
78+ ("Last-Modified", datetime.ctime(datetime.fromtimestamp(mtime))),
79+ ])
80+
81+ return combine_files(files, updated_root, resource_prefix,
82 rewrite_urls=rewrite_urls)
83 return app
84
85=== modified file 'convoy/tests/test_combo.py'
86--- convoy/tests/test_combo.py 2012-01-27 03:37:59 +0000
87+++ convoy/tests/test_combo.py 2012-02-23 18:57:20 +0000
88@@ -18,11 +18,13 @@
89 import os
90 import difflib
91 import textwrap
92+from datetime import datetime
93 from unittest import defaultTestLoader
94
95 import mocker
96 from paste.fixture import TestApp
97
98+from convoy.combo import build_filelist
99 from convoy.combo import combo_app
100 from convoy.combo import combine_files
101 from convoy.combo import parse_path_hint
102@@ -94,11 +96,14 @@
103 "** oop-min **",
104 "/* event-custom/event-custom-min.js */",
105 "** event-custom-min **"))
106+
107+ testfiles, mtime = build_filelist(test_dir, [
108+ "yui/yui-min.js",
109+ "oop/oop-min.js",
110+ "event-custom/event-custom-min.js"])
111+
112 self.assertEquals(
113- "".join(combine_files(["yui/yui-min.js",
114- "oop/oop-min.js",
115- "event-custom/event-custom-min.js"],
116- root=test_dir)).strip(),
117+ "".join(combine_files(testfiles, root=test_dir)).strip(),
118 expected)
119
120 def test_combine_css_makes_relative_path(self):
121@@ -140,10 +145,12 @@
122 background: url(editor/assets/skins/sam/img/bg.png);
123 }
124 """
125+ testfiles, mtime = build_filelist(test_dir, [
126+ "widget/assets/skins/sam/widget.css",
127+ "editor/assets/skins/sam/editor.css"])
128+
129 self.assertTextEquals(
130- "".join(combine_files(["widget/assets/skins/sam/widget.css",
131- "editor/assets/skins/sam/editor.css"],
132- root=test_dir)).strip(),
133+ "".join(combine_files(testfiles, root=test_dir)).strip(),
134 expected)
135
136
137@@ -186,12 +193,76 @@
138 background: url("http://foo/static/img/bg.png");
139 }
140 """
141+ testfiles, mtime = build_filelist(test_dir, [
142+ "widget/assets/skins/sam/widget.css",
143+ "editor/assets/skins/sam/editor.css"])
144+
145 self.assertTextEquals(
146- "".join(combine_files(["widget/assets/skins/sam/widget.css",
147- "editor/assets/skins/sam/editor.css"],
148- root=test_dir)).strip(),
149+ "".join(combine_files(testfiles, root=test_dir)).strip(),
150 expected)
151
152+
153+ def test_file_builder_missing(self):
154+ """
155+ Given missing files, make sure our mtime is 0 and that the full paths
156+ are based off the root we passed in.
157+ """
158+ test_dir = self.makeDir()
159+ test_files = [
160+ 'widget/assets/skins/sam/widget.css',
161+ 'widget/assets/skins/sam/editor.css',
162+ ]
163+
164+ files, mtime = build_filelist(test_dir, test_files)
165+ self.assertEquals(0, mtime,
166+ "mtime doesn't get updated for missing files")
167+ self.assertEquals(files[0],
168+ os.path.join(test_dir, test_files[0]))
169+
170+
171+ def test_file_builder(self):
172+ """
173+ Given a list of filenames we should build a fullpath list and modified
174+ time
175+ """
176+ test_dir = self.makeDir()
177+ init_time = datetime.ctime(datetime.now())
178+
179+ self.makeSampleFile(
180+ test_dir,
181+ os.path.join("widget", "assets", "skins", "sam", "widget.css"),
182+ """\
183+ /* widget skin */
184+ .yui-widget {
185+ background: url("data:image/gif;base64,base64-data");
186+ }
187+ """)
188+
189+ self.makeSampleFile(
190+ test_dir,
191+ os.path.join("editor", "assets", "skins", "sam", "editor.css"),
192+ """\
193+ /* editor skin */
194+ .yui-editor {
195+ background: url("data:image/gif;base64,base64-data");
196+ }
197+ """)
198+
199+ test_files = [
200+ "widget/assets/skins/sam/widget.css",
201+ "editor/assets/skins/sam/editor.css",
202+ ]
203+
204+ files, mtime = build_filelist(test_dir, test_files)
205+
206+ self.assertEquals(
207+ os.path.join(test_dir, test_files[0]),
208+ files[0])
209+ self.assertEquals(files[1],
210+ os.path.join(test_dir, test_files[1]))
211+ self.assertNotEqual(0, mtime)
212+
213+
214 def test_combine_css_leaves_data_uris_untouched(self):
215 """
216 Combining CSS files does not touch data uris in
217@@ -231,10 +302,12 @@
218 background: url("data:image/gif;base64,base64-data");
219 }
220 """
221+ testfiles, mtime = build_filelist(test_dir, [
222+ "widget/assets/skins/sam/widget.css",
223+ "editor/assets/skins/sam/editor.css"])
224+
225 self.assertTextEquals(
226- "".join(combine_files(["widget/assets/skins/sam/widget.css",
227- "editor/assets/skins/sam/editor.css"],
228- root=test_dir)).strip(),
229+ "".join(combine_files(testfiles, root=test_dir)).strip(),
230 expected)
231
232 def test_missing_file_is_ignored(self):
233@@ -256,11 +329,14 @@
234 "/* [missing] */",
235 "/* event-custom/event-custom-min.js */",
236 "** event-custom-min **"))
237+
238+ testfiles, mtime = build_filelist(test_dir, [
239+ "yui/yui-min.js",
240+ "oop/oop-min.js",
241+ "event-custom/event-custom-min.js"])
242+
243 self.assertEquals(
244- "".join(combine_files(["yui/yui-min.js",
245- "oop/oop-min.js",
246- "event-custom/event-custom-min.js"],
247- root=test_dir)).strip(),
248+ "".join(combine_files(testfiles, root=test_dir)).strip(),
249 expected)
250
251 def test_no_parent_hack(self):
252@@ -360,9 +436,12 @@
253 background: url(/static/editor/assets/skins/sam/img/bg.png);
254 }
255 """
256+ testfiles, mtime = build_filelist(test_dir, [
257+ "widget/assets/skins/sam/widget.css",
258+ "editor/assets/skins/sam/editor.css"])
259+
260 self.assertTextEquals(
261- "".join(combine_files(["widget/assets/skins/sam/widget.css",
262- "editor/assets/skins/sam/editor.css"],
263+ "".join(combine_files(testfiles,
264 root=test_dir,
265 resource_prefix="/static/")).strip(),
266 expected)
267@@ -381,9 +460,9 @@
268 /* yui/base/base.css */
269 .foo{background-image:url(img.png)}
270 """
271+ testfiles, mtime = build_filelist(test_dir, ["yui/base/base.css"])
272 self.assertTextEquals(
273- "".join(combine_files(["yui/base/base.css"],
274- root=test_dir)).strip(),
275+ "".join(combine_files(testfiles, root=test_dir)),
276 expected)
277
278
279@@ -419,11 +498,12 @@
280 ["yui/yui-min.js",
281 "oop/oop-min.js",
282 "event-custom/event-custom-min.js"]), status=200)
283- self.assertEquals(res.headers, [("Content-Type", "text/javascript")])
284+ self.assertEquals(res.headers[0], ("Content-Type", "text/javascript"))
285 self.assertEquals(res.body.strip(), expected)
286
287 def test_combo_app_sets_content_type_for_css(self):
288 """The WSGI App should set a proper Content-Type for CSS."""
289+ init_time = datetime.ctime(datetime.now())
290 self.makeSampleFile(
291 self.root,
292 os.path.join("widget", "skin", "sam", "widget.css"),
293@@ -433,7 +513,10 @@
294
295 res = self.app.get("/?" + "&".join(
296 ["widget/skin/sam/widget.css"]), status=200)
297- self.assertEquals(res.headers, [("Content-Type", "text/css")])
298+ self.assertEquals(res.headers[0], ("Content-Type", "text/css"))
299+ self.assertEqual(res.headers[1][0], 'Last-Modified')
300+ self.assertEqual(res.headers[1][1], init_time, (res.headers[1][1],
301+ init_time))
302 self.assertEquals(res.body.strip(), expected)
303
304 def test_no_filename_gives_404(self):

Subscribers

People subscribed via source and target branches

to all changes: