diff options
| author | Michael Merickel <michael@merickel.org> | 2019-12-04 01:13:52 -0600 |
|---|---|---|
| committer | Michael Merickel <michael@merickel.org> | 2019-12-04 23:53:31 -0600 |
| commit | f6cb1efa8fba683bdc5c9b4a645f9357fe2e6208 (patch) | |
| tree | c78f1d8c1c2d695b14f1bfd5358ca05e0c5b2ee0 | |
| parent | 4d276efe5fd806b74d604c3c8817c0c72808c491 (diff) | |
| download | pyramid-f6cb1efa8fba683bdc5c9b4a645f9357fe2e6208.tar.gz pyramid-f6cb1efa8fba683bdc5c9b4a645f9357fe2e6208.tar.bz2 pyramid-f6cb1efa8fba683bdc5c9b4a645f9357fe2e6208.zip | |
negotiate the best static asset using supported encodings
| -rw-r--r-- | CHANGES.rst | 6 | ||||
| -rw-r--r-- | docs/narr/assets.rst | 31 | ||||
| -rw-r--r-- | src/pyramid/config/views.py | 22 | ||||
| -rw-r--r-- | src/pyramid/static.py | 173 | ||||
| -rw-r--r-- | tests/fixtures/static/encoded.html | 15 | ||||
| -rw-r--r-- | tests/fixtures/static/encoded.html.gz | bin | 0 -> 187 bytes | |||
| -rw-r--r-- | tests/pkgs/static_encodings/__init__.py | 2 | ||||
| -rw-r--r-- | tests/test_config/test_views.py | 4 | ||||
| -rw-r--r-- | tests/test_integration.py | 55 | ||||
| -rw-r--r-- | tests/test_static.py | 68 |
10 files changed, 350 insertions, 26 deletions
diff --git a/CHANGES.rst b/CHANGES.rst index 537ca4671..8cf6eaff6 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -64,6 +64,12 @@ Features to predicate factories as their second argument. See https://github.com/Pylons/pyramid/pull/3514 +- Added support for serving pre-compressed static assets by using the + ``content_encodings`` argument of + ``pyramid.config.Configurator.add_static_view`` and + ``pyramid.static.static_view``. + See https://github.com/Pylons/pyramid/pull/3537 + Deprecations ------------ diff --git a/docs/narr/assets.rst b/docs/narr/assets.rst index d1d64f0c3..f9d30563e 100644 --- a/docs/narr/assets.rst +++ b/docs/narr/assets.rst @@ -190,6 +190,37 @@ such a request. The :meth:`~pyramid.request.Request.static_url` API is discussed in more detail later in this chapter. .. index:: + single: pre-compressed assets + +.. _pre_compressed_assets: + +Serving Pre-compressed Assets +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. versionadded:: 2.0 + +It's possible to configure :app:`Pyramid` to serve pre-compressed static assets. +This can greatly reduce the bandwidth required to serve assets - most modern browsers support ``gzip``, ``deflate``, and ``br`` (brotli) encoded responses. +A client declares support for encoded responses using the ``Accept-Encoding`` HTTP header. For example, ``Accept-Encoding: gzip, default, br``. +The response will then contain the pre-compressed content with the ``Content-Encoding`` header set to the matched encoding. +This feature assumes that the static assets exist unencoded (``identity`` encoding) as well as in zero or more encoded formats. +If the encoded version of a file is missing, or the client doesn't declare support for the encoded version, the unencoded version is returned instead. + +In order to configure this in your application, the first step is to compress your assets. +For example, update your static asset pipeline to export ``.gz`` versions of every file. +Second, add ``content_encodings=['gzip']`` when invoking :meth:`pyramid.config.Configurator.add_static_view`. + +The encoded file extensions are determined by :attr:`mimetypes.encodings_map`. +So, if your desired encoding is missing, you'll need to add it there: + +.. code-block:: python + + import mimetypes + mimetypes.encodings_map['.br'] = 'br' # add brotli + +It is not necessary for every file to support every encoding, but :app:`Pyramid` will not serve an encoding that is not declared. + +.. index:: single: generating static asset urls single: static asset urls pair: assets; generating urls diff --git a/src/pyramid/config/views.py b/src/pyramid/config/views.py index afb685f93..509a64b2b 100644 --- a/src/pyramid/config/views.py +++ b/src/pyramid/config/views.py @@ -1943,6 +1943,16 @@ class ViewsConfiguratorMixin(object): prefix*. By default, this argument is ``None``, meaning that no particular Expires or Cache-Control headers are set in the response. + The ``content_encodings`` keyword argument is a list of alternative + file encodings supported in the ``Accept-Encoding`` HTTP Header. + Alternative files are found using file extensions defined in + :attr:`mimetypes.encodings_map`. An encoded asset will be returned + with the ``Content-Encoding`` header set to the selected encoding. + If the asset contains alternative encodings then the + ``Accept-Encoding`` value will be added to the response's ``Vary`` + header. By default, the list is empty and no alternatives will be + supported. + The ``permission`` keyword argument is used to specify the :term:`permission` required by a user to execute the static view. By default, it is the string @@ -2019,6 +2029,11 @@ class ViewsConfiguratorMixin(object): static_url('mypackage:images/logo.png', request) See :ref:`static_assets_section` for more information. + + .. versionchanged:: 2.0 + + Added the ``content_encodings`` argument. + """ spec = self._make_spec(path) info = self._get_static_info() @@ -2191,10 +2206,15 @@ class StaticURLInfo(object): # it's a view name url = None cache_max_age = extra.pop('cache_max_age', None) + content_encodings = extra.pop('content_encodings', []) # create a view view = static_view( - spec, cache_max_age=cache_max_age, use_subpath=True + spec, + cache_max_age=cache_max_age, + use_subpath=True, + reload=config.registry.settings['pyramid.reload_assets'], + content_encodings=content_encodings, ) # Mutate extra to allow factory, etc to be passed through here. diff --git a/src/pyramid/static.py b/src/pyramid/static.py index e3561e93e..7870b803e 100644 --- a/src/pyramid/static.py +++ b/src/pyramid/static.py @@ -1,9 +1,10 @@ # -*- coding: utf-8 -*- from functools import lru_cache import json +import mimetypes import os -from os.path import getmtime, normcase, normpath, join, isdir, exists +from os.path import getmtime, getsize, normcase, normpath, join, isdir, exists from pkg_resources import resource_exists, resource_filename, resource_isdir @@ -53,6 +54,19 @@ class static_view(object): the static application will consider request.environ[``PATH_INFO``] as ``PATH_INFO`` input. By default, this is ``False``. + ``reload`` controls whether a cache of files is maintained or the asset + subsystem is queried per-request to determine what files are available. + By default, this is ``False`` and new files added while the process is + running are not recognized. + + ``content_encodings`` is a list of alternative file encodings supported + in the ``Accept-Encoding`` HTTP Header. Alternative files are found using + file extensions defined in :attr:`mimetypes.encodings_map`. An encoded + asset will be returned with the ``Content-Encoding`` header set to the + selected encoding. If the asset contains alternative encodings then the + ``Accept-Encoding`` value will be added to the response's ``Vary`` header. + By default, the list is empty and no alternatives will be supported. + .. note:: If the ``root_dir`` is relative to a :term:`package`, or is a @@ -61,6 +75,11 @@ class static_view(object): assets within the named ``root_dir`` package-relative directory. However, if the ``root_dir`` is absolute, configuration will not be able to override the assets it contains. + + .. versionchanged:: 2.0 + + Added ``reload`` and ``content_encodings`` options. + """ def __init__( @@ -70,6 +89,8 @@ class static_view(object): package_name=None, use_subpath=False, index='index.html', + reload=False, + content_encodings=(), ): # package_name is for bw compat; it is preferred to pass in a # package-relative path as root_dir @@ -83,8 +104,36 @@ class static_view(object): self.docroot = docroot self.norm_docroot = normcase(normpath(docroot)) self.index = index + self.reload = reload + self.content_encodings = _compile_content_encodings(content_encodings) + self.filemap = {} def __call__(self, context, request): + resource_name = self.get_resource_name(request) + files = self.get_possible_files(resource_name) + filepath, content_encoding = self.find_best_match(request, files) + if filepath is None: + raise HTTPNotFound(request.url) + + content_type, _ = _guess_type(resource_name) + response = FileResponse( + filepath, + request, + self.cache_max_age, + content_type, + content_encoding, + ) + if len(files) > 1: + _add_vary(response, 'Accept-Encoding') + return response + + def get_resource_name(self, request): + """ + Return the computed name of the requested resource. + + The returned file is not guaranteed to exist. + + """ if self.use_subpath: path_tuple = request.subpath else: @@ -94,46 +143,126 @@ class static_view(object): if path is None: raise HTTPNotFound('Out of bounds: %s' % request.url) + # normalize asset spec or fs path into resource_path if self.package_name: # package resource resource_path = '%s/%s' % (self.docroot.rstrip('/'), path) if resource_isdir(self.package_name, resource_path): if not request.path_url.endswith('/'): - self.add_slash_redirect(request) + raise self.add_slash_redirect(request) resource_path = '%s/%s' % ( resource_path.rstrip('/'), self.index, ) - if not resource_exists(self.package_name, resource_path): - raise HTTPNotFound(request.url) - filepath = resource_filename(self.package_name, resource_path) - else: # filesystem file - # os.path.normpath converts / to \ on windows - filepath = normcase(normpath(join(self.norm_docroot, path))) - if isdir(filepath): + resource_path = normcase(normpath(join(self.norm_docroot, path))) + if isdir(resource_path): if not request.path_url.endswith('/'): - self.add_slash_redirect(request) - filepath = join(filepath, self.index) - if not exists(filepath): - raise HTTPNotFound(request.url) + raise self.add_slash_redirect(request) + resource_path = join(resource_path, self.index) - content_type, content_encoding = _guess_type(filepath) - return FileResponse( - filepath, - request, - self.cache_max_age, - content_type, - content_encoding=None, - ) + return resource_path + + def find_resource_path(self, name): + """ + Return the absolute path to the resource or ``None`` if it doesn't + exist. + + """ + if self.package_name: + if resource_exists(self.package_name, name): + return resource_filename(self.package_name, name) + + elif exists(name): + return name + + def get_possible_files(self, resource_name): + """ Return a sorted list of ``(size, encoding, path)`` entries.""" + result = self.filemap.get(resource_name) + if result is not None: + return result + + # XXX we could put a lock around this work but worst case scenario a + # couple requests scan the disk for files at the same time and then + # the cache is set going forward so do not bother + result = [] + + # add the identity + path = self.find_resource_path(resource_name) + if path: + result.append((path, None)) + + # add each file we find for the supported encodings + # we don't mind adding multiple files for the same encoding if there + # are copies with different extensions because we sort by size so the + # smallest is always found first and the rest ignored + for encoding, extensions in self.content_encodings.items(): + for ext in extensions: + encoded_name = resource_name + ext + path = self.find_resource_path(encoded_name) + if path: + result.append((path, encoding)) + + # sort the files by size, smallest first + result.sort(key=lambda x: getsize(x[0])) + + # only cache the results if reload is disabled + if not self.reload: + self.filemap[resource_name] = result + return result + + def find_best_match(self, request, files): + """ Return ``(path | None, encoding)``.""" + # if the client did not specify encodings then assume only the + # identity is acceptable + if not request.accept_encoding: + identity_path = next( + (path for path, encoding in files if encoding is None), None, + ) + return identity_path, None + + # find encodings the client will accept + acceptable_encodings = { + x[0] + for x in request.accept_encoding.acceptable_offers( + [encoding for path, encoding in files if encoding is not None] + ) + } + acceptable_encodings.add(None) + + # return the smallest file from the acceptable encodings + # we know that files is sorted by size, smallest first + for path, encoding in files: + if encoding in acceptable_encodings: + return path, encoding def add_slash_redirect(self, request): url = request.path_url + '/' qs = request.query_string if qs: url = url + '?' + qs - raise HTTPMovedPermanently(url) + return HTTPMovedPermanently(url) + + +def _compile_content_encodings(encodings): + """ + Convert mimetypes.encodings_map into a dict of + ``(encoding) -> [file extensions]``. + + """ + result = {} + for ext, encoding in mimetypes.encodings_map.items(): + if encoding in encodings: + result.setdefault(encoding, []).append(ext) + return result + + +def _add_vary(response, option): + vary = response.vary or [] + if not any(x.lower() == option.lower() for x in vary): + vary.append(option) + response.vary = vary _seps = set(['/', os.sep]) diff --git a/tests/fixtures/static/encoded.html b/tests/fixtures/static/encoded.html new file mode 100644 index 000000000..0999b4f1b --- /dev/null +++ b/tests/fixtures/static/encoded.html @@ -0,0 +1,15 @@ +<!-- + when modified, re-run: + gzip -k encoded.html +--> +<html> +<head> +<title> +A Simple HTML Document +</title> +</head> +<body> +<p>This is a very simple HTML document</p> +<p>It only has two paragraphs</p> +</body> +</html> diff --git a/tests/fixtures/static/encoded.html.gz b/tests/fixtures/static/encoded.html.gz Binary files differnew file mode 100644 index 000000000..afcc25768 --- /dev/null +++ b/tests/fixtures/static/encoded.html.gz diff --git a/tests/pkgs/static_encodings/__init__.py b/tests/pkgs/static_encodings/__init__.py new file mode 100644 index 000000000..3b86a1a15 --- /dev/null +++ b/tests/pkgs/static_encodings/__init__.py @@ -0,0 +1,2 @@ +def includeme(config): + config.add_static_view('/', 'tests:fixtures', content_encodings=['gzip']) diff --git a/tests/test_config/test_views.py b/tests/test_config/test_views.py index a1e975756..baa87dd6b 100644 --- a/tests/test_config/test_views.py +++ b/tests/test_config/test_views.py @@ -4174,7 +4174,9 @@ class DummyRegistry: utility = None def __init__(self): - self.settings = {} + self.settings = { + 'pyramid.reload_assets': False, + } def queryUtility(self, type_or_iface, name=None, default=None): return self.utility or default diff --git a/tests/test_integration.py b/tests/test_integration.py index 331542d7d..8a4575d7b 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -73,8 +73,8 @@ class IntegrationBase(object): root_factory=self.root_factory, package=self.package ) config.include(self.package) - app = config.make_wsgi_app() - self.testapp = TestApp(app) + self.app = config.make_wsgi_app() + self.testapp = TestApp(self.app) self.config = config def tearDown(self): @@ -227,6 +227,57 @@ class TestStaticAppUsingAssetSpec(StaticAppBase, unittest.TestCase): package = 'tests.pkgs.static_assetspec' +class TestStaticAppWithEncodings(IntegrationBase, unittest.TestCase): + package = 'tests.pkgs.static_encodings' + + # XXX webtest actually runs response.decode_content() and so we can't + # use it to test gzip- or deflate-encoded responses to see if they + # were transferred correctly + def _getResponse(self, *args, **kwargs): + from pyramid.request import Request + + req = Request.blank(*args, **kwargs) + return req.get_response(self.app) + + def test_no_accept(self): + res = self._getResponse('/static/encoded.html') + self.assertEqual(res.headers['Vary'], 'Accept-Encoding') + self.assertNotIn('Content-Encoding', res.headers) + _assertBody( + res.body, os.path.join(here, 'fixtures/static/encoded.html') + ) + + def test_unsupported_accept(self): + res = self._getResponse( + '/static/encoded.html', + headers={'Accept-Encoding': 'br, foo, bar'}, + ) + self.assertEqual(res.headers['Vary'], 'Accept-Encoding') + self.assertNotIn('Content-Encoding', res.headers) + _assertBody( + res.body, os.path.join(here, 'fixtures/static/encoded.html') + ) + + def test_accept_gzip(self): + res = self._getResponse( + '/static/encoded.html', + headers={'Accept-Encoding': 'br, foo, gzip'}, + ) + self.assertEqual(res.headers['Vary'], 'Accept-Encoding') + self.assertEqual(res.headers['Content-Encoding'], 'gzip') + _assertBody( + res.body, os.path.join(here, 'fixtures/static/encoded.html.gz') + ) + + def test_accept_gzip_returns_identity(self): + res = self._getResponse( + '/static/index.html', headers={'Accept-Encoding': 'gzip'} + ) + self.assertNotIn('Vary', res.headers) + self.assertNotIn('Content-Encoding', res.headers) + _assertBody(res.body, os.path.join(here, 'fixtures/static/index.html')) + + class TestStaticAppNoSubpath(unittest.TestCase): staticapp = static_view(os.path.join(here, 'fixtures'), use_subpath=False) diff --git a/tests/test_static.py b/tests/test_static.py index a323b1d89..3d0deda3f 100644 --- a/tests/test_static.py +++ b/tests/test_static.py @@ -39,6 +39,8 @@ class Test_static_view_use_subpath_False(unittest.TestCase): self.assertEqual(inst.docroot, 'resource_name') self.assertEqual(inst.cache_max_age, 3600) self.assertEqual(inst.index, 'index.html') + self.assertEqual(inst.reload, False) + self.assertEqual(inst.content_encodings, {}) def test_call_adds_slash_path_info_empty(self): inst = self._makeOne('tests:fixtures/static') @@ -252,6 +254,8 @@ class Test_static_view_use_subpath_True(unittest.TestCase): self.assertEqual(inst.docroot, 'resource_name') self.assertEqual(inst.cache_max_age, 3600) self.assertEqual(inst.index, 'index.html') + self.assertEqual(inst.reload, False) + self.assertEqual(inst.content_encodings, {}) def test_call_adds_slash_path_info_empty(self): inst = self._makeOne('tests:fixtures/static') @@ -403,6 +407,70 @@ class Test_static_view_use_subpath_True(unittest.TestCase): self.assertRaises(HTTPNotFound, inst, context, request) +class Test_static_view_content_encodings(unittest.TestCase): + def _getTargetClass(self): + from pyramid.static import static_view + + return static_view + + def _makeOne(self, *arg, **kw): + return self._getTargetClass()(*arg, **kw) + + def _makeRequest(self, kw=None): + from pyramid.request import Request + + environ = { + 'wsgi.url_scheme': 'http', + 'wsgi.version': (1, 0), + 'SERVER_NAME': 'example.com', + 'SERVER_PORT': '6543', + 'PATH_INFO': '/', + 'SCRIPT_NAME': '', + 'REQUEST_METHOD': 'GET', + } + if kw is not None: + environ.update(kw) + return Request(environ=environ) + + def test_call_without_accept(self): + inst = self._makeOne( + 'tests:fixtures/static', content_encodings=['gzip'] + ) + request = self._makeRequest({'PATH_INFO': '/encoded.html'}) + context = DummyContext() + + res = inst(context, request) + self.assertEqual(res.headers['Vary'], 'Accept-Encoding') + self.assertNotIn('Content-Encoding', res.headers) + self.assertEqual(len(res.body), 221) + + def test_call_with_accept_gzip(self): + inst = self._makeOne( + 'tests:fixtures/static', content_encodings=['gzip'] + ) + request = self._makeRequest( + {'PATH_INFO': '/encoded.html', 'HTTP_ACCEPT_ENCODING': 'gzip'} + ) + context = DummyContext() + + res = inst(context, request) + self.assertEqual(res.headers['Vary'], 'Accept-Encoding') + self.assertEqual(res.headers['Content-Encoding'], 'gzip') + self.assertEqual(len(res.body), 187) + + def test_call_get_possible_files_is_cached(self): + inst = self._makeOne('tests:fixtures/static') + result1 = inst.get_possible_files('tests:fixtures/static/encoded.html') + result2 = inst.get_possible_files('tests:fixtures/static/encoded.html') + self.assertIs(result1, result2) + + def test_call_get_possible_files_is_not_cached(self): + inst = self._makeOne('tests:fixtures/static', reload=True) + result1 = inst.get_possible_files('tests:fixtures/static/encoded.html') + result2 = inst.get_possible_files('tests:fixtures/static/encoded.html') + self.assertIsNot(result1, result2) + + class TestQueryStringConstantCacheBuster(unittest.TestCase): def _makeOne(self, param=None): from pyramid.static import QueryStringConstantCacheBuster as cls |
