Source code for score.webassets._init

# Copyright © 2015-2017 STRG.AT GmbH, Vienna, Austria
#
# This file is part of the The SCORE Framework.
#
# The SCORE Framework and all its parts are free software: you can redistribute
# them and/or modify them under the terms of the GNU Lesser General Public
# License version 3 as published by the Free Software Foundation which is in the
# file named COPYING.LESSER.txt.
#
# The SCORE Framework and all its parts are distributed without any WARRANTY;
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
# PARTICULAR PURPOSE. For more details see the GNU Lesser General Public
# License.
#
# If you have not received a copy of the GNU Lesser General Public License see
# http://www.gnu.org/licenses/.
#
# The License-Agreement realised between you as Licensee and STRG.AT GmbH as
# Licenser including the issue of its valid conclusion and its pre- and
# post-contractual effects is governed by the laws of Austria. Any disputes
# concerning this License-Agreement including the issue of its valid conclusion
# and its pre- and post-contractual effects are exclusively decided by the
# competent court, in whose district STRG.AT GmbH has its registered seat, at
# the discretion of STRG.AT GmbH also the competent court, in whose district the
# Licensee has his registered seat, an establishment or assets.

from score.init import (
    ConfiguredModule, ConfigurationError, parse_list, parse_bool)
import os
import email.utils
import time
import xxhash
from collections import namedtuple

Request = namedtuple('Request', ('path', 'GET', 'headers'))

defaults = {
    'rootdir': None,
    'modules': [],
    'freeze': False,
    'tpl.autobundle': False,
}


[docs]def init(confdict, http=None, tpl=None): """ Initializes this module acoording to :ref:`our module initialization guidelines <module_initialization>` with the following configuration keys: :confkey:`rootdir` :confdefault:`None` The folder where this module will store the bundled assets. :confkey:`modules` :confdefault:`[]` A list of configured score modules to retrieve proxy objects from. The configured modules listed here must all expose a ``score_webassets_proxy()`` method, that returns a :class:`WebassetsProxy` object. :confkey:`freeze` :confdefault:`False` Option for speeding up :term:`asset hash` calculations. See :ref:`webassets_freezing` for valid values. :confkey:`tpl.autobundle` :confdefault:`False` Whether the webassets_* functions registered with :mod:`score.tpl` should provide :term:`bundles <asset bundle>` instead of separate files. This should be set to `True` on deployment systems to speed up web page rendering. """ conf = dict(defaults.items()) conf.update(confdict) modules = parse_list(conf['modules']) if conf['rootdir'] and not os.path.exists(conf['rootdir']): raise ConfigurationError( 'score.webassets', 'Configured rootdir does not exist') try: freeze = parse_bool(conf['freeze']) except ValueError: freeze = conf['freeze'] return ConfiguredWebassetsModule(http, tpl, modules, conf['rootdir'], freeze, parse_bool(conf['tpl.autobundle']))
[docs]class ConfiguredWebassetsModule(ConfiguredModule): """ This module's :class:`configuration class <score.init.ConfiguredModule>`. """ def __init__(self, http, tpl, modules, rootdir, freeze, tpl_autobundle): super().__init__(__package__) self.http = http self.tpl = tpl self.modules = modules self.rootdir = rootdir self.freeze = freeze self.tpl_autobundle = tpl_autobundle self._frozen_versions = {} if tpl: self._register_tpl_globals() if http: self._register_http_route() def _register_tpl_globals(self): self.tpl.filetypes['text/html'].add_global( 'webassets_link', self._generate_html_tag, escape=False) self.tpl.filetypes['text/html'].add_global( 'webassets_content', self._generate_html_content, escape=False) def _register_http_route(self): @self.http.newroute('score.webassets', '/_assets/{module}/{path>.*}') def webassets(ctx, module, paths): request = ctx.http.request result = self.get_request_response(Request( '/' + request.path.lstrip('/').split('/', maxsplit=1)[1], request.GET, request.headers, )) if isinstance(result, int): ctx.http.response.status = result else: for header, value in result[0].items(): ctx.http.response.headers[header] = value ctx.http.response.text = result[1] @webassets.vars2url def webassets_vars2url(ctx, module, paths): return '/_assets' + self.get_bundle_url(module, paths) @webassets.match2vars def webassets_match2vars(ctx, matches): return { 'module': matches['module'], 'paths': matches['path'], } def _finalize(self, score): self.proxies = dict( (module, score._modules[module].score_webassets_proxy()) for module in self.modules) def _generate_html_tag(self, module, *paths): if not paths: proxy = self._get_proxy(module) paths = self._get_proxy_default_paths(proxy) if not paths: return '' else: proxy = self._get_proxy(module, *paths) if self.tpl_autobundle: url = self.http.url(None, 'score.webassets', module, paths) return proxy.render_url(url) else: parts = [] for path in paths: url = self.http.url(None, 'score.webassets', module, [path]) parts.append(proxy.render_url(url)) return ''.join(parts) def _generate_html_content(self, module, *paths): if not paths: paths = None return self.get_bundle_content(module, paths) def _get_proxy_default_paths(self, proxy): if not self.freeze: return list(proxy.iter_default_paths()) if not hasattr(self, '_proxy_default_paths'): self._proxy_default_paths = {} if proxy not in self._proxy_default_paths: self._proxy_default_paths[proxy] = list(proxy.iter_default_paths()) return self._proxy_default_paths[proxy]
[docs] def get_asset_content(self, module, path): """ Returns the content of the asset identified my its *module* and *path*. """ proxy = self._get_proxy(module, path) return proxy.render(path)
[docs] def get_asset_mimetype(self, module, path): """ Returns the mime type of the asset identified my its *module* and *path*. """ proxy = self._get_proxy(module, path) return proxy.mimetype(path)
[docs] def get_asset_hash(self, module, path): """ Provides the :term:`hash <asset hash>` of the asset identified my its *module* and *path*. """ if isinstance(self.freeze, str): return self.freeze elif self.freeze: key = '%s/%s' % (module, path) try: return self._frozen_versions[key] except KeyError: proxy = self._get_proxy(module, path) hash_ = proxy.hash(path) self._frozen_versions[key] = hash_ return hash_ else: proxy = self._get_proxy(module, path) return proxy.hash(path)
[docs] def get_asset_url(self, module, path): """ Returns the relative URL to the asset identified by its *module* and *path*, that this module can resolve via :meth:`get_request_response`. You won't need this function, if you're using :mod:`score.http`. But if your means of deployment is different, you will want to create URLs to your assets using this function. It will look something like this:: /css/reset.css?_v=0b2931cc6255c72e This should be rewritten to something you can detect in your application:: /_score_webassets/css/reset.css?_v=0b2931cc6255c72e Whenever a URL starting with your custom prefix is requested, you can pass the modified :class:`Request` with the original URL to :meth:`get_request_response`: .. code-block:: python response = webassets.get_request_response(Request( '/css/reset.css', {'_v': '0b2931cc6255c72e'}, {'Accept-Encoding': 'gzip,deflate', 'Referer': ... } )) """ proxy = self._get_proxy(module, path) url = '/%s/%s' % (module, path) hash_ = self.get_asset_hash(module, path) if hash_: url += '?_v=' + hash_ if self.rootdir: file = os.path.join(self.rootdir, module, path, hash_) if not os.path.exists(file): os.makedirs(os.path.dirname(file), exist_ok=True) with open(file, 'w') as fp: fp.write(proxy.mimetype(path)) fp.write('\n') fp.write(proxy.render(path)) return url
[docs] def get_bundle_name(self, module, paths=None): """ Provides a unique name for a :term:`bundle <asset bundle>` consisting of assets found in given *module* and given *paths*. Will use the module's :meth:`default paths <WebassetsProxy.iter_default_paths>`, if the latter is omitted. This feature is used internally for storing different bundles inside the same folder, for example. """ if paths is None: proxy = self._get_proxy(module) paths = self._get_proxy_default_paths(proxy) elif not paths: raise ValueError('No paths provided') return xxhash.xxh64('\0'.join(sorted(paths)).encode('UTF-8'))\ .hexdigest()
[docs] def get_bundle_hash(self, module, paths=None): """ Provides the :term:`hash <asset hash>` of a :term:`bundle <asset bundle>` consisting of assets found in given *module* and given *paths*. Will use the module's :meth:`default paths <WebassetsProxy.iter_default_paths>`, if the latter is omitted. """ if paths is None: proxy = self._get_proxy(module) paths = self._get_proxy_default_paths(proxy) elif not paths: raise ValueError('No paths provided') if isinstance(self.freeze, str): return self.freeze elif self.freeze: key = '%s/bundle\0%s' % (module, '\0'.join(paths)) try: return self._frozen_versions[key] except KeyError: proxy = self._get_proxy(module, *paths) hash_ = proxy.bundle_hash(paths) self._frozen_versions[key] = hash_ return hash_ proxy = self._get_proxy(module, *paths) return proxy.bundle_hash(paths)
[docs] def get_bundle_content(self, module, paths=None): """ Returns the content of requested :term:`bundle <asset bundle>`. The *module* name is required and will create a bundle with module's :meth:`default paths <WebassetsProxy.iter_default_paths>`. It is also possible to create a bundle with a specific list of :term:`asset paths <asset path>`. """ if paths is None: proxy = self._get_proxy(module) paths = list(proxy.iter_default_paths()) elif not paths: raise ValueError('No paths provided') else: proxy = self._get_proxy(module, *paths) return proxy.create_bundle(paths)
[docs] def get_bundle_url(self, module, paths=None): """ Returns the relative URL to given :term:`bundle <asset bundle>`, that this module can resolve via :meth:`get_request_response`. The *module* name is required and will create a bundle with module's :meth:`default paths <WebassetsProxy.iter_default_paths>`. It is also possible to create a bundle with a specific list of :term:`asset paths <asset path>`. See :meth:`get_asset_url` for example usage. """ if paths is None: proxy = self._get_proxy(module) paths = self._get_proxy_default_paths(proxy) elif not paths: raise ValueError('No paths provided') else: proxy = self._get_proxy(module, *paths) if len(paths) == 1: return self.get_asset_url(module, paths[0]) if not self.rootdir: raise RuntimeError( 'Cannot generate bundle url: no rootdir configured') bundle_name = self.get_bundle_name(module, paths) bundle_hash = self.get_bundle_hash(module, paths) file = os.path.join(self.rootdir, module, bundle_name, bundle_hash) if not os.path.exists(file): os.makedirs(os.path.dirname(file), exist_ok=True) with open(file, 'w') as fp: fp.write(proxy.bundle_mimetype(paths)) fp.write('\n') fp.write(proxy.create_bundle(paths)) url = '/%s/__bundle_%s__' % (module, bundle_name,) if bundle_hash: url += '?_v=' + bundle_hash return url
[docs] def get_request_response(self, request): """ Provides the most efficient response to an HTTP :class:`Request` to obtain an asset. The return value is either a single `int`, denoting an HTTP status code (like 404 or 304), or a 2-tuple ``(headers, body)``. The *headers* list in the latter case is a `dict` mapping header names to their values, whereas the *body* is just a string. Note that none of the return values are formatted in any way. They will need to be properly encoded (which should happen automatically in most frameworks). """ try: module, path = request.path.lstrip('/').split('/', maxsplit=1) if path.startswith('__bundle_') and path.endswith('__'): def loader(hash_=None): name = path[len('__bundle_'):-2] file = os.path.join(self.rootdir, module, name, hash_) try: content = open(file).read() except FileNotFoundError: raise AssetNotFound(module, 'bundle(%s)@%s' % (name, hash_)) return content.split('\n', maxsplit=1) else: def loader(hash_=None): if hash_ and self.rootdir: file = os.path.join(self.rootdir, module, path, hash_) try: content = open(file).read() except FileNotFoundError: raise AssetNotFound(module, '%s@%s' % (path, hash_)) return content.split('\n', maxsplit=1) proxy = self._get_proxy(module, path) return proxy.mimetype(path), proxy.render(path) return self._get_common_response(request, module, path, loader) except AssetNotFound: return 404
def _get_common_response(self, request, module, path, loader): if '_v' in request.GET: can_send_304 = ( 'If-None-Match' in request.headers or 'If-Modified-Since' in request.headers) # it really doesn't matter what the values of these headers are, # they merely indicate that the resource was requested by the client # earlier and it is now checking for changes. but since assets with # hashes are immutable, we can always respond with 304. if can_send_304: return 304 hash_ = request.GET['_v'] try: mimetype, body = loader(hash_) except FileNotFoundError: raise AssetNotFound(module, path) return ({ 'Content-Type': mimetype, 'Cache-Control': 'max-age=' + str(60 * 60 * 24 * 30 * 12), 'Etag': hash_, 'Last-Modified': email.utils.formatdate(), }, body) if 'If-Modified-Since' in request.headers and self.rootdir: t = time.mktime(email.utils.parsedate( request.headers['If-Modified-Since'])) folder = os.path.join(self.rootdir, module, path) try: if not any(os.path.getmtime(f) > t for f in os.listdir(folder)): # there aren't any newer files in this folder return 304 except FileNotFoundError: # folder does not exist, ignore pass hash_ = request.GET.get('_v', None) mimetype, body = loader(hash_) headers = { 'Content-Type': mimetype, 'Last-Modified': email.utils.formatdate(), } if hash_: headers['Etag'] = hash_ return (headers, body) def _get_proxy(self, module, *paths): if module not in self.modules: path = '???' if paths: path = paths[0] raise AssetNotFound(module, path) proxy = self.proxies[module] if self.freeze: if not hasattr(self, '_proxy_valid_paths'): self._proxy_valid_paths = {} if proxy not in self._proxy_valid_paths: self._proxy_valid_paths[proxy] = [] valid_paths = self._proxy_valid_paths[proxy] for path in paths: if path in valid_paths: continue if not proxy.validate_path(path): raise AssetNotFound(module, path) valid_paths.append(path) else: for path in paths: if not proxy.validate_path(path): raise AssetNotFound(module, path) return proxy
[docs]class AssetNotFound(Exception): """ Thrown when an asset was requested, but not found. Web applications might want to return the HTTP status code 404 in this case. Assets are uniquely identified by the combination of their :term:`module <asset module>` name and a :term:`path <asset path>`. """ def __init__(self, module, path): self.module = module self.path = path super().__init__('/%s/%s' % (module, path))