Michael Merickel
2015-12-18 7c7ab5306cb42e63ebc89bdd8f55982000663fa8
Merge pull request #2186 from mmerickel/backport/2171

backport config.add_cache_buster
8 files modified
829 ■■■■■ changed files
CHANGES.txt 26 ●●●●● patch | view | raw | blame | history
docs/narr/assets.rst 173 ●●●● patch | view | raw | blame | history
pyramid/config/assets.py 5 ●●●● patch | view | raw | blame | history
pyramid/config/views.py 177 ●●●● patch | view | raw | blame | history
pyramid/interfaces.py 63 ●●●● patch | view | raw | blame | history
pyramid/static.py 43 ●●●● patch | view | raw | blame | history
pyramid/tests/test_config/test_views.py 266 ●●●●● patch | view | raw | blame | history
pyramid/tests/test_static.py 76 ●●●● patch | view | raw | blame | history
CHANGES.txt
@@ -1,3 +1,29 @@
1.6b3 (unreleased)
==================
Backward Incompatibilities
--------------------------
- Remove the ``cachebust`` option from ``config.add_static_view``. See
  ``config.add_cache_buster`` for the new way to attach cache busters to
  static assets.
  See https://github.com/Pylons/pyramid/pull/2186
- Modify the ``pyramid.interfaces.ICacheBuster`` API to be a simple callable
  instead of an object with ``match`` and ``pregenerate`` methods. Cache
  busters are now focused solely on generation. Matching has been dropped.
  Note this affects usage of ``pyramid.static.QueryStringCacheBuster`` and
  ``pyramid.static.ManifestCacheBuster``.
  See https://github.com/Pylons/pyramid/pull/2186
Features
--------
- Add a new ``config.add_cache_buster`` API for attaching cache busters to
  static assets. See https://github.com/Pylons/pyramid/pull/2186
1.6b2 (2015-10-15)
==================
docs/narr/assets.rst
@@ -366,8 +366,7 @@
resource's old URL.
:app:`Pyramid` can be configured to produce cache busting URLs for static
assets by passing the optional argument, ``cachebust`` to
:meth:`~pyramid.config.Configurator.add_static_view`:
assets using :meth:`~pyramid.config.Configurator.add_cache_buster`:
.. code-block:: python
   :linenos:
@@ -376,14 +375,13 @@
   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/')
   config.add_cache_buster(
       'mypackage:folder/static/',
       QueryStringConstantCacheBuster(str(int(time.time()))))
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:
Adding the cachebuster instructs :app:`Pyramid` to add the current time for
a static asset to the query string in the asset's URL:
.. code-block:: python
   :linenos:
@@ -392,10 +390,7 @@
   # Returns: 'http://www.example.com/static/js/myapp.js?x=1445318121'
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.
will its URL.
.. note::
@@ -410,7 +405,7 @@
It can be useful in some situations (e.g., development) to globally disable all
configured cache busters without changing calls to
:meth:`~pyramid.config.Configurator.add_static_view`.  To do this set the
:meth:`~pyramid.config.Configurator.add_cache_buster`.  To do this set the
``PYRAMID_PREVENT_CACHEBUST`` environment variable or the
``pyramid.prevent_cachebust`` configuration value to a true value.
@@ -419,9 +414,9 @@
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`.
Calls to :meth:`~pyramid.config.Configurator.add_cache_buster` may use
any object that implements the interface
:class:`~pyramid.interfaces.ICacheBuster`.
:app:`Pyramid` ships with a very simplistic
:class:`~pyramid.static.QueryStringConstantCacheBuster`, which adds an
@@ -451,18 +446,45 @@
       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'):
       def __init__(self, param='x', repo_path=None):
           super(GitCacheBuster, self).__init__(param=param)
           here = os.path.dirname(os.path.abspath(__file__))
           if repo_path is None:
               repo_path = os.path.dirname(os.path.abspath(__file__))
           self.sha1 = subprocess.check_output(
               ['git', 'rev-parse', 'HEAD'],
               cwd=here).strip()
               cwd=repo_path).strip()
       def tokenize(self, pathspec):
           return self.sha1
Choosing a Cache Buster
~~~~~~~~~~~~~~~~~~~~~~~
A simple cache buster that modifies the path segment can be constructed as
well:
.. code-block:: python
   :linenos:
   import posixpath
   class PathConstantCacheBuster(object):
       def __init__(self, token):
           self.token = token
       def __call__(self, request, subpath, kw):
           base_subpath, ext = posixpath.splitext(subpath)
           new_subpath = base_subpath + self.token + ext
           return new_subpath, kw
The caveat with this approach is that modifying the path segment
changes the file name, and thus must match what is actually on the
filesystem in order for :meth:`~pyramid.config.Configurator.add_static_view`
to find the file. It's better to use the
:class:`~pyramid.static.ManifestCacheBuster` for these situations, as
described in the next section.
.. _path_segment_cache_busters:
Path Segments and 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
@@ -504,28 +526,11 @@
   config.add_static_view(
       name='http://mycdn.example.com/',
       path='mypackage:static',
       cachebust=ManifestCacheBuster('myapp:static/manifest.json'))
       path='mypackage:static')
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:
   import time
   from pyramid.static import QueryStringConstantCacheBuster
   config.add_static_view(
       name='http://mycdn.example.com/',
       path='mypackage:static',
       cachebust=QueryStringConstantCacheBuster(str(int(time.time()))))
   config.add_cache_buster(
       'mypackage:static/',
       ManifestCacheBuster('myapp:static/manifest.json'))
.. index::
   single: static assets view
@@ -536,25 +541,38 @@
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:
handwritten. Below is an example of how to integrate the cache buster into
the entire stack. Remember, it is just an example and should be modified to
fit your specific tools.
* Process the files by using a precompiler which rewrites URLs to their final
  cache busted form. Then, you can use the
* First, 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.
* Templatize JS and CSS, and call ``request.static_url()`` inside their
  template code.
Now that you are able to generate static URLs within :app:`Pyramid`,
you'll need to handle URLs that are out of our control. To do this you may
use some of the following options to get started:
* Pass static URL references to CSS and JavaScript via other means.
* Configure your asset pipeline to rewrite URL references inline in
  CSS and JavaScript. This is the best approach because then the files
  may be hosted by :app:`Pyramid` or an external CDN without having to
  change anything. They really are static.
* Templatize JS and CSS, and call ``request.static_url()`` inside their
  template code. While this approach may work in certain scenarios, it is not
  recommended because your static assets will not really be static and are now
  dependent on :app:`Pyramid` to be served correctly. See
  :ref:`advanced_static` for more information on this approach.
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.
little knowledge of the asset types your application may use. The integration
into :app:`Pyramid` is simply for linking those assets into your HTML and
other dynamic content.
.. _advanced_static:
@@ -793,3 +811,56 @@
  As of Pyramid 1.6, it is also possible to override an asset by supplying an
  absolute path to a file or directory. This may be useful if the assets are
  not distributed as part of a Python package.
Cache Busting and Asset Overrides
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Overriding static assets that are being hosted using
:meth:`pyramid.config.Configurator.add_static_view` can affect your cache
busting strategy when using any cache busters that are asset-aware such as
:class:`pyramid.static.ManifestCacheBuster`. What sets asset-aware cache
busters apart is that they have logic tied to specific assets. For example,
a manifest is only generated for a specific set of pre-defined assets. Now,
imagine you have overridden an asset defined in this manifest with a new,
unknown version. By default, the cache buster will be invoked for an asset
it has never seen before and will likely end up returning a cache busting
token for the original asset rather than the asset that will actually end up
being served! In order to get around this issue, it's possible to attach a
different :class:`pyramid.interfaces.ICacheBuster` implementation to the
new assets. This would cause the original assets to be served by their
manifest, and the new assets served by their own cache buster. To do this,
:meth:`pyramid.config.Configurator.add_cache_buster` supports an ``explicit``
option. For example:
.. code-block:: python
   :linenos:
   from pyramid.static import ManifestCacheBuster
   # define a static view for myapp:static assets
   config.add_static_view('static', 'myapp:static')
   # setup a cache buster for your app based on the myapp:static assets
   my_cb = ManifestCacheBuster('myapp:static/manifest.json')
   config.add_cache_buster('myapp:static', my_cb)
   # override an asset
   config.override_asset(
       to_override='myapp:static/background.png',
       override_with='theme:static/background.png')
   # override the cache buster for theme:static assets
   theme_cb = ManifestCacheBuster('theme:static/manifest.json')
   config.add_cache_buster('theme:static', theme_cb, explicit=True)
In the above example there is a default cache buster, ``my_cb``, for all
assets served from the ``myapp:static`` folder. This would also affect
``theme:static/background.png`` when generating URLs via
``request.static_url('myapp:static/background.png')``.
The ``theme_cb`` is defined explicitly for any assets loaded from the
``theme:static`` folder. Explicit cache busters have priority and thus
``theme_cb`` would be invoked for
``request.static_url('myapp:static/background.png')``, but ``my_cb`` would
be used for any other assets like
``request.static_url('myapp:static/favicon.ico')``.
pyramid/config/assets.py
@@ -262,12 +262,15 @@
    def __init__(self, prefix):
        self.prefix = prefix
    def get_filename(self, resource_name):
    def get_path(self, resource_name):
        if resource_name:
            path = os.path.join(self.prefix, resource_name)
        else:
            path = self.prefix
        return path
    def get_filename(self, resource_name):
        path = self.get_path(resource_name)
        if os.path.exists(path):
            return path
pyramid/config/views.py
@@ -1,4 +1,5 @@
import inspect
import posixpath
import operator
import os
import warnings
@@ -20,6 +21,7 @@
    IException,
    IExceptionViewClassifier,
    IMultiView,
    IPackageOverrides,
    IRendererFactory,
    IRequest,
    IResponse,
@@ -35,6 +37,7 @@
from pyramid import renderers
from pyramid.asset import resolve_asset_spec
from pyramid.compat import (
    string_types,
    urlparse,
@@ -65,7 +68,6 @@
from pyramid.security import NO_PERMISSION_REQUIRED
from pyramid.static import static_view
from pyramid.threadlocal import get_current_registry
from pyramid.url import parse_url_overrides
@@ -1840,18 +1842,7 @@
        ``Expires`` and ``Cache-Control`` headers for static assets served.
        Note that this argument has no effect when the ``name`` is a *url
        prefix*.  By default, this argument is ``None``, meaning that no
        particular Expires or Cache-Control headers are set in the response,
        unless ``cachebust`` is specified.
        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.
        particular Expires or Cache-Control headers are set in the response.
        The ``permission`` keyword argument is used to specify the
        :term:`permission` required by a user to execute the static view.  By
@@ -1931,11 +1922,41 @@
        See :ref:`static_assets_section` for more information.
        """
        spec = self._make_spec(path)
        info = self._get_static_info()
        info.add(self, name, spec, **kw)
    def add_cache_buster(self, path, cachebust, explicit=False):
        """
        Add a cache buster to a set of files on disk.
        The ``path`` should be the path on disk where the static files
        reside.  This can be an absolute path, a package-relative path, or a
        :term:`asset specification`.
        The ``cachebust`` 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 ``explicit`` is set to ``True`` then the ``path`` for the cache
        buster will be matched based on the ``rawspec`` instead of the
        ``pathspec`` as defined in the
        :class:`~pyramid.interfaces.ICacheBuster` interface.
        Default: ``False``.
        """
        spec = self._make_spec(path)
        info = self._get_static_info()
        info.add_cache_buster(self, spec, cachebust, explicit=explicit)
    def _get_static_info(self):
        info = self.registry.queryUtility(IStaticURLInfo)
        if info is None:
            info = StaticURLInfo()
            self.registry.registerUtility(info, IStaticURLInfo)
        info.add(self, name, spec, **kw)
        return info
def isexception(o):
    if IInterface.providedBy(o):
@@ -1949,26 +1970,19 @@
@implementer(IStaticURLInfo)
class StaticURLInfo(object):
    def _get_registrations(self, registry):
        try:
            reg = registry._static_url_registrations
        except AttributeError:
            reg = registry._static_url_registrations = []
        return reg
    def __init__(self):
        self.registrations = []
        self.cache_busters = []
    def generate(self, path, request, **kw):
        try:
            registry = request.registry
        except AttributeError: # bw compat (for tests)
            registry = get_current_registry()
        registrations = self._get_registrations(registry)
        for (url, spec, route_name, cachebust) in registrations:
        for (url, spec, route_name) in self.registrations:
            if path.startswith(spec):
                subpath = path[len(spec):]
                if WIN: # pragma: no cover
                    subpath = subpath.replace('\\', '/') # windows
                if cachebust:
                    subpath, kw = cachebust(subpath, kw)
                if self.cache_busters:
                    subpath, kw = self._bust_asset_path(
                        request, spec, subpath, kw)
                if url is None:
                    kw['subpath'] = subpath
                    return request.route_url(route_name, **kw)
@@ -2008,19 +2022,6 @@
            # make sure it ends with a slash
            name = name + '/'
        if config.registry.settings.get('pyramid.prevent_cachebust'):
            cb = None
        else:
            cb = extra.pop('cachebust', None)
        if cb:
            def cachebust(subpath, kw):
                subpath_tuple = tuple(subpath.split('/'))
                subpath_tuple, kw = cb.pregenerate(
                    spec + subpath, subpath_tuple, kw)
                return '/'.join(subpath_tuple), kw
        else:
            cachebust = None
        if url_parse(name).netloc:
            # it's a URL
            # url, spec, route_name
@@ -2029,14 +2030,11 @@
        else:
            # it's a view name
            url = None
            ten_years = 10 * 365 * 24 * 60 * 60  # more or less
            default = ten_years if cb else None
            cache_max_age = extra.pop('cache_max_age', default)
            cache_max_age = extra.pop('cache_max_age', None)
            # create a view
            cb_match = getattr(cb, 'match', None)
            view = static_view(spec, cache_max_age=cache_max_age,
                               use_subpath=True, cachebust_match=cb_match)
                               use_subpath=True)
            # Mutate extra to allow factory, etc to be passed through here.
            # Treat permission specially because we'd like to default to
@@ -2068,7 +2066,7 @@
                )
        def register():
            registrations = self._get_registrations(config.registry)
            registrations = self.registrations
            names = [ t[0] for t in  registrations ]
@@ -2077,7 +2075,7 @@
                registrations.pop(idx)
            # url, spec, route_name
            registrations.append((url, spec, route_name, cachebust))
            registrations.append((url, spec, route_name))
        intr = config.introspectable('static views',
                                     name,
@@ -2088,3 +2086,86 @@
        config.action(None, callable=register, introspectables=(intr,))
    def add_cache_buster(self, config, spec, cachebust, explicit=False):
        # ensure the spec always has a trailing slash as we only support
        # adding cache busters to folders, not files
        if os.path.isabs(spec): # FBO windows
            sep = os.sep
        else:
            sep = '/'
        if not spec.endswith(sep) and not spec.endswith(':'):
            spec = spec + sep
        def register():
            if config.registry.settings.get('pyramid.prevent_cachebust'):
                return
            cache_busters = self.cache_busters
            # find duplicate cache buster (old_idx)
            # and insertion location (new_idx)
            new_idx, old_idx = len(cache_busters), None
            for idx, (spec_, cb_, explicit_) in enumerate(cache_busters):
                # if we find an identical (spec, explicit) then use it
                if spec == spec_ and explicit == explicit_:
                    old_idx = new_idx = idx
                    break
                # past all explicit==False specs then add to the end
                elif not explicit and explicit_:
                    new_idx = idx
                    break
                # explicit matches and spec is shorter
                elif explicit == explicit_ and len(spec) < len(spec_):
                    new_idx = idx
                    break
            if old_idx is not None:
                cache_busters.pop(old_idx)
            cache_busters.insert(new_idx, (spec, cachebust, explicit))
        intr = config.introspectable('cache busters',
                                     spec,
                                     'cache buster for %r' % spec,
                                     'cache buster')
        intr['cachebust'] = cachebust
        intr['path'] = spec
        intr['explicit'] = explicit
        config.action(None, callable=register, introspectables=(intr,))
    def _bust_asset_path(self, request, spec, subpath, kw):
        registry = request.registry
        pkg_name, pkg_subpath = resolve_asset_spec(spec)
        rawspec = None
        if pkg_name is not None:
            pathspec = '{0}:{1}{2}'.format(pkg_name, pkg_subpath, subpath)
            overrides = registry.queryUtility(IPackageOverrides, name=pkg_name)
            if overrides is not None:
                resource_name = posixpath.join(pkg_subpath, subpath)
                sources = overrides.filtered_sources(resource_name)
                for source, filtered_path in sources:
                    rawspec = source.get_path(filtered_path)
                    if hasattr(source, 'pkg_name'):
                        rawspec = '{0}:{1}'.format(source.pkg_name, rawspec)
                    break
        else:
            pathspec = pkg_subpath + subpath
        if rawspec is None:
            rawspec = pathspec
        kw['pathspec'] = pathspec
        kw['rawspec'] = rawspec
        for spec_, cachebust, explicit in reversed(self.cache_busters):
            if (
                (explicit and rawspec.startswith(spec_)) or
                (not explicit and pathspec.startswith(spec_))
            ):
                subpath, kw = cachebust(request, subpath, kw)
                break
        return subpath, kw
pyramid/interfaces.py
@@ -584,6 +584,9 @@
    def generate(path, request, **kw):
        """ Generate a URL for the given path """
    def add_cache_buster(config, spec, cache_buster):
        """ Add a new cache buster to a particular set of assets """
class IResponseFactory(Interface):
    """ A utility which generates a response """
    def __call__(request):
@@ -1186,45 +1189,43 @@
class ICacheBuster(Interface):
    """
    Instances of ``ICacheBuster`` may be provided as arguments to
    :meth:`~pyramid.config.Configurator.add_static_view`.  Instances of
    ``ICacheBuster`` provide mechanisms for generating a cache bust token for
    a static asset, modifying a static asset URL to include a cache bust token,
    and, optionally, unmodifying a static asset URL in order to look up an
    asset.  See :ref:`cache_busting`.
    A cache buster modifies the URL generation machinery for
    :meth:`~pyramid.request.Request.static_url`. See :ref:`cache_busting`.
    .. versionadded:: 1.6
    """
    def pregenerate(pathspec, subpath, kw):
    def __call__(request, subpath, kw):
        """
        Modifies a subpath and/or keyword arguments from which a static asset
        URL will be computed during URL generation.  The ``pathspec`` argument
        is the path specification for the resource to be cache busted.
        The ``subpath`` argument is a tuple of path elements that represent the
        portion of the asset URL which is used to find the asset.  The ``kw``
        argument is a dict of keywords that are to be passed eventually to
        :meth:`~pyramid.request.Request.route_url` for URL generation.  The
        return value should be a two-tuple of ``(subpath, kw)`` which are
        versions of the same arguments modified to include the cache bust token
        in the generated URL.
        """
        URL will be computed during URL generation.
    def match(subpath):
        """
        Performs the logical inverse of
        :meth:`~pyramid.interfaces.ICacheBuster.pregenerate` by taking a
        subpath from a cache busted URL and removing the cache bust token, so
        that :app:`Pyramid` can find the underlying asset.
        The ``subpath`` argument is a path of ``/``-delimited segments that
        represent the portion of the asset URL which is used to find the asset.
        The ``kw`` argument is a dict of keywords that are to be passed
        eventually to :meth:`~pyramid.request.Request.static_url` for URL
        generation.  The return value should be a two-tuple of
        ``(subpath, kw)`` where ``subpath`` is the relative URL from where the
        file is served and ``kw`` is the same input argument. The return value
        should be modified to include the cache bust token in the generated
        URL.
        ``subpath`` is the subpath portion of the URL for an incoming request
        for a static asset.  The return value should be the same tuple with the
        cache busting token elided.
        The ``kw`` dictionary contains extra arguments passed to
        :meth:`~pyramid.request.Request.static_url` as well as some extra
        items that may be usful including:
        If the cache busting scheme in use doesn't specifically modify the path
        portion of the generated URL (e.g. it adds a query string), a method
        which implements this interface may not be necessary.  It is
        permissible for an instance of
        :class:`~pyramid.interfaces.ICacheBuster` to omit this method.
          - ``pathspec`` is the path specification for the resource
            to be cache busted.
          - ``rawspec`` is the original location of the file, ignoring
            any calls to :meth:`pyramid.config.Configurator.override_asset`.
        The ``pathspec`` and ``rawspec`` values are only different in cases
        where an asset has been mounted into a virtual location using
        :meth:`pyramid.config.Configurator.override_asset`. For example, with
        a call to ``request.static_url('myapp:static/foo.png'), the
        ``pathspec`` is ``myapp:static/foo.png`` whereas the ``rawspec`` may
        be ``themepkg:bar.png``, assuming a call to
        ``config.override_asset('myapp:static/foo.png', 'themepkg:bar.png')``.
        """
# configuration phases: a lower phase number means the actions associated
pyramid/static.py
@@ -170,15 +170,15 @@
    to the query string and defaults to ``'x'``.
    To use this class, subclass it and provide a ``tokenize`` method which
    accepts a ``pathspec`` and returns a token.
    accepts ``request, pathspec, kw`` and returns a token.
    .. versionadded:: 1.6
    """
    def __init__(self, param='x'):
        self.param = param
    def pregenerate(self, pathspec, subpath, kw):
        token = self.tokenize(pathspec)
    def __call__(self, request, subpath, kw):
        token = self.tokenize(request, subpath, kw)
        query = kw.setdefault('_query', {})
        if isinstance(query, dict):
            query[self.param] = token
@@ -203,7 +203,7 @@
        super(QueryStringConstantCacheBuster, self).__init__(param=param)
        self._token = token
    def tokenize(self, pathspec):
    def tokenize(self, request, subpath, kw):
        return self._token
class ManifestCacheBuster(object):
@@ -225,7 +225,7 @@
           "images/background.png": "images/background-a8169106.png",
       }
    Specifically, it is a JSON-serialized dictionary where the keys are the
    By default, 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::
@@ -234,6 +234,9 @@
       >>> request.static_url('myapp:static/css/main.css')
       "http://www.example.com/static/css/main-678b7c80.css"
    The file format and location can be changed by subclassing and overriding
    :meth:`.parse_manifest`.
    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
@@ -241,11 +244,6 @@
    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
    """
@@ -260,20 +258,23 @@
        self._mtime = None
        if not reload:
            self._manifest = self.parse_manifest()
            self._manifest = self.get_manifest()
    def parse_manifest(self):
    def get_manifest(self):
        with open(self.manifest_path, 'rb') as fp:
            return self.parse_manifest(fp.read())
    def parse_manifest(self, content):
        """
        Return a mapping parsed from the ``manifest_path``.
        Parse the ``content`` read from the ``manifest_path`` into a
        dictionary mapping.
        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)
        return json.loads(content.decode('utf-8'))
    @property
    def manifest(self):
@@ -283,12 +284,10 @@
                return {}
            mtime = self.getmtime(self.manifest_path)
            if self._mtime is None or mtime > self._mtime:
                self._manifest = self.parse_manifest()
                self._manifest = self.get_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)
    def __call__(self, request, subpath, kw):
        subpath = self.manifest.get(subpath, subpath)
        return (subpath, kw)
pyramid/tests/test_config/test_views.py
@@ -1,3 +1,4 @@
import os
import unittest
from pyramid import testing
@@ -3865,21 +3866,10 @@
    def _makeOne(self):
        return self._getTargetClass()()
    def _makeConfig(self, registrations=None):
        config = DummyConfig()
        registry = DummyRegistry()
        if registrations is not None:
            registry._static_url_registrations = registrations
        config.registry = registry
        return config
    def _makeRequest(self):
        request = DummyRequest()
        request.registry = DummyRegistry()
        return request
    def _assertRegistrations(self, config, expected):
        self.assertEqual(config.registry._static_url_registrations, expected)
    def test_verifyClass(self):
        from pyramid.interfaces import IStaticURLInfo
@@ -3898,49 +3888,35 @@
    def test_generate_registration_miss(self):
        inst = self._makeOne()
        registrations = [
            (None, 'spec', 'route_name', None),
            ('http://example.com/foo/', 'package:path/', None, None)]
        inst._get_registrations = lambda *x: registrations
        inst.registrations = [
            (None, 'spec', 'route_name'),
            ('http://example.com/foo/', 'package:path/', None)]
        request = self._makeRequest()
        result = inst.generate('package:path/abc', request)
        self.assertEqual(result, 'http://example.com/foo/abc')
    def test_generate_registration_no_registry_on_request(self):
        inst = self._makeOne()
        registrations = [
            ('http://example.com/foo/', 'package:path/', None, None)]
        inst._get_registrations = lambda *x: registrations
        request = self._makeRequest()
        del request.registry
        result = inst.generate('package:path/abc', request)
        self.assertEqual(result, 'http://example.com/foo/abc')
    def test_generate_slash_in_name1(self):
        inst = self._makeOne()
        registrations = [
            ('http://example.com/foo/', 'package:path/', None, None)]
        inst._get_registrations = lambda *x: registrations
        inst.registrations = [('http://example.com/foo/', 'package:path/', None)]
        request = self._makeRequest()
        result = inst.generate('package:path/abc', request)
        self.assertEqual(result, 'http://example.com/foo/abc')
    def test_generate_slash_in_name2(self):
        inst = self._makeOne()
        registrations = [
            ('http://example.com/foo/', 'package:path/', None, None)]
        inst._get_registrations = lambda *x: registrations
        inst.registrations = [('http://example.com/foo/', 'package:path/', None)]
        request = self._makeRequest()
        result = inst.generate('package:path/', request)
        self.assertEqual(result, 'http://example.com/foo/')
    def test_generate_quoting(self):
        from pyramid.interfaces import IStaticURLInfo
        config = testing.setUp()
        try:
            config.add_static_view('images', path='mypkg:templates')
            inst = self._makeOne()
            request = testing.DummyRequest()
            request.registry = config.registry
            inst = config.registry.getUtility(IStaticURLInfo)
            result = inst.generate('mypkg:templates/foo%2Fbar', request)
            self.assertEqual(result, 'http://example.com/images/foo%252Fbar')
        finally:
@@ -3948,8 +3924,7 @@
    def test_generate_route_url(self):
        inst = self._makeOne()
        registrations = [(None, 'package:path/', '__viewname/', None)]
        inst._get_registrations = lambda *x: registrations
        inst.registrations = [(None, 'package:path/', '__viewname/')]
        def route_url(n, **kw):
            self.assertEqual(n, '__viewname/')
            self.assertEqual(kw, {'subpath':'abc', 'a':1})
@@ -3961,8 +3936,7 @@
    def test_generate_url_unquoted_local(self):
        inst = self._makeOne()
        registrations = [(None, 'package:path/', '__viewname/', None)]
        inst._get_registrations = lambda *x: registrations
        inst.registrations = [(None, 'package:path/', '__viewname/')]
        def route_url(n, **kw):
            self.assertEqual(n, '__viewname/')
            self.assertEqual(kw, {'subpath':'abc def', 'a':1})
@@ -3974,16 +3948,15 @@
    def test_generate_url_quoted_remote(self):
        inst = self._makeOne()
        registrations = [('http://example.com/', 'package:path/', None, None)]
        inst._get_registrations = lambda *x: registrations
        inst.registrations = [('http://example.com/', 'package:path/', None)]
        request = self._makeRequest()
        result = inst.generate('package:path/abc def', request, a=1)
        self.assertEqual(result, 'http://example.com/abc%20def')
    def test_generate_url_with_custom_query(self):
        inst = self._makeOne()
        registrations = [('http://example.com/', 'package:path/', None, None)]
        inst._get_registrations = lambda *x: registrations
        registrations = [('http://example.com/', 'package:path/', None)]
        inst.registrations = registrations
        request = self._makeRequest()
        result = inst.generate('package:path/abc def', request, a=1,
                               _query='(openlayers)')
@@ -3992,112 +3965,171 @@
    def test_generate_url_with_custom_anchor(self):
        inst = self._makeOne()
        registrations = [('http://example.com/', 'package:path/', None, None)]
        inst._get_registrations = lambda *x: registrations
        inst.registrations = [('http://example.com/', 'package:path/', None)]
        request = self._makeRequest()
        uc = text_(b'La Pe\xc3\xb1a', 'utf-8')
        result = inst.generate('package:path/abc def', request, a=1,
                               _anchor=uc)
        result = inst.generate('package:path/abc def', request, a=1, _anchor=uc)
        self.assertEqual(result,
                         'http://example.com/abc%20def#La%20Pe%C3%B1a')
    def test_generate_url_cachebust(self):
        def cachebust(subpath, kw):
        def cachebust(request, subpath, kw):
            kw['foo'] = 'bar'
            return 'foo' + '/' + subpath, kw
        inst = self._makeOne()
        registrations = [(None, 'package:path/', '__viewname', cachebust)]
        inst._get_registrations = lambda *x: registrations
        inst.registrations = [(None, 'package:path/', '__viewname')]
        inst.cache_busters = [('package:path/', cachebust, False)]
        request = self._makeRequest()
        called = [False]
        def route_url(n, **kw):
            called[0] = True
            self.assertEqual(n, '__viewname')
            self.assertEqual(kw, {'subpath':'foo/abc', 'foo':'bar'})
            self.assertEqual(kw, {'subpath': 'foo/abc', 'foo': 'bar',
                                  'pathspec': 'package:path/abc',
                                  'rawspec': 'package:path/abc'})
        request.route_url = route_url
        inst.generate('package:path/abc', request)
        self.assertTrue(called[0])
    def test_generate_url_cachebust_abspath(self):
        here = os.path.dirname(__file__) + os.sep
        def cachebust(pathspec, subpath, kw):
            kw['foo'] = 'bar'
            return 'foo' + '/' + subpath, kw
        inst = self._makeOne()
        inst.registrations = [(None, here, '__viewname')]
        inst.cache_busters = [(here, cachebust, False)]
        request = self._makeRequest()
        called = [False]
        def route_url(n, **kw):
            called[0] = True
            self.assertEqual(n, '__viewname')
            self.assertEqual(kw, {'subpath': 'foo/abc', 'foo': 'bar',
                                  'pathspec': here + 'abc',
                                  'rawspec': here + 'abc'})
        request.route_url = route_url
        inst.generate(here + 'abc', request)
        self.assertTrue(called[0])
    def test_generate_url_cachebust_nomatch(self):
        def fake_cb(*a, **kw): raise AssertionError
        inst = self._makeOne()
        inst.registrations = [(None, 'package:path/', '__viewname')]
        inst.cache_busters = [('package:path2/', fake_cb, False)]
        request = self._makeRequest()
        called = [False]
        def route_url(n, **kw):
            called[0] = True
            self.assertEqual(n, '__viewname')
            self.assertEqual(kw, {'subpath': 'abc',
                                  'pathspec': 'package:path/abc',
                                  'rawspec': 'package:path/abc'})
        request.route_url = route_url
        inst.generate('package:path/abc', request)
        self.assertTrue(called[0])
    def test_generate_url_cachebust_with_overrides(self):
        config = testing.setUp()
        try:
            request = testing.DummyRequest()
            config.add_static_view('static', 'path')
            config.override_asset(
                'pyramid.tests.test_config:path/',
                'pyramid.tests.test_config:other_path/')
            def cb(val):
                def cb_(request, subpath, kw):
                    kw['_query'] = {'x': val}
                    return subpath, kw
                return cb_
            config.add_cache_buster('path', cb('foo'))
            result = request.static_url('path/foo.png')
            self.assertEqual(result, 'http://example.com/static/foo.png?x=foo')
            config.add_cache_buster('other_path', cb('bar'), explicit=True)
            result = request.static_url('path/foo.png')
            self.assertEqual(result, 'http://example.com/static/foo.png?x=bar')
        finally:
            testing.tearDown()
    def test_add_already_exists(self):
        config = DummyConfig()
        inst = self._makeOne()
        config = self._makeConfig(
            [('http://example.com/', 'package:path/', None)])
        inst.registrations = [('http://example.com/', 'package:path/', None)]
        inst.add(config, 'http://example.com', 'anotherpackage:path')
        expected = [
            ('http://example.com/',  'anotherpackage:path/', None, None)]
        self._assertRegistrations(config, expected)
        expected = [('http://example.com/', 'anotherpackage:path/', None)]
        self.assertEqual(inst.registrations, expected)
    def test_add_package_root(self):
        config = DummyConfig()
        inst = self._makeOne()
        config = self._makeConfig()
        inst.add(config, 'http://example.com', 'package:')
        expected = [('http://example.com/',  'package:', None, None)]
        self._assertRegistrations(config, expected)
        expected = [('http://example.com/', 'package:', None)]
        self.assertEqual(inst.registrations, expected)
    def test_add_url_withendslash(self):
        config = DummyConfig()
        inst = self._makeOne()
        config = self._makeConfig()
        inst.add(config, 'http://example.com/', 'anotherpackage:path')
        expected = [
            ('http://example.com/', 'anotherpackage:path/', None, None)]
        self._assertRegistrations(config, expected)
        expected = [('http://example.com/', 'anotherpackage:path/', None)]
        self.assertEqual(inst.registrations, expected)
    def test_add_url_noendslash(self):
        config = DummyConfig()
        inst = self._makeOne()
        config = self._makeConfig()
        inst.add(config, 'http://example.com', 'anotherpackage:path')
        expected = [
            ('http://example.com/', 'anotherpackage:path/', None, None)]
        self._assertRegistrations(config, expected)
        expected = [('http://example.com/', 'anotherpackage:path/', None)]
        self.assertEqual(inst.registrations, expected)
    def test_add_url_noscheme(self):
        config = DummyConfig()
        inst = self._makeOne()
        config = self._makeConfig()
        inst.add(config, '//example.com', 'anotherpackage:path')
        expected = [('//example.com/', 'anotherpackage:path/', None, None)]
        self._assertRegistrations(config, expected)
        expected = [('//example.com/', 'anotherpackage:path/', None)]
        self.assertEqual(inst.registrations, expected)
    def test_add_viewname(self):
        from pyramid.security import NO_PERMISSION_REQUIRED
        from pyramid.static import static_view
        config = self._makeConfig()
        config = DummyConfig()
        inst = self._makeOne()
        inst.add(config, 'view', 'anotherpackage:path', cache_max_age=1)
        expected = [(None, 'anotherpackage:path/', '__view/', None)]
        self._assertRegistrations(config, expected)
        expected = [(None, 'anotherpackage:path/', '__view/')]
        self.assertEqual(inst.registrations, expected)
        self.assertEqual(config.route_args, ('__view/', 'view/*subpath'))
        self.assertEqual(config.view_kw['permission'], NO_PERMISSION_REQUIRED)
        self.assertEqual(config.view_kw['view'].__class__, static_view)
    def test_add_viewname_with_route_prefix(self):
        config = self._makeConfig()
        config = DummyConfig()
        config.route_prefix = '/abc'
        inst = self._makeOne()
        inst.add(config, 'view', 'anotherpackage:path',)
        expected = [(None, 'anotherpackage:path/', '__/abc/view/', None)]
        self._assertRegistrations(config, expected)
        expected = [(None, 'anotherpackage:path/', '__/abc/view/')]
        self.assertEqual(inst.registrations, expected)
        self.assertEqual(config.route_args, ('__/abc/view/', 'view/*subpath'))
    def test_add_viewname_with_permission(self):
        config = self._makeConfig()
        config = DummyConfig()
        inst = self._makeOne()
        inst.add(config, 'view', 'anotherpackage:path', cache_max_age=1,
                 permission='abc')
        self.assertEqual(config.view_kw['permission'], 'abc')
    def test_add_viewname_with_context(self):
        config = self._makeConfig()
        config = DummyConfig()
        inst = self._makeOne()
        inst.add(config, 'view', 'anotherpackage:path', cache_max_age=1,
                 context=DummyContext)
        self.assertEqual(config.view_kw['context'], DummyContext)
    def test_add_viewname_with_for_(self):
        config = self._makeConfig()
        config = DummyConfig()
        inst = self._makeOne()
        inst.add(config, 'view', 'anotherpackage:path', cache_max_age=1,
                 for_=DummyContext)
        self.assertEqual(config.view_kw['context'], DummyContext)
    def test_add_viewname_with_renderer(self):
        config = self._makeConfig()
        config = DummyConfig()
        inst = self._makeOne()
        inst.add(config, 'view', 'anotherpackage:path', cache_max_age=1,
                 renderer='mypackage:templates/index.pt')
@@ -4105,22 +4137,71 @@
                         'mypackage:templates/index.pt')
    def test_add_cachebust_prevented(self):
        config = self._makeConfig()
        config = DummyConfig()
        config.registry.settings['pyramid.prevent_cachebust'] = True
        inst = self._makeOne()
        inst.add(config, 'view', 'mypackage:path', cachebust=True)
        cachebust = config.registry._static_url_registrations[0][3]
        self.assertEqual(cachebust, None)
        cachebust = DummyCacheBuster('foo')
        inst.add_cache_buster(config, 'mypackage:path', cachebust)
        self.assertEqual(inst.cache_busters, [])
    def test_add_cachebust_custom(self):
        config = self._makeConfig()
    def test_add_cachebuster(self):
        config = DummyConfig()
        inst = self._makeOne()
        inst.add(config, 'view', 'mypackage:path',
                 cachebust=DummyCacheBuster('foo'))
        cachebust = config.registry._static_url_registrations[0][3]
        subpath, kw = cachebust('some/path', {})
        inst.add_cache_buster(config, 'mypackage:path', DummyCacheBuster('foo'))
        cachebust = inst.cache_busters[-1][1]
        subpath, kw = cachebust(None, 'some/path', {})
        self.assertEqual(subpath, 'some/path')
        self.assertEqual(kw['x'], 'foo')
    def test_add_cachebuster_abspath(self):
        here = os.path.dirname(__file__)
        config = DummyConfig()
        inst = self._makeOne()
        cb = DummyCacheBuster('foo')
        inst.add_cache_buster(config, here, cb)
        self.assertEqual(inst.cache_busters, [(here + '/', cb, False)])
    def test_add_cachebuster_overwrite(self):
        config = DummyConfig()
        inst = self._makeOne()
        cb1 = DummyCacheBuster('foo')
        cb2 = DummyCacheBuster('bar')
        inst.add_cache_buster(config, 'mypackage:path/', cb1)
        inst.add_cache_buster(config, 'mypackage:path', cb2)
        self.assertEqual(inst.cache_busters,
                         [('mypackage:path/', cb2, False)])
    def test_add_cachebuster_overwrite_explicit(self):
        config = DummyConfig()
        inst = self._makeOne()
        cb1 = DummyCacheBuster('foo')
        cb2 = DummyCacheBuster('bar')
        inst.add_cache_buster(config, 'mypackage:path/', cb1)
        inst.add_cache_buster(config, 'mypackage:path', cb2, True)
        self.assertEqual(inst.cache_busters,
                         [('mypackage:path/', cb1, False),
                          ('mypackage:path/', cb2, True)])
    def test_add_cachebuster_for_more_specific_path(self):
        config = DummyConfig()
        inst = self._makeOne()
        cb1 = DummyCacheBuster('foo')
        cb2 = DummyCacheBuster('bar')
        cb3 = DummyCacheBuster('baz')
        cb4 = DummyCacheBuster('xyz')
        cb5 = DummyCacheBuster('w')
        inst.add_cache_buster(config, 'mypackage:path', cb1)
        inst.add_cache_buster(config, 'mypackage:path/sub', cb2, True)
        inst.add_cache_buster(config, 'mypackage:path/sub/other', cb3)
        inst.add_cache_buster(config, 'mypackage:path/sub/other', cb4, True)
        inst.add_cache_buster(config, 'mypackage:path/sub/less', cb5, True)
        self.assertEqual(
            inst.cache_busters,
            [('mypackage:path/', cb1, False),
             ('mypackage:path/sub/other/', cb3, False),
             ('mypackage:path/sub/', cb2, True),
             ('mypackage:path/sub/less/', cb5, True),
             ('mypackage:path/sub/other/', cb4, True)])
class Test_view_description(unittest.TestCase):
    def _callFUT(self, view):
@@ -4141,8 +4222,13 @@
class DummyRegistry:
    utility = None
    def __init__(self):
        self.settings = {}
    def queryUtility(self, type_or_iface, name=None, default=None):
        return self.utility or default
from zope.interface import implementer
from pyramid.interfaces import (
@@ -4204,6 +4290,9 @@
        return self.permitted
class DummyConfig:
    def __init__(self):
        self.registry = DummyRegistry()
    route_prefix = ''
    def add_route(self, *args, **kw):
        self.route_args = args
@@ -4236,7 +4325,8 @@
class DummyCacheBuster(object):
    def __init__(self, token):
        self.token = token
    def pregenerate(self, pathspec, subpath, kw):
    def __call__(self, request, subpath, kw):
        kw['x'] = self.token
        return subpath, kw
pyramid/tests/test_static.py
@@ -383,31 +383,31 @@
    def test_token(self):
        fut = self._makeOne().tokenize
        self.assertEqual(fut('whatever'), 'foo')
        self.assertEqual(fut(None, 'whatever', None), 'foo')
    def test_pregenerate(self):
        fut = self._makeOne().pregenerate
    def test_it(self):
        fut = self._makeOne()
        self.assertEqual(
            fut('foo', ('bar',), {}),
            (('bar',), {'_query': {'x': 'foo'}}))
            fut('foo', 'bar', {}),
            ('bar', {'_query': {'x': 'foo'}}))
    def test_pregenerate_change_param(self):
        fut = self._makeOne('y').pregenerate
    def test_change_param(self):
        fut = self._makeOne('y')
        self.assertEqual(
            fut('foo', ('bar',), {}),
            (('bar',), {'_query': {'y': 'foo'}}))
            fut('foo', 'bar', {}),
            ('bar', {'_query': {'y': 'foo'}}))
    def test_pregenerate_query_is_already_tuples(self):
        fut = self._makeOne().pregenerate
    def test_query_is_already_tuples(self):
        fut = self._makeOne()
        self.assertEqual(
            fut('foo', ('bar',), {'_query': [('a', 'b')]}),
            (('bar',), {'_query': (('a', 'b'), ('x', 'foo'))}))
            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
    def test_query_is_tuple_of_tuples(self):
        fut = self._makeOne()
        self.assertEqual(
            fut('foo', ('bar',), {'_query': (('a', 'b'),)}),
            (('bar',), {'_query': (('a', 'b'), ('x', 'foo'))}))
            fut('foo', 'bar', {'_query': (('a', 'b'),)}),
            ('bar', {'_query': (('a', 'b'), ('x', 'foo'))}))
class TestManifestCacheBuster(unittest.TestCase):
@@ -417,55 +417,55 @@
    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'], {}))
        fut = self._makeOne(manifest_path)
        self.assertEqual(fut('foo', 'bar', {}), ('bar', {}))
        self.assertEqual(
            fut('foo', ('css', 'main.css'), {}),
            (['css', 'main-test.css'], {}))
            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'], {}))
        fut = self._makeOne('fixtures/manifest.json')
        self.assertEqual(fut('foo', 'bar', {}), ('bar', {}))
        self.assertEqual(
            fut('foo', ('css', 'main.css'), {}),
            (['css', 'main-test.css'], {}))
            fut('foo', 'css/main.css', {}),
            ('css/main-test.css', {}))
    def test_it_with_absspec(self):
        fut = self._makeOne('pyramid.tests:fixtures/manifest.json').pregenerate
        self.assertEqual(fut('foo', ('bar',), {}), (['bar'], {}))
        fut = self._makeOne('pyramid.tests:fixtures/manifest.json')
        self.assertEqual(fut('foo', 'bar', {}), ('bar', {}))
        self.assertEqual(
            fut('foo', ('css', 'main.css'), {}),
            (['css', 'main-test.css'], {}))
            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
        fut = inst
        # test without a valid manifest
        self.assertEqual(
            fut('foo', ('css', 'main.css'), {}),
            (['css', 'main.css'], {}))
            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'], {}))
            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'], {}))
            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'], {}))
            fut('foo', 'css/main.css', {}),
            ('css/main-678b7c80.css', {}))
    def test_invalid_manifest(self):
        self.assertRaises(IOError, lambda: self._makeOne('foo'))