Michael Merickel
2015-10-20 547f05a8dbfa6add0dbe0124ca5f3374e4b80e14
update cache buster prose and add ManifestCacheBuster
2 files added
7 files modified
638 ■■■■■ changed files
docs/api/static.rst 8 ●●●● patch | view | raw | blame | history
docs/glossary.rst 4 ●●●● patch | view | raw | blame | history
docs/narr/assets.rst 255 ●●●●● patch | view | raw | blame | history
pyramid/config/views.py 20 ●●●●● patch | view | raw | blame | history
pyramid/static.py 166 ●●●● patch | view | raw | blame | history
pyramid/tests/fixtures/manifest.json 4 ●●●● patch | view | raw | blame | history
pyramid/tests/fixtures/manifest2.json 4 ●●●● patch | view | raw | blame | history
pyramid/tests/test_config/test_views.py 10 ●●●●● patch | view | raw | blame | history
pyramid/tests/test_static.py 167 ●●●●● patch | view | raw | blame | history
docs/api/static.rst
@@ -9,16 +9,10 @@
     :members:
     :inherited-members:
  .. autoclass:: PathSegmentCacheBuster
  .. autoclass:: ManifestCacheBuster
     :members:
  .. autoclass:: QueryStringCacheBuster
     :members:
  .. autoclass:: PathSegmentMd5CacheBuster
     :members:
  .. autoclass:: QueryStringMd5CacheBuster
     :members:
  .. autoclass:: QueryStringConstantCacheBuster
docs/glossary.rst
@@ -1089,3 +1089,7 @@
      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.
docs/narr/assets.rst
@@ -356,14 +356,14 @@
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 "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
: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.
:app:`Pyramid` can be configured to produce cache busting URLs for static
assets by passing the optional argument, ``cachebust`` to
@@ -372,30 +372,38 @@
.. 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=True)
   config.add_static_view(
       name='static', path='mypackage:folder/static',
       cachebust=QueryStringConstantCacheBuster(str(int(time.time()))),
   )
Setting the ``cachebust`` argument instructs :app:`Pyramid` to use a cache
busting scheme which adds the md5 checksum for a static asset as a path segment
in the asset's URL:
busting scheme which adds the curent time for a static asset to the query
string 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/c9658b3c0a314a1ca21e5988e662a09e/js/myapp.js'
   # Returns: 'http://www.example.com/static/js/myapp.js?x=1445318121'
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.
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.
.. note::
   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.
   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.
Disabling the Cache Buster
~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -406,65 +414,45 @@
``PYRAMID_PREVENT_CACHEBUST`` environment variable or the
``pyramid.prevent_cachebust`` configuration value to a true value.
.. _custom_cache_busters:
Customizing the Cache Buster
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Revisiting from the previous section:
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`.
.. 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:
   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
:app:`Pyramid` ships with a very simplistic
:class:`~pyramid.static.QueryStringConstantCacheBuster`, which adds an
arbitrary token you provide to the query string of the asset's URL.
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.
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 either
:class:`~pyramid.static.PathSegmentCacheBuster` or
way the asset token is generated.  To do this just subclass
:class:`~pyramid.static.QueryStringCacheBuster` and define a
``tokenize(pathspec)`` method. Here is an example which just uses Git to get
the hash of the currently checked out code:
``tokenize(pathspec)`` method. Here is an example which uses Git to get
the hash of the current commit:
.. code-block:: python
   :linenos:
   import os
   import subprocess
   from pyramid.static import PathSegmentCacheBuster
   from pyramid.static import QueryStringCacheBuster
   class GitCacheBuster(PathSegmentCacheBuster):
   class GitCacheBuster(QueryStringCacheBuster):
       """
       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):
       def __init__(self, param='x'):
           super(GitCacheBuster, self).__init__(param=param)
           here = os.path.dirname(os.path.abspath(__file__))
           self.sha1 = subprocess.check_output(
               ['git', 'rev-parse', 'HEAD'],
@@ -476,26 +464,60 @@
Choosing a Cache Buster
~~~~~~~~~~~~~~~~~~~~~~~
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.
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.
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:
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:
.. code-block:: python
   :linenos:
@@ -506,7 +528,7 @@
   config.add_static_view(
       name='http://mycdn.example.com/',
       path='mypackage:static',
       cachebust=QueryStringConstantCacheBuster(str(time.time())))
       cachebust=QueryStringConstantCacheBuster(str(int(time.time()))))
.. index::
   single: static assets view
@@ -518,85 +540,24 @@
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.
  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.
* Templatize JS and CSS, and call ``request.static_url()`` inside their
  template code.
* Pass static URL references to CSS and JavaScript via other means.
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);
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.
.. _advanced_static:
pyramid/config/views.py
@@ -34,7 +34,6 @@
    )
from pyramid import renderers
from pyramid.static import PathSegmentMd5CacheBuster
from pyramid.compat import (
    string_types,
@@ -1847,14 +1846,12 @@
        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 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.
        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.
        The ``permission`` keyword argument is used to specify the
        :term:`permission` required by a user to execute the static view.  By
@@ -1952,9 +1949,6 @@
@implementer(IStaticURLInfo)
class StaticURLInfo(object):
    # Indirection for testing
    _default_cachebust = PathSegmentMd5CacheBuster
    def _get_registrations(self, registry):
        try:
            reg = registry._static_url_registrations
@@ -2018,8 +2012,6 @@
            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('/'))
pyramid/static.py
@@ -1,8 +1,9 @@
# -*- coding: utf-8 -*-
import hashlib
import json
import os
from os.path import (
    getmtime,
    normcase,
    normpath,
    join,
@@ -27,7 +28,7 @@
    HTTPMovedPermanently,
    )
from pyramid.path import AssetResolver, caller_package
from pyramid.path import caller_package
from pyramid.response import FileResponse
from pyramid.traversal import traversal_path_info
@@ -157,71 +158,6 @@
    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
@@ -247,22 +183,6 @@
            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
@@ -282,3 +202,83 @@
    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)
pyramid/tests/fixtures/manifest.json
New file
@@ -0,0 +1,4 @@
{
    "css/main.css": "css/main-test.css",
    "images/background.png": "images/background-a8169106.png"
}
pyramid/tests/fixtures/manifest2.json
New file
@@ -0,0 +1,4 @@
{
    "css/main.css": "css/main-678b7c80.css",
    "images/background.png": "images/background-a8169106.png"
}
pyramid/tests/test_config/test_views.py
@@ -4104,16 +4104,6 @@
        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
pyramid/tests/test_static.py
@@ -1,5 +1,8 @@
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)
@@ -368,118 +371,7 @@
        from pyramid.httpexceptions import HTTPNotFound
        self.assertRaises(HTTPNotFound, inst, context, request)
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 QueryStringMd5CacheBuster as cls
        if param:
            inst = cls(param)
        else:
            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',), {}),
            (('bar',), {'_query': {'x': 'foo'}}))
    def test_pregenerate_change_param(self):
        fut = self._makeOne('y').pregenerate
        self.assertEqual(
            fut('foo', ('bar',), {}),
            (('bar',), {'_query': {'y': 'foo'}}))
    def test_pregenerate_query_is_already_tuples(self):
        fut = self._makeOne().pregenerate
        self.assertEqual(
            fut('foo', ('bar',), {'_query': [('a', 'b')]}),
            (('bar',), {'_query': (('a', 'b'), ('x', 'foo'))}))
    def test_pregenerate_query_is_tuple_of_tuples(self):
        fut = self._makeOne().pregenerate
        self.assertEqual(
            fut('foo', ('bar',), {'_query': (('a', 'b'),)}),
            (('bar',), {'_query': (('a', 'b'), ('x', 'foo'))}))
class TestQueryStringConstantCacheBuster(TestQueryStringMd5CacheBuster):
class TestQueryStringConstantCacheBuster(unittest.TestCase):
    def _makeOne(self, param=None):
        from pyramid.static import QueryStringConstantCacheBuster as cls
@@ -517,6 +409,57 @@
            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