diff options
| -rw-r--r-- | pyramid/static.py | 82 | ||||
| -rw-r--r-- | pyramid/tests/fixtures/manifest.json | 4 | ||||
| -rw-r--r-- | pyramid/tests/fixtures/manifest2.json | 4 | ||||
| -rw-r--r-- | pyramid/tests/test_static.py | 54 |
4 files changed, 144 insertions, 0 deletions
diff --git a/pyramid/static.py b/pyramid/static.py index 2aff02c0c..59f440c82 100644 --- a/pyramid/static.py +++ b/pyramid/static.py @@ -1,7 +1,9 @@ # -*- coding: utf-8 -*- +import json import os from os.path import ( + getmtime, normcase, normpath, join, @@ -202,3 +204,83 @@ class QueryStringConstantCacheBuster(QueryStringCacheBuster): def tokenize(self, pathspec): return self._token + +class ManifestCacheBuster(object): + """ + An implementation of :class:`~pyramid.interfaces.ICacheBuster` which + uses a supplied manifest file to map an asset path to a cache-busted + version of the path. + + The file is expected to conform to the following simple JSON format: + + .. code-block:: json + + { + "css/main.css": "css/main-678b7c80.css", + "images/background.png": "images/background-a8169106.png", + } + + Specifically, it is a JSON-serialized dictionary where the keys are the + source asset paths used in calls to + :meth:`~pyramid.request.Request.static_url. For example:: + + .. code-block:: python + + >>> request.static_url('myapp:static/css/main.css') + "http://www.example.com/static/css/main-678b7c80.css" + + If a path is not found in the manifest it will pass through unchanged. + + If ``reload`` is ``True`` then the manifest file will be reloaded when + changed. It is not recommended to leave this enabled in production. + + If the manifest file cannot be found on disk it will be treated as + an empty mapping unless ``reload`` is ``False``. + + The default implementation assumes the requested (possibly cache-busted) + path is the actual filename on disk. Subclasses may override the ``match`` + method to alter this behavior. For example, to strip the cache busting + token from the path. + + .. versionadded:: 1.6 + """ + exists = staticmethod(exists) # testing + getmtime = staticmethod(getmtime) # testing + + def __init__(self, manifest_path, reload=False): + self.manifest_path = manifest_path + self.reload = reload + + self._mtime = None + if not reload: + self._manifest = self.parse_manifest() + + def parse_manifest(self): + """ + Return a mapping parsed from the ``manifest_path``. + + Subclasses may override this method to use something other than + ``json.loads``. + + """ + with open(self.manifest_path, 'rb') as fp: + content = fp.read().decode('utf-8') + return json.loads(content) + + @property + def manifest(self): + """ The current manifest dictionary.""" + if self.reload: + if not self.exists(self.manifest_path): + return {} + mtime = self.getmtime(self.manifest_path) + if self._mtime is None or mtime > self._mtime: + self._manifest = self.parse_manifest() + self._mtime = mtime + return self._manifest + + def pregenerate(self, pathspec, subpath, kw): + path = '/'.join(subpath) + path = self.manifest.get(path, path) + new_subpath = path.split('/') + return (new_subpath, kw) diff --git a/pyramid/tests/fixtures/manifest.json b/pyramid/tests/fixtures/manifest.json new file mode 100644 index 000000000..0a43bc5e3 --- /dev/null +++ b/pyramid/tests/fixtures/manifest.json @@ -0,0 +1,4 @@ +{ + "css/main.css": "css/main-test.css", + "images/background.png": "images/background-a8169106.png" +} diff --git a/pyramid/tests/fixtures/manifest2.json b/pyramid/tests/fixtures/manifest2.json new file mode 100644 index 000000000..fd6b9a7bb --- /dev/null +++ b/pyramid/tests/fixtures/manifest2.json @@ -0,0 +1,4 @@ +{ + "css/main.css": "css/main-678b7c80.css", + "images/background.png": "images/background-a8169106.png" +} diff --git a/pyramid/tests/test_static.py b/pyramid/tests/test_static.py index 7f50a0e43..ac30e9e50 100644 --- a/pyramid/tests/test_static.py +++ b/pyramid/tests/test_static.py @@ -1,6 +1,9 @@ import datetime +import os.path import unittest +here = os.path.dirname(__file__) + # 5 years from now (more or less) fiveyrsfuture = datetime.datetime.utcnow() + datetime.timedelta(5*365) @@ -406,6 +409,57 @@ class TestQueryStringConstantCacheBuster(unittest.TestCase): fut('foo', ('bar',), {'_query': (('a', 'b'),)}), (('bar',), {'_query': (('a', 'b'), ('x', 'foo'))})) +class TestManifestCacheBuster(unittest.TestCase): + + def _makeOne(self, path, **kw): + from pyramid.static import ManifestCacheBuster as cls + return cls(path, **kw) + + def test_it(self): + manifest_path = os.path.join(here, 'fixtures', 'manifest.json') + fut = self._makeOne(manifest_path).pregenerate + self.assertEqual(fut('foo', ('bar',), {}), (['bar'], {})) + self.assertEqual( + fut('foo', ('css', 'main.css'), {}), + (['css', 'main-test.css'], {})) + + def test_reload(self): + manifest_path = os.path.join(here, 'fixtures', 'manifest.json') + new_manifest_path = os.path.join(here, 'fixtures', 'manifest2.json') + inst = self._makeOne('foo', reload=True) + inst.getmtime = lambda *args, **kwargs: 0 + fut = inst.pregenerate + + # test without a valid manifest + self.assertEqual( + fut('foo', ('css', 'main.css'), {}), + (['css', 'main.css'], {})) + + # swap to a real manifest, setting mtime to 0 + inst.manifest_path = manifest_path + self.assertEqual( + fut('foo', ('css', 'main.css'), {}), + (['css', 'main-test.css'], {})) + + # ensure switching the path doesn't change the result + inst.manifest_path = new_manifest_path + self.assertEqual( + fut('foo', ('css', 'main.css'), {}), + (['css', 'main-test.css'], {})) + + # update mtime, should cause a reload + inst.getmtime = lambda *args, **kwargs: 1 + self.assertEqual( + fut('foo', ('css', 'main.css'), {}), + (['css', 'main-678b7c80.css'], {})) + + def test_invalid_manifest(self): + self.assertRaises(IOError, lambda: self._makeOne('foo')) + + def test_invalid_manifest_with_reload(self): + inst = self._makeOne('foo', reload=True) + self.assertEqual(inst.manifest, {}) + class DummyContext: pass |
