diff options
| -rw-r--r-- | CHANGES.txt | 3 | ||||
| -rw-r--r-- | docs/api/static.rst | 8 | ||||
| -rw-r--r-- | docs/glossary.rst | 4 | ||||
| -rw-r--r-- | docs/narr/assets.rst | 257 | ||||
| -rw-r--r-- | pyramid/config/views.py | 20 | ||||
| -rw-r--r-- | pyramid/static.py | 178 | ||||
| -rw-r--r-- | pyramid/tests/fixtures/manifest.json | 4 | ||||
| -rw-r--r-- | pyramid/tests/fixtures/manifest2.json | 4 | ||||
| -rw-r--r-- | pyramid/tests/test_config/test_views.py | 10 | ||||
| -rw-r--r-- | pyramid/tests/test_static.py | 165 |
10 files changed, 368 insertions, 285 deletions
diff --git a/CHANGES.txt b/CHANGES.txt index 18e8ba39c..8b63cf847 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -57,8 +57,7 @@ Features Core APIs are shipped for both cache busting via query strings and path segments and may be extended to fit into custom asset pipelines. See https://github.com/Pylons/pyramid/pull/1380 and - https://github.com/Pylons/pyramid/pull/1583 and - https://github.com/Pylons/pyramid/pull/2013 + https://github.com/Pylons/pyramid/pull/1583 - Add ``pyramid.config.Configurator.root_package`` attribute and init parameter to assist with includeable packages that wish to resolve diff --git a/docs/api/static.rst b/docs/api/static.rst index f3727e197..b6b279139 100644 --- a/docs/api/static.rst +++ b/docs/api/static.rst @@ -9,11 +9,17 @@ :members: :inherited-members: - .. autoclass:: ManifestCacheBuster + .. autoclass:: PathSegmentCacheBuster :members: .. autoclass:: QueryStringCacheBuster :members: + .. autoclass:: PathSegmentMd5CacheBuster + :members: + + .. autoclass:: QueryStringMd5CacheBuster + :members: + .. autoclass:: QueryStringConstantCacheBuster :members: diff --git a/docs/glossary.rst b/docs/glossary.rst index b4bb36421..9c0ea8598 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -1089,7 +1089,3 @@ Glossary data in a Redis database. See https://pypi.python.org/pypi/pyramid_redis_sessions for more information. - cache busting - A technique used when serving a cacheable static asset in order to force - a client to query the new version of the asset. See :ref:`cache_busting` - for more information. diff --git a/docs/narr/assets.rst b/docs/narr/assets.rst index d36fa49c0..020794062 100644 --- a/docs/narr/assets.rst +++ b/docs/narr/assets.rst @@ -356,14 +356,14 @@ may change, and then you'll want the client to load a new copy of the asset. Under normal circumstances you'd just need to wait for the client's cached copy to expire before they get the new version of the static resource. -A commonly used workaround to this problem is a technique known as -:term:`cache busting`. Cache busting schemes generally involve generating a -URL for a static asset that changes when the static asset changes. This way -headers can be sent along with the static asset instructing the client to cache -the asset for a very long time. When a static asset is changed, the URL used -to refer to it in a web page also changes, so the client sees it as a new -resource and requests the asset, regardless of any caching policy set for the -resource's old URL. +A commonly used workaround to this problem is a technique known as "cache +busting". Cache busting schemes generally involve generating a URL for a +static asset that changes when the static asset changes. This way headers can +be sent along with the static asset instructing the client to cache the asset +for a very long time. When a static asset is changed, the URL used to refer to +it in a web page also changes, so the client sees it as a new resource and +requests the asset, regardless of any caching policy set for the resource's old +URL. :app:`Pyramid` can be configured to produce cache busting URLs for static assets by passing the optional argument, ``cachebust`` to @@ -372,38 +372,30 @@ assets by passing the optional argument, ``cachebust`` to .. code-block:: python :linenos: - import time - from pyramid.static import QueryStringConstantCacheBuster - # config is an instance of pyramid.config.Configurator - config.add_static_view( - name='static', path='mypackage:folder/static', - cachebust=QueryStringConstantCacheBuster(str(int(time.time()))), - ) + config.add_static_view(name='static', path='mypackage:folder/static', + cachebust=True) Setting the ``cachebust`` argument instructs :app:`Pyramid` to use a cache -busting scheme which adds the curent time for a static asset to the query -string in the asset's URL: +busting scheme which adds the md5 checksum for a static asset as a path segment +in the asset's URL: .. code-block:: python :linenos: js_url = request.static_url('mypackage:folder/static/js/myapp.js') - # Returns: 'http://www.example.com/static/js/myapp.js?x=1445318121' + # Returns: 'http://www.example.com/static/c9658b3c0a314a1ca21e5988e662a09e/js/myapp.js' -When the web server restarts, the time constant will change and therefore so -will its URL. Supplying the ``cachebust`` argument also causes the static -view to set headers instructing clients to cache the asset for ten years, -unless the ``cache_max_age`` argument is also passed, in which case that -value is used. +When the asset changes, so will its md5 checksum, and therefore so will its +URL. Supplying the ``cachebust`` argument also causes the static view to set +headers instructing clients to cache the asset for ten years, unless the +``cache_max_age`` argument is also passed, in which case that value is used. .. note:: - Cache busting is an inherently complex topic as it integrates the asset - pipeline and the web application. It is expected and desired that - application authors will write their own cache buster implementations - conforming to the properties of their own asset pipelines. See - :ref:`custom_cache_busters` for information on writing your own. + md5 checksums are cached in RAM, so if you change a static resource without + restarting your application, you may still generate URLs with a stale md5 + checksum. Disabling the Cache Buster ~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -414,45 +406,65 @@ configured cache busters without changing calls to ``PYRAMID_PREVENT_CACHEBUST`` environment variable or the ``pyramid.prevent_cachebust`` configuration value to a true value. -.. _custom_cache_busters: - Customizing the Cache Buster ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -The ``cachebust`` option to -:meth:`~pyramid.config.Configurator.add_static_view` may be set to any object -that implements the interface :class:`~pyramid.interfaces.ICacheBuster`. +Revisiting from the previous section: + +.. code-block:: python + :linenos: + + # config is an instance of pyramid.config.Configurator + config.add_static_view(name='static', path='mypackage:folder/static', + cachebust=True) + +Setting ``cachebust`` to ``True`` instructs :app:`Pyramid` to use a default +cache busting implementation that should work for many situations. The +``cachebust`` may be set to any object that implements the interface +:class:`~pyramid.interfaces.ICacheBuster`. The above configuration is exactly +equivalent to: + +.. code-block:: python + :linenos: -:app:`Pyramid` ships with a very simplistic + from pyramid.static import PathSegmentMd5CacheBuster + + # config is an instance of pyramid.config.Configurator + config.add_static_view(name='static', path='mypackage:folder/static', + cachebust=PathSegmentMd5CacheBuster()) + +:app:`Pyramid` includes a handful of ready to use cache buster implementations: +:class:`~pyramid.static.PathSegmentMd5CacheBuster`, which inserts an md5 +checksum token in the path portion of the asset's URL, +:class:`~pyramid.static.QueryStringMd5CacheBuster`, which adds an md5 checksum +token to the query string of the asset's URL, and :class:`~pyramid.static.QueryStringConstantCacheBuster`, which adds an -arbitrary token you provide to the query string of the asset's URL. This -is almost never what you want in production as it does not allow fine-grained -busting of individual assets. +arbitrary token you provide to the query string of the asset's URL. In order to implement your own cache buster, you can write your own class from scratch which implements the :class:`~pyramid.interfaces.ICacheBuster` interface. Alternatively you may choose to subclass one of the existing implementations. One of the most likely scenarios is you'd want to change the -way the asset token is generated. To do this just subclass +way the asset token is generated. To do this just subclass either +:class:`~pyramid.static.PathSegmentCacheBuster` or :class:`~pyramid.static.QueryStringCacheBuster` and define a -``tokenize(pathspec)`` method. Here is an example which uses Git to get -the hash of the current commit: +``tokenize(pathspec)`` method. Here is an example which just uses Git to get +the hash of the currently checked out code: .. code-block:: python :linenos: import os import subprocess - from pyramid.static import QueryStringCacheBuster + from pyramid.static import PathSegmentCacheBuster - class GitCacheBuster(QueryStringCacheBuster): + class GitCacheBuster(PathSegmentCacheBuster): """ Assuming your code is installed as a Git checkout, as opposed to an egg from an egg repository like PYPI, you can use this cachebuster to get the current commit's SHA1 to use as the cache bust token. """ - def __init__(self, param='x'): - super(GitCacheBuster, self).__init__(param=param) + def __init__(self): here = os.path.dirname(os.path.abspath(__file__)) self.sha1 = subprocess.check_output( ['git', 'rev-parse', 'HEAD'], @@ -464,60 +476,26 @@ the hash of the current commit: Choosing a Cache Buster ~~~~~~~~~~~~~~~~~~~~~~~ -Many caching HTTP proxies will fail to cache a resource if the URL contains -a query string. Therefore, in general, you should prefer a cache busting -strategy which modifies the path segment rather than methods which add a -token to the query string. - -You will need to consider whether the :app:`Pyramid` application will be -serving your static assets, whether you are using an external asset pipeline -to handle rewriting urls internal to the css/javascript, and how fine-grained -do you want the cache busting tokens to be. - -In many cases you will want to host the static assets on another web server -or externally on a CDN. In these cases your :app:`Pyramid` application may not -even have access to a copy of the static assets. In order to cache bust these -assets you will need some information about them. - -If you are using an external asset pipeline to generate your static files you -should consider using the :class:`~pyramid.static.ManifestCacheBuster`. -This cache buster can load a standard JSON formatted file generated by your -pipeline and use it to cache bust the assets. This has many performance -advantages as :app:`Pyramid` does not need to look at the files to generate -any cache busting tokens, but still supports fine-grained per-file tokens. - -Assuming an example ``manifest.json`` like: - -.. code-block:: json - - { - "css/main.css": "css/main-678b7c80.css", - "images/background.png": "images/background-a8169106.png" - } - -The following code would set up a cachebuster: - -.. code-block:: python - :linenos: - - from pyramid.path import AssetResolver - from pyramid.static import ManifestCacheBuster - - resolver = AssetResolver() - manifest = resolver.resolve('myapp:static/manifest.json') - config.add_static_view( - name='http://mycdn.example.com/', - path='mypackage:static', - cachebust=ManifestCacheBuster(manifest.abspath())) - -A simpler approach is to use the -:class:`~pyramid.static.QueryStringConstantCacheBuster` to generate a global -token that will bust all of the assets at once. The advantage of this strategy -is that it is simple and by using the query string there doesn't need to be -any shared information between your application and the static assets. - -The following code would set up a cachebuster that just uses the time at -start up as a cachebust token: +The default cache buster implementation, +:class:`~pyramid.static.PathSegmentMd5CacheBuster`, works very well assuming +that you're using :app:`Pyramid` to serve your static assets. The md5 checksum +is fine grained enough that browsers should only request new versions of +specific assets that have changed. Many caching HTTP proxies will fail to +cache a resource if the URL contains a query string. In general, therefore, +you should prefer a cache busting strategy which modifies the path segment to a +strategy which adds a query string. + +It is possible, however, that your static assets are being served by another +web server or externally on a CDN. In these cases modifying the path segment +for a static asset URL would cause the external service to fail to find the +asset, causing your customer to get a 404. In these cases you would need to +fall back to a cache buster which adds a query string. It is even possible +that there isn't a copy of your static assets available to the :app:`Pyramid` +application, so a cache busting implementation that generates md5 checksums +would fail since it can't access the assets. In such a case, +:class:`~pyramid.static.QueryStringConstantCacheBuster` is a reasonable +fallback. The following code would set up a cachebuster that just uses the +time at start up as a cachebust token: .. code-block:: python :linenos: @@ -528,7 +506,7 @@ start up as a cachebust token: config.add_static_view( name='http://mycdn.example.com/', path='mypackage:static', - cachebust=QueryStringConstantCacheBuster(str(int(time.time())))) + cachebust=QueryStringConstantCacheBuster(str(time.time()))) .. index:: single: static assets view @@ -540,24 +518,85 @@ Often one needs to refer to images and other static assets inside CSS and JavaScript files. If cache busting is active, the final static asset URL is not available until the static assets have been assembled. These URLs cannot be handwritten. Thus, when having static asset references in CSS and JavaScript, -one needs to perform one of the following tasks: +one needs to perform one of the following tasks. * Process the files by using a precompiler which rewrites URLs to their final - cache busted form. Then, you can use the - :class:`~pyramid.static.ManifestCacheBuster` to synchronize your asset - pipeline with :app:`Pyramid`, allowing the pipeline to have full control - over the final URLs of your assets. + cache busted form. * Templatize JS and CSS, and call ``request.static_url()`` inside their template code. * Pass static URL references to CSS and JavaScript via other means. -If your CSS and JavaScript assets use URLs to reference other assets it is -recommended that you implement an external asset pipeline that can rewrite the -generated static files with new URLs containing cache busting tokens. The -machinery inside :app:`Pyramid` will not help with this step as it has very -little knowledge of the asset types your application may use. +Below are some simple approaches for CSS and JS programming which consider +asset cache busting. These approaches do not require additional tools or +packages. + +Relative cache busted URLs in CSS ++++++++++++++++++++++++++++++++++ + +Consider a CSS file ``/static/theme/css/site.css`` which contains the following +CSS code. + +.. code-block:: css + + body { + background: url(/static/theme/img/background.jpg); + } + +Any changes to ``background.jpg`` would not appear to the visitor because the +URL path is not cache busted as it is. Instead we would have to construct an +URL to the background image with the default ``PathSegmentCacheBuster`` cache +busting mechanism:: + + https://site/static/1eeb262c717/theme/img/background.jpg + +Every time the image is updated, the URL would need to be changed. It is not +practical to write this non-human readable URL into a CSS file. + +However, the CSS file itself is cache busted and is located under the path for +static assets. This lets us use relative references in our CSS to cache bust +the image. + +.. code-block:: css + + body { + background: url(../img/background.jpg); + } + +The browser would interpret this as having the CSS file hash in URL:: + + https://site/static/ab234b262c71/theme/css/../img/background.jpg + +The downside of this approach is that if the background image changes, one +needs to bump the CSS file. The CSS file hash change signals the caches that +the relative URL to the image in the CSS has been changed. When updating CSS +and related image assets, updates usually happen hand in hand, so this does not +add extra effort to theming workflow. + +Passing cache busted URLs to JavaScript ++++++++++++++++++++++++++++++++++++++++ + +For JavaScript, one can pass static asset URLs as function arguments or +globals. The globals can be generated in page template code, having access to +the ``request.static_url()`` function. + +Below is a simple example of passing a cached busted image URL in the Jinja2 +template language. Put the following code into the ``<head>`` section of the +relevant page. + +.. code-block:: html + + <script> + window.assets.backgroundImage = + "{{ '/theme/img/background.jpg'|static_url() }}"; + </script> + +Then in your main ``site.js`` file, put the following code. + +.. code-block:: javascript + + var image = new Image(window.assets.backgroundImage); .. _advanced_static: diff --git a/pyramid/config/views.py b/pyramid/config/views.py index e386bc4e1..34fc49c00 100644 --- a/pyramid/config/views.py +++ b/pyramid/config/views.py @@ -34,6 +34,7 @@ from pyramid.interfaces import ( ) from pyramid import renderers +from pyramid.static import PathSegmentMd5CacheBuster from pyramid.compat import ( string_types, @@ -1861,12 +1862,14 @@ class ViewsConfiguratorMixin(object): The ``cachebust`` keyword argument may be set to cause :meth:`~pyramid.request.Request.static_url` to use cache busting when generating URLs. See :ref:`cache_busting` for general information - about cache busting. The value of the ``cachebust`` argument must - be an object which implements - :class:`~pyramid.interfaces.ICacheBuster`. If the ``cachebust`` - argument is provided, the default for ``cache_max_age`` is modified - to be ten years. ``cache_max_age`` may still be explicitly provided - to override this default. + about cache busting. The value of the ``cachebust`` argument may be + ``True``, in which case a default cache busting implementation is used. + The value of the ``cachebust`` argument may also be an object which + implements :class:`~pyramid.interfaces.ICacheBuster`. See the + :mod:`~pyramid.static` module for some implementations. If the + ``cachebust`` argument is provided, the default for ``cache_max_age`` + is modified to be ten years. ``cache_max_age`` may still be explicitly + provided to override this default. The ``permission`` keyword argument is used to specify the :term:`permission` required by a user to execute the static view. By @@ -1964,6 +1967,9 @@ def isexception(o): @implementer(IStaticURLInfo) class StaticURLInfo(object): + # Indirection for testing + _default_cachebust = PathSegmentMd5CacheBuster + def _get_registrations(self, registry): try: reg = registry._static_url_registrations @@ -2027,6 +2033,8 @@ class StaticURLInfo(object): cb = None else: cb = extra.pop('cachebust', None) + if cb is True: + cb = self._default_cachebust() if cb: def cachebust(subpath, kw): subpath_tuple = tuple(subpath.split('/')) diff --git a/pyramid/static.py b/pyramid/static.py index c2c8c89e5..cb78feb9b 100644 --- a/pyramid/static.py +++ b/pyramid/static.py @@ -1,9 +1,8 @@ # -*- coding: utf-8 -*- -import json +import hashlib import os from os.path import ( - getmtime, normcase, normpath, join, @@ -19,10 +18,7 @@ from pkg_resources import ( from repoze.lru import lru_cache -from pyramid.asset import ( - abspath_from_asset_spec, - resolve_asset_spec, -) +from pyramid.asset import resolve_asset_spec from pyramid.compat import text_ @@ -31,7 +27,7 @@ from pyramid.httpexceptions import ( HTTPMovedPermanently, ) -from pyramid.path import caller_package +from pyramid.path import AssetResolver, caller_package from pyramid.response import FileResponse from pyramid.traversal import traversal_path_info @@ -163,6 +159,71 @@ def _secure_path(path_tuple): encoded = slash.join(path_tuple) # will be unicode return encoded +def _generate_md5(spec): + asset = AssetResolver(None).resolve(spec) + md5 = hashlib.md5() + with asset.stream() as stream: + for block in iter(lambda: stream.read(4096), b''): + md5.update(block) + return md5.hexdigest() + +class Md5AssetTokenGenerator(object): + """ + A mixin class which provides an implementation of + :meth:`~pyramid.interfaces.ICacheBuster.target` which generates an md5 + checksum token for an asset, caching it for subsequent calls. + """ + def __init__(self): + self.token_cache = {} + + def tokenize(self, pathspec): + # An astute observer will notice that this use of token_cache doesn't + # look particularly thread safe. Basic read/write operations on Python + # dicts, however, are atomic, so simply accessing and writing values + # to the dict shouldn't cause a segfault or other catastrophic failure. + # (See: http://effbot.org/pyfaq/what-kinds-of-global-value-mutation-are-thread-safe.htm) + # + # We do have a race condition that could result in the same md5 + # checksum getting computed twice or more times in parallel. Since + # the program would still function just fine if this were to occur, + # the extra overhead of using locks to serialize access to the dict + # seems an unnecessary burden. + # + token = self.token_cache.get(pathspec) + if not token: + self.token_cache[pathspec] = token = _generate_md5(pathspec) + return token + +class PathSegmentCacheBuster(object): + """ + An implementation of :class:`~pyramid.interfaces.ICacheBuster` which + inserts a token for cache busting in the path portion of an asset URL. + + To use this class, subclass it and provide a ``tokenize`` method which + accepts a ``pathspec`` and returns a token. + + .. versionadded:: 1.6 + """ + def pregenerate(self, pathspec, subpath, kw): + token = self.tokenize(pathspec) + return (token,) + subpath, kw + + def match(self, subpath): + return subpath[1:] + +class PathSegmentMd5CacheBuster(PathSegmentCacheBuster, + Md5AssetTokenGenerator): + """ + An implementation of :class:`~pyramid.interfaces.ICacheBuster` which + inserts an md5 checksum token for cache busting in the path portion of an + asset URL. Generated md5 checksums are cached in order to speed up + subsequent calls. + + .. versionadded:: 1.6 + """ + def __init__(self): + super(PathSegmentMd5CacheBuster, self).__init__() + class QueryStringCacheBuster(object): """ An implementation of :class:`~pyramid.interfaces.ICacheBuster` which adds @@ -188,6 +249,22 @@ class QueryStringCacheBuster(object): kw['_query'] = tuple(query) + ((self.param, token),) return subpath, kw +class QueryStringMd5CacheBuster(QueryStringCacheBuster, + Md5AssetTokenGenerator): + """ + An implementation of :class:`~pyramid.interfaces.ICacheBuster` which adds + an md5 checksum token for cache busting in the query string of an asset + URL. Generated md5 checksums are cached in order to speed up subsequent + calls. + + The optional ``param`` argument determines the name of the parameter added + to the query string and defaults to ``'x'``. + + .. versionadded:: 1.6 + """ + def __init__(self, param='x'): + super(QueryStringMd5CacheBuster, self).__init__(param=param) + class QueryStringConstantCacheBuster(QueryStringCacheBuster): """ An implementation of :class:`~pyramid.interfaces.ICacheBuster` which adds @@ -207,90 +284,3 @@ 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 ``manifest_spec`` can be an absolute path or a :term:`asset spec` - pointing to a package-relative file. - - The manifest 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_spec, reload=False): - package_name = caller_package().__name__ - self.manifest_path = abspath_from_asset_spec( - manifest_spec, package_name) - 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`` to load any type of file format and return a conforming - dictionary. - - """ - 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 deleted file mode 100644 index 0a43bc5e3..000000000 --- a/pyramid/tests/fixtures/manifest.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "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 deleted file mode 100644 index fd6b9a7bb..000000000 --- a/pyramid/tests/fixtures/manifest2.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "css/main.css": "css/main-678b7c80.css", - "images/background.png": "images/background-a8169106.png" -} diff --git a/pyramid/tests/test_config/test_views.py b/pyramid/tests/test_config/test_views.py index acfb81962..1c2d300a1 100644 --- a/pyramid/tests/test_config/test_views.py +++ b/pyramid/tests/test_config/test_views.py @@ -4104,6 +4104,16 @@ class TestStaticURLInfo(unittest.TestCase): self.assertEqual(config.view_kw['renderer'], 'mypackage:templates/index.pt') + def test_add_cachebust_default(self): + config = self._makeConfig() + inst = self._makeOne() + inst._default_cachebust = lambda: DummyCacheBuster('foo') + inst.add(config, 'view', 'mypackage:path', cachebust=True) + cachebust = config.registry._static_url_registrations[0][3] + subpath, kw = cachebust('some/path', {}) + self.assertEqual(subpath, 'some/path') + self.assertEqual(kw['x'], 'foo') + def test_add_cachebust_prevented(self): config = self._makeConfig() config.registry.settings['pyramid.prevent_cachebust'] = True diff --git a/pyramid/tests/test_static.py b/pyramid/tests/test_static.py index 4a07c2cb1..a3df74b44 100644 --- a/pyramid/tests/test_static.py +++ b/pyramid/tests/test_static.py @@ -1,9 +1,6 @@ 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) @@ -371,14 +368,87 @@ class Test_static_view_use_subpath_True(unittest.TestCase): from pyramid.httpexceptions import HTTPNotFound self.assertRaises(HTTPNotFound, inst, context, request) -class TestQueryStringConstantCacheBuster(unittest.TestCase): +class TestMd5AssetTokenGenerator(unittest.TestCase): + _fspath = None + _tmp = None + + @property + def fspath(self): + if self._fspath: + return self._fspath + + import os + import tempfile + self._tmp = tmp = tempfile.mkdtemp() + self._fspath = os.path.join(tmp, 'test.txt') + return self._fspath + + def tearDown(self): + import shutil + if self._tmp: + shutil.rmtree(self._tmp) + + def _makeOne(self): + from pyramid.static import Md5AssetTokenGenerator as cls + return cls() + + def test_package_resource(self): + fut = self._makeOne().tokenize + expected = '76d653a3a044e2f4b38bb001d283e3d9' + token = fut('pyramid.tests:fixtures/static/index.html') + self.assertEqual(token, expected) + + def test_filesystem_resource(self): + fut = self._makeOne().tokenize + expected = 'd5155f250bef0e9923e894dbc713c5dd' + with open(self.fspath, 'w') as f: + f.write("Are we rich yet?") + token = fut(self.fspath) + self.assertEqual(token, expected) + + def test_cache(self): + fut = self._makeOne().tokenize + expected = 'd5155f250bef0e9923e894dbc713c5dd' + with open(self.fspath, 'w') as f: + f.write("Are we rich yet?") + token = fut(self.fspath) + self.assertEqual(token, expected) + + # md5 shouldn't change because we've cached it + with open(self.fspath, 'w') as f: + f.write("Sorry for the convenience.") + token = fut(self.fspath) + self.assertEqual(token, expected) + +class TestPathSegmentMd5CacheBuster(unittest.TestCase): + + def _makeOne(self): + from pyramid.static import PathSegmentMd5CacheBuster as cls + inst = cls() + inst.tokenize = lambda pathspec: 'foo' + return inst + + def test_token(self): + fut = self._makeOne().tokenize + self.assertEqual(fut('whatever'), 'foo') + + def test_pregenerate(self): + fut = self._makeOne().pregenerate + self.assertEqual(fut('foo', ('bar',), 'kw'), (('foo', 'bar'), 'kw')) + + def test_match(self): + fut = self._makeOne().match + self.assertEqual(fut(('foo', 'bar')), ('bar',)) + +class TestQueryStringMd5CacheBuster(unittest.TestCase): def _makeOne(self, param=None): - from pyramid.static import QueryStringConstantCacheBuster as cls + from pyramid.static import QueryStringMd5CacheBuster as cls if param: - inst = cls('foo', param) + inst = cls(param) else: - inst = cls('foo') + inst = cls() + inst.tokenize = lambda pathspec: 'foo' return inst def test_token(self): @@ -409,70 +479,43 @@ 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_it_with_relspec(self): - fut = self._makeOne('fixtures/manifest.json').pregenerate - self.assertEqual(fut('foo', ('bar',), {}), (['bar'], {})) - self.assertEqual( - fut('foo', ('css', 'main.css'), {}), - (['css', 'main-test.css'], {})) +class TestQueryStringConstantCacheBuster(TestQueryStringMd5CacheBuster): - def test_it_with_absspec(self): - fut = self._makeOne('pyramid.tests:fixtures/manifest.json').pregenerate - self.assertEqual(fut('foo', ('bar',), {}), (['bar'], {})) - self.assertEqual( - fut('foo', ('css', 'main.css'), {}), - (['css', 'main-test.css'], {})) + def _makeOne(self, param=None): + from pyramid.static import QueryStringConstantCacheBuster as cls + if param: + inst = cls('foo', param) + else: + inst = cls('foo') + return inst - 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 + def test_token(self): + fut = self._makeOne().tokenize + self.assertEqual(fut('whatever'), 'foo') - # test without a valid manifest + def test_pregenerate(self): + fut = self._makeOne().pregenerate self.assertEqual( - fut('foo', ('css', 'main.css'), {}), - (['css', 'main.css'], {})) + fut('foo', ('bar',), {}), + (('bar',), {'_query': {'x': 'foo'}})) - # swap to a real manifest, setting mtime to 0 - inst.manifest_path = manifest_path + def test_pregenerate_change_param(self): + fut = self._makeOne('y').pregenerate self.assertEqual( - fut('foo', ('css', 'main.css'), {}), - (['css', 'main-test.css'], {})) + fut('foo', ('bar',), {}), + (('bar',), {'_query': {'y': 'foo'}})) - # ensure switching the path doesn't change the result - inst.manifest_path = new_manifest_path + def test_pregenerate_query_is_already_tuples(self): + fut = self._makeOne().pregenerate self.assertEqual( - fut('foo', ('css', 'main.css'), {}), - (['css', 'main-test.css'], {})) + fut('foo', ('bar',), {'_query': [('a', 'b')]}), + (('bar',), {'_query': (('a', 'b'), ('x', 'foo'))})) - # update mtime, should cause a reload - inst.getmtime = lambda *args, **kwargs: 1 + def test_pregenerate_query_is_tuple_of_tuples(self): + fut = self._makeOne().pregenerate 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, {}) + fut('foo', ('bar',), {'_query': (('a', 'b'),)}), + (('bar',), {'_query': (('a', 'b'), ('x', 'foo'))})) class DummyContext: pass |
