Skip to content

Commit

Permalink
Fixed #17896 -- Added file_hash method to CachedStaticFilesStorage to…
Browse files Browse the repository at this point in the history
… be able to customize the way the hashed name of a file is created. Thanks to mkai for the initial patch.
  • Loading branch information
jezdez committed May 16, 2012
1 parent 085c03e commit 5f75ac9
Show file tree
Hide file tree
Showing 4 changed files with 71 additions and 8 deletions.
23 changes: 16 additions & 7 deletions django/contrib/staticfiles/storage.py
Expand Up @@ -64,6 +64,17 @@ def __init__(self, *args, **kwargs):
compiled = re.compile(pattern)
self._patterns.setdefault(extension, []).append(compiled)

def file_hash(self, name, content=None):
"""
Retuns a hash of the file with the given name and optional content.
"""
if content is None:
return None
md5 = hashlib.md5()
for chunk in content.chunks():
md5.update(chunk)
return md5.hexdigest()[:12]

def hashed_name(self, name, content=None):
parsed_name = urlsplit(unquote(name))
clean_name = parsed_name.path.strip()
Expand All @@ -78,13 +89,11 @@ def hashed_name(self, name, content=None):
return name
path, filename = os.path.split(clean_name)
root, ext = os.path.splitext(filename)
# Get the MD5 hash of the file
md5 = hashlib.md5()
for chunk in content.chunks():
md5.update(chunk)
md5sum = md5.hexdigest()[:12]
hashed_name = os.path.join(path, u"%s.%s%s" %
(root, md5sum, ext))
file_hash = self.file_hash(clean_name, content)
if file_hash is not None:
file_hash = u".%s" % file_hash
hashed_name = os.path.join(path, u"%s%s%s" %
(root, file_hash, ext))
unparsed_name = list(parsed_name)
unparsed_name[2] = hashed_name
# Special casing for a @font-face hack, like url(myfont.eot?#iefix")
Expand Down
9 changes: 9 additions & 0 deletions docs/ref/contrib/staticfiles.txt
Expand Up @@ -348,6 +348,15 @@ CachedStaticFilesStorage
:setting:`CACHES` setting named ``'staticfiles'``. It falls back to using
the ``'default'`` cache backend.

.. method:: file_hash(name, content=None)

.. versionadded:: 1.5

The method that is used when creating the hashed name of a file.
Needs to return a hash for the given file name and content.
By default it calculates a MD5 hash from the content's chunks as
mentioned above.

.. _`far future Expires headers`: http://developer.yahoo.com/performance/rules.html#expires
.. _`@import`: http://www.w3.org/TR/CSS2/cascade.html#at-import
.. _`url()`: http://www.w3.org/TR/CSS2/syndata.html#uri
Expand Down
7 changes: 7 additions & 0 deletions tests/regressiontests/staticfiles_tests/storage.py
@@ -1,5 +1,6 @@
from datetime import datetime
from django.core.files import storage
from django.contrib.staticfiles.storage import CachedStaticFilesStorage

class DummyStorage(storage.Storage):
"""
Expand All @@ -17,3 +18,9 @@ def exists(self, name):

def modified_time(self, name):
return datetime.date(1970, 1, 1)


class SimpleCachedStaticFilesStorage(CachedStaticFilesStorage):

def file_hash(self, name, content=None):
return 'deploy12345'
40 changes: 39 additions & 1 deletion tests/regressiontests/staticfiles_tests/tests.py
Expand Up @@ -10,7 +10,7 @@

from django.template import loader, Context
from django.conf import settings
from django.core.cache.backends.base import BaseCache, CacheKeyWarning
from django.core.cache.backends.base import BaseCache
from django.core.exceptions import ImproperlyConfigured
from django.core.files.storage import default_storage
from django.core.management import call_command
Expand Down Expand Up @@ -515,6 +515,44 @@ def test_cache_key_memcache_validation(self):
self.assertEqual(cache_key, 'staticfiles:e95bbc36387084582df2a70750d7b351')


# we set DEBUG to False here since the template tag wouldn't work otherwise
@override_settings(**dict(TEST_SETTINGS,
STATICFILES_STORAGE='regressiontests.staticfiles_tests.storage.SimpleCachedStaticFilesStorage',
DEBUG=False,
))
class TestCollectionSimpleCachedStorage(BaseCollectionTestCase,
BaseStaticFilesTestCase, TestCase):
"""
Tests for the Cache busting storage
"""
def cached_file_path(self, path):
fullpath = self.render_template(self.static_template_snippet(path))
return fullpath.replace(settings.STATIC_URL, '')

def test_template_tag_return(self):
"""
Test the CachedStaticFilesStorage backend.
"""
self.assertStaticRaises(ValueError,
"does/not/exist.png",
"/static/does/not/exist.png")
self.assertStaticRenders("test/file.txt",
"/static/test/file.deploy12345.txt")
self.assertStaticRenders("cached/styles.css",
"/static/cached/styles.deploy12345.css")
self.assertStaticRenders("path/",
"/static/path/")
self.assertStaticRenders("path/?query",
"/static/path/?query")

def test_template_tag_simple_content(self):
relpath = self.cached_file_path("cached/styles.css")
self.assertEqual(relpath, "cached/styles.deploy12345.css")
with storage.staticfiles_storage.open(relpath) as relfile:
content = relfile.read()
self.assertNotIn("cached/other.css", content)
self.assertIn("other.deploy12345.css", content)

if sys.platform != 'win32':

class TestCollectionLinks(CollectionTestCase, TestDefaults):
Expand Down

1 comment on commit 5f75ac9

@matthewwithanm
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jezdez What do you think about removing the content arg from file_hash() and moving the file reading into it? That way, you would be able to get rid of the potential network operations of exists() and open() just by overriding file_hash(). (I believe the original reason behind this patch was to avoid network operations, but it seems that—with those exists() and open() calls still present in hashed_name()—it hasn't quite gone far enough.)

As an example of why this would be rad, I'm thinking of a CachedStaticFilesStorage that overrides file_hash() and uses the local static files to compute the hash, instead of reading over the network, a la CachedFilesMixin.post_process().

Please sign in to comment.