"""Integration of the ``webassets`` library with Flask."""
from __future__ import print_function
import logging
from os import path
from flask import _request_ctx_stack, current_app
from flask.templating import render_template_string
# We want to expose Bundle via this module.
from webassets import Bundle
from webassets.env import (BaseEnvironment, ConfigStorage, Resolver,
env_options, url_prefix_join)
from webassets.filter import Filter, register_filter
from webassets.loaders import PythonLoader, YAMLLoader
__version__ = (0, 12)
# webassets core compatibility used in setup.py
__webassets_version__ = ('>=0.11.1', )
__all__ = (
'Environment',
'Bundle',
'FlaskConfigStorage',
'FlaskResolver',
'Jinja2Filter',
)
[docs]class Jinja2Filter(Filter):
"""Will compile all source files as Jinja2 templates using the standard
Flask contexts.
"""
name = 'jinja2'
max_debug_level = None
def __init__(self, context=None):
super(Jinja2Filter, self).__init__()
self.context = context or {}
def input(self, _in, out, source_path, output_path, **kw):
out.write(render_template_string(_in.read(), **self.context))
# Override the built-in ``jinja2`` filter that ships with ``webassets``. This
# custom filter uses Flask's ``render_template_string`` function to provide all
# the standard Flask template context variables.
register_filter(Jinja2Filter)
[docs]class FlaskConfigStorage(ConfigStorage):
"""Uses the config object of a Flask app as the backend: either the app
instance bound to the extension directly, or the current Flask app on
the stack.
Also provides per-application defaults for some values.
Note that if no app is available, this config object is basically
unusable - this is by design; this could also let the user set defaults
by writing to a container not related to any app, which would be used
as a fallback if a current app does not include a key. However, at least
for now, I specifically made the choice to keep things simple and not
allow global across-app defaults.
"""
def __init__(self, *a, **kw):
self._defaults = {}
ConfigStorage.__init__(self, *a, **kw)
def _transform_key(self, key):
if key.lower() in env_options:
return "ASSETS_%s" % key.upper()
else:
return key.upper()
[docs] def setdefault(self, key, value):
"""We may not always be connected to an app, but we still need
to provide a way to the base environment to set it's defaults.
"""
try:
super(FlaskConfigStorage, self).setdefault(key, value)
except RuntimeError:
self._defaults.__setitem__(key, value)
def __contains__(self, key):
return self._transform_key(key) in self.env._app.config
def __getitem__(self, key):
value = self._get_deprecated(key)
if value:
return value
# First try the current app's config
public_key = self._transform_key(key)
if self.env._app:
if public_key in self.env._app.config:
return self.env._app.config[public_key]
# Try a non-app specific default value
if key in self._defaults:
return self._defaults.__getitem__(key)
# Finally try to use a default based on the current app
deffunc = getattr(self, "_app_default_%s" % key, False)
if deffunc:
return deffunc()
# We've run out of options
raise KeyError()
def __setitem__(self, key, value):
if not self._set_deprecated(key, value):
self.env._app.config[self._transform_key(key)] = value
def __delitem__(self, key):
del self.env._app.config[self._transform_key(key)]
def get_static_folder(app_or_blueprint):
"""Return the static folder of the given Flask app
instance, or module/blueprint.
In newer Flask versions this can be customized, in older
ones (<=0.6) the folder is fixed.
"""
if not hasattr(app_or_blueprint, 'static_folder'):
# I believe this is for app objects in very old Flask
# versions that did not support custom static folders.
return path.join(app_or_blueprint.root_path, 'static')
if not app_or_blueprint.has_static_folder:
# Use an exception type here that is not hidden by spit_prefix.
raise TypeError(('The referenced blueprint %s has no static '
'folder.') % app_or_blueprint)
return app_or_blueprint.static_folder
[docs]class FlaskResolver(Resolver):
"""Adds support for Flask blueprints.
This resolver is designed to use the Flask staticfile system to
locate files, by looking at directory prefixes (``foo/bar.png``
looks in the static folder of the ``foo`` blueprint. ``url_for``
is used to generate urls to these files.
This default behaviour changes when you start setting certain
standard *webassets* path and url configuration values:
If a :attr:`Environment.directory` is set, output files will
always be written there, while source files still use the Flask
system.
If a :attr:`Environment.load_path` is set, it is used to look
up source files, replacing the Flask system. Blueprint prefixes
are no longer resolved.
"""
[docs] def split_prefix(self, ctx, item):
"""See if ``item`` has blueprint prefix, return (directory, rel_path).
"""
app = ctx._app
try:
if hasattr(app, 'blueprints'):
blueprint, name = item.split('/', 1)
directory = get_static_folder(app.blueprints[blueprint])
endpoint = '%s.static' % blueprint
item = name
else:
# Module support for Flask < 0.7
module, name = item.split('/', 1)
directory = get_static_folder(app.modules[module])
endpoint = '%s.static' % module
item = name
except (ValueError, KeyError):
directory = get_static_folder(app)
endpoint = 'static'
return directory, item, endpoint
def use_webassets_system_for_output(self, ctx):
return ctx.config.get('directory') is not None or \
ctx.config.get('url') is not None
def use_webassets_system_for_sources(self, ctx):
return bool(ctx.load_path)
def search_for_source(self, ctx, item):
# If a load_path is set, use it instead of the Flask static system.
#
# Note: With only env.directory set, we don't go to default;
# Setting env.directory only makes the output directory fixed.
if self.use_webassets_system_for_sources(ctx):
return Resolver.search_for_source(self, ctx, item)
# Look in correct blueprint's directory
directory, item, endpoint = self.split_prefix(ctx, item)
try:
return self.consider_single_directory(directory, item)
except IOError:
# XXX: Hack to make the tests pass, which are written to not
# expect an IOError upon missing files. They need to be rewritten.
return path.normpath(path.join(directory, item))
def resolve_output_to_path(self, ctx, target, bundle):
# If a directory/url pair is set, always use it for output files
if self.use_webassets_system_for_output(ctx):
return Resolver.resolve_output_to_path(self, ctx, target, bundle)
# Allow targeting blueprint static folders
directory, rel_path, endpoint = self.split_prefix(ctx, target)
return path.normpath(path.join(directory, rel_path))
def resolve_source_to_url(self, ctx, filepath, item):
# If a load path is set, use it instead of the Flask static system.
if self.use_webassets_system_for_sources(ctx):
return super(FlaskResolver, self).resolve_source_to_url(ctx, filepath, item)
return self.convert_item_to_flask_url(ctx, item, filepath)
def resolve_output_to_url(self, ctx, target):
# With a directory/url pair set, use it for output files.
if self.use_webassets_system_for_output(ctx):
return Resolver.resolve_output_to_url(self, ctx, target)
# Otherwise, behaves like all other flask URLs.
return self.convert_item_to_flask_url(ctx, target)
[docs] def convert_item_to_flask_url(self, ctx, item, filepath=None):
"""Given a relative reference like `foo/bar.css`, returns
the Flask static url. By doing so it takes into account
blueprints, i.e. in the aformentioned example,
``foo`` may reference a blueprint.
If an absolute path is given via ``filepath``, it will be
used instead. This is needed because ``item`` may be a
glob instruction that was resolved to multiple files.
If app.config("FLASK_ASSETS_USE_S3") exists and is True
then we import the url_for function from flask_s3,
otherwise we import url_for from flask directly.
If app.config("FLASK_ASSETS_USE_CDN") exists and is True
then we import the url_for function from flask.
"""
if ctx.environment._app.config.get("FLASK_ASSETS_USE_S3"):
try:
from flask_s3 import url_for
except ImportError as e:
print("You must have Flask S3 to use FLASK_ASSETS_USE_S3 option")
raise e
elif ctx.environment._app.config.get("FLASK_ASSETS_USE_CDN"):
try:
from flask_cdn import url_for
except ImportError as e:
print("You must have Flask CDN to use FLASK_ASSETS_USE_CDN option")
raise e
elif ctx.environment._app.config.get("FLASK_ASSETS_USE_AZURE"):
try:
from flask_azure_storage import url_for
except ImportError as e:
print("You must have Flask Azure Storage to use FLASK_ASSETS_USE_AZURE option")
raise e
else:
from flask import url_for
directory, rel_path, endpoint = self.split_prefix(ctx, item)
if filepath is not None:
filename = filepath[len(directory)+1:]
else:
filename = rel_path
flask_ctx = None
if not _request_ctx_stack.top:
flask_ctx = ctx.environment._app.test_request_context()
flask_ctx.push()
try:
url = url_for(endpoint, filename=filename)
# In some cases, url will be an absolute url with a scheme and hostname.
# (for example, when using werkzeug's host matching).
# In general, url_for() will return a http url. During assets build, we
# we don't know yet if the assets will be served over http, https or both.
# Let's use // instead. url_for takes a _scheme argument, but only together
# with external=True, which we do not want to force every time. Further,
# this _scheme argument is not able to render // - it always forces a colon.
if url and url.startswith('http:'):
url = url[5:]
return url
finally:
if flask_ctx:
flask_ctx.pop()
[docs]class Environment(BaseEnvironment):
"""This object is used to hold a collection of bundles and configuration.
If it initialized with an instance of Flask application then webassets
Jinja2 extension is automatically registered.
"""
config_storage_class = FlaskConfigStorage
resolver_class = FlaskResolver
def __init__(self, app=None):
self.app = app
super(Environment, self).__init__()
if app:
self.init_app(app)
@property
def _app(self):
"""The application object to work with; this is either the app
that we have been bound to, or the current application.
"""
if self.app is not None:
return self.app
ctx = _request_ctx_stack.top
if ctx is not None:
return ctx.app
try:
from flask import _app_ctx_stack
app_ctx = _app_ctx_stack.top
if app_ctx is not None:
return app_ctx.app
except ImportError:
pass
raise RuntimeError('assets instance not bound to an application, '+
'and no application in current context')
# XXX: This is required because in a couple of places, webassets 0.6
# still access env.directory, at one point even directly. We need to
# fix this for 0.6 compatibility, but it might be preferrable to
# introduce another API similar to _normalize_source_path() for things
# like the cache directory and output files.
def set_directory(self, directory):
self.config['directory'] = directory
def get_directory(self):
if self.config.get('directory') is not None:
return self.config['directory']
return get_static_folder(self._app)
directory = property(get_directory, set_directory, doc=
"""The base directory to which all paths will be relative to.
""")
def set_url(self, url):
self.config['url'] = url
def get_url(self):
if self.config.get('url') is not None:
return self.config['url']
return self._app.static_url_path
url = property(get_url, set_url, doc=
"""The base url to which all static urls will be relative to.""")
def init_app(self, app):
app.jinja_env.add_extension('webassets.ext.jinja2.AssetsExtension')
app.jinja_env.assets_environment = self
[docs] def from_yaml(self, path):
"""Register bundles from a YAML configuration file"""
bundles = YAMLLoader(path).load_bundles()
for name in bundles:
self.register(name, bundles[name])
[docs] def from_module(self, path):
"""Register bundles from a Python module"""
bundles = PythonLoader(path).load_bundles()
for name in bundles:
self.register(name, bundles[name])
try:
import flask_script as script
except ImportError:
pass
else:
import argparse
from webassets.script import GenericArgparseImplementation, CommandError
class FlaskArgparseInterface(GenericArgparseImplementation):
"""Subclass the CLI implementation to add a --parse-templates option."""
def _construct_parser(self, *a, **kw):
super(FlaskArgparseInterface, self).\
_construct_parser(*a, **kw)
self.parser.add_argument(
'--jinja-extension', default='*.html',
help='specify the glob pattern for Jinja extensions (default: *.html)')
self.parser.add_argument(
'--parse-templates', action='store_true',
help='search project templates to find bundles')
def _setup_assets_env(self, ns, log):
env = super(FlaskArgparseInterface, self)._setup_assets_env(ns, log)
if env is not None:
if ns.parse_templates:
log.info('Searching templates...')
# Note that we exclude container bundles. By their very nature,
# they are guaranteed to have been created by solely referencing
# other bundles which are already registered.
env.add(*[b for b in self.load_from_templates(env, ns.jinja_extension)
if not b.is_container])
if not len(env):
raise CommandError(
'No asset bundles were found. '
'If you are defining assets directly within '
'your templates, you want to use the '
'--parse-templates option.')
return env
def load_from_templates(self, env, jinja_extension):
from webassets.ext.jinja2 import Jinja2Loader, AssetsExtension
from flask import current_app as app
# Use the application's Jinja environment to parse
jinja2_env = app.jinja_env
# Get the template directories of app and blueprints
template_dirs = [path.join(app.root_path, app.template_folder)]
for blueprint in app.blueprints.values():
if blueprint.template_folder is None:
continue
template_dirs.append(
path.join(blueprint.root_path, blueprint.template_folder))
return Jinja2Loader(env, template_dirs, [jinja2_env], jinja_ext=jinja_extension).\
load_bundles()
[docs] class ManageAssets(script.Command):
"""Manage assets."""
capture_all_args = True
def __init__(self, assets_env=None, impl=FlaskArgparseInterface,
log=None):
self.env = assets_env
self.implementation = impl
self.log = log
[docs] def run(self, args):
"""Runs the management script.
If ``self.env`` is not defined, it will import it from
``current_app``.
"""
if not self.env:
from flask import current_app
self.env = current_app.jinja_env.assets_environment
# Determine 'prog' - something like for example
# "./manage.py assets", to be shown in the help string.
# While we don't know the command name we are registered with
# in Flask-Assets, we are lucky to be able to rely on the
# name being in argv[1].
import sys, os.path
prog = '%s %s' % (os.path.basename(sys.argv[0]), sys.argv[1])
impl = self.implementation(self.env, prog=prog, log=self.log)
return impl.main(args)
__all__ = __all__ + ('ManageAssets',)
try:
import click
from flask import cli
except ImportError:
pass
else:
def _webassets_cmd(cmd):
"""Helper to run a webassets command."""
from webassets.script import CommandLineEnvironment
logger = logging.getLogger('webassets')
logger.addHandler(logging.StreamHandler())
logger.setLevel(logging.DEBUG)
cmdenv = CommandLineEnvironment(
current_app.jinja_env.assets_environment, logger
)
getattr(cmdenv, cmd)()
@click.group()
def assets():
"""Web assets commands."""
@assets.command()
@cli.with_appcontext
def build():
"""Build bundles."""
_webassets_cmd('build')
@assets.command()
@cli.with_appcontext
def clean():
"""Clean bundles."""
_webassets_cmd('clean')
@assets.command()
@cli.with_appcontext
def watch():
"""Watch bundles for file changes."""
_webassets_cmd('watch')
__all__ = __all__ + ('assets', 'build', 'clean', 'watch')