Skip to content

Commit 00c580a

Browse files
committed
Add a view for collecting static files before serving them.
In a Django development setup, it's common to have a /static/ URL set up to serve static media to the browser upon request. That's usually set to either serve out of a specific directory or out of any configured finders. However, the former doesn't guarantee any referenced files were collected first, and the latter only considers source files (for the purposes of timestamp calculation/caching) and not the collected/compiled files. This change introduces a new view, meant for use with Django's static serving view, that will collect the referenced file before serving it, guaranteeing that media like images and fonts are present. To ensure that this view will return content quickly (especially considering it will serve up any files referenced by the template tags), an optimization was made to Collector to allow the caller to collect only the given list of files. The view makes use of this to handle the collection process fast.
1 parent f2366b5 commit 00c580a

File tree

5 files changed

+184
-2
lines changed

5 files changed

+184
-2
lines changed

pipeline/collector.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from django.contrib.staticfiles import finders
88
from django.contrib.staticfiles.storage import staticfiles_storage
9+
from django.utils import six
910

1011
from pipeline.finders import PipelineFinder
1112

@@ -26,7 +27,7 @@ def clear(self, path=""):
2627
for d in dirs:
2728
self.clear(os.path.join(path, d))
2829

29-
def collect(self, request=None):
30+
def collect(self, request=None, files=[]):
3031
if self.request and self.request is request:
3132
return
3233
self.request = request
@@ -41,10 +42,17 @@ def collect(self, request=None):
4142
prefixed_path = os.path.join(storage.prefix, path)
4243
else:
4344
prefixed_path = path
44-
if prefixed_path not in found_files:
45+
46+
if (prefixed_path not in found_files and
47+
(not files or prefixed_path in files)):
4548
found_files[prefixed_path] = (storage, path)
4649
self.copy_file(path, prefixed_path, storage)
4750

51+
if files and len(files) == len(found_files):
52+
break
53+
54+
return six.iterkeys(found_files)
55+
4856
def copy_file(self, path, prefixed_path, source_storage):
4957
# Delete the target file if needed or break
5058
if not self.delete_file(path, prefixed_path, source_storage):

pipeline/views.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
from __future__ import unicode_literals
2+
3+
from django.conf import settings as django_settings
4+
from django.core.exceptions import ImproperlyConfigured
5+
from django.views.static import serve
6+
7+
from .collector import default_collector
8+
from .conf import settings
9+
10+
11+
def serve_static(request, path, insecure=False, **kwargs):
12+
"""Collect and serve static files.
13+
14+
This view serves up static files, much like Django's
15+
:py:func:`~django.views.static.serve` view, with the addition that it
16+
collects static files first (if enabled). This allows images, fonts, and
17+
other assets to be served up without first loading a page using the
18+
``{% javascript %}`` or ``{% stylesheet %}`` template tags.
19+
20+
You can use this view by adding the following to any :file:`urls.py`::
21+
22+
urlpatterns += static('static/', view='pipeline.views.serve_static')
23+
"""
24+
# Follow the same logic Django uses for determining access to the
25+
# static-serving view.
26+
if not django_settings.DEBUG and not insecure:
27+
raise ImproperlyConfigured("The staticfiles view can only be used in "
28+
"debug mode or if the --insecure "
29+
"option of 'runserver' is used")
30+
31+
if not settings.PIPELINE_ENABLED and settings.PIPELINE_COLLECTOR_ENABLED:
32+
# Collect only the requested file, in order to serve the result as
33+
# fast as possible. This won't interfere with the template tags in any
34+
# way, as those will still cause Django to collect all media.
35+
default_collector.collect(request, files=[path])
36+
37+
return serve(request, path, document_root=django_settings.STATIC_ROOT,
38+
**kwargs)

tests/tests/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
os.environ.setdefault('NUMBER_OF_PROCESSORS', '1')
88

99

10+
from .test_collector import *
1011
from .test_compiler import *
1112
from .test_compressor import *
1213
from .test_template import *
@@ -15,3 +16,4 @@
1516
from .test_packager import *
1617
from .test_storage import *
1718
from .test_utils import *
19+
from .test_views import *

tests/tests/test_collector.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from __future__ import unicode_literals
2+
3+
import os
4+
5+
from django.contrib.staticfiles import finders
6+
from django.test import TestCase
7+
8+
from pipeline.collector import default_collector
9+
from pipeline.finders import PipelineFinder
10+
11+
12+
class CollectorTest(TestCase):
13+
def tearDown(self):
14+
super(CollectorTest, self).tearDown()
15+
16+
default_collector.clear()
17+
18+
def test_collect(self):
19+
self.assertEqual(
20+
set(default_collector.collect()),
21+
set(self._get_collectable_files()))
22+
23+
def test_collect_with_files(self):
24+
self.assertEqual(
25+
set(default_collector.collect(files=[
26+
'pipeline/js/first.js',
27+
'pipeline/js/second.js',
28+
])),
29+
set([
30+
'pipeline/js/first.js',
31+
'pipeline/js/second.js',
32+
]))
33+
34+
def _get_collectable_files(self):
35+
for finder in finders.get_finders():
36+
if not isinstance(finder, PipelineFinder):
37+
for path, storage in finder.list(['CVS', '.*', '*~']):
38+
if getattr(storage, 'prefix', None):
39+
yield os.path.join(storage.prefix, path)
40+
else:
41+
yield path

tests/tests/test_views.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
from __future__ import unicode_literals
2+
3+
from django.contrib.staticfiles.storage import staticfiles_storage
4+
from django.core.exceptions import ImproperlyConfigured
5+
from django.http import Http404
6+
from django.test import RequestFactory, TestCase
7+
from django.test.utils import override_settings
8+
9+
from pipeline.collector import default_collector
10+
from pipeline.views import serve_static
11+
from tests.utils import pipeline_settings
12+
13+
14+
@override_settings(DEBUG=True)
15+
@pipeline_settings(PIPELINE_COLLECTOR_ENABLED=True, PIPELINE_ENABLED=False)
16+
class ServeStaticViewsTest(TestCase):
17+
def setUp(self):
18+
super(ServeStaticViewsTest, self).setUp()
19+
20+
self.filename = 'pipeline/js/first.js'
21+
self.storage = staticfiles_storage
22+
self.request = RequestFactory().get('/static/%s' % self.filename)
23+
24+
default_collector.clear()
25+
26+
def tearDown(self):
27+
super(ServeStaticViewsTest, self).tearDown()
28+
29+
default_collector.clear()
30+
staticfiles_storage._setup()
31+
32+
def test_found(self):
33+
self._test_found()
34+
35+
def test_not_found(self):
36+
self._test_not_found('missing-file')
37+
38+
@override_settings(DEBUG=False)
39+
def test_debug_false(self):
40+
with self.assertRaises(ImproperlyConfigured):
41+
serve_static(self.request, self.filename)
42+
43+
self.assertFalse(self.storage.exists(self.filename))
44+
45+
@override_settings(DEBUG=False)
46+
def test_debug_false_and_insecure(self):
47+
self._test_found(insecure=True)
48+
49+
@pipeline_settings(PIPELINE_ENABLED=True)
50+
def test_pipeline_enabled_and_found(self):
51+
self._write_content()
52+
self._test_found()
53+
54+
@pipeline_settings(PIPELINE_ENABLED=True)
55+
def test_pipeline_enabled_and_not_found(self):
56+
self._test_not_found(self.filename)
57+
58+
@pipeline_settings(PIPELINE_COLLECTOR_ENABLED=False)
59+
def test_collector_disabled_and_found(self):
60+
self._write_content()
61+
self._test_found()
62+
63+
@pipeline_settings(PIPELINE_COLLECTOR_ENABLED=False)
64+
def test_collector_disabled_and_not_found(self):
65+
self._test_not_found(self.filename)
66+
67+
def _write_content(self, content='abc123'):
68+
"""Write sample content to the test static file."""
69+
with self.storage.open(self.filename, 'w') as f:
70+
f.write(content)
71+
72+
def _test_found(self, **kwargs):
73+
"""Test that a file can be found and contains the correct content."""
74+
response = serve_static(self.request, self.filename, **kwargs)
75+
self.assertEqual(response.status_code, 200)
76+
self.assertTrue(self.storage.exists(self.filename))
77+
78+
if hasattr(response, 'streaming_content'):
79+
content = b''.join(response.streaming_content)
80+
else:
81+
content = response.content
82+
83+
with self.storage.open(self.filename) as f:
84+
self.assertEqual(f.read(), content)
85+
86+
def _test_not_found(self, filename):
87+
"""Test that a file could not be found."""
88+
self.assertFalse(self.storage.exists(filename))
89+
90+
with self.assertRaises(Http404):
91+
serve_static(self.request, filename)
92+
93+
self.assertFalse(self.storage.exists(filename))

0 commit comments

Comments
 (0)