diff --git a/AUTHORS b/AUTHORS index 6b670b04..fb3d2de2 100644 --- a/AUTHORS +++ b/AUTHORS @@ -31,6 +31,7 @@ Matt Haggard Matt Hanger Mattias Wong <0x1998@gmail.com> Maxim Bodyansky +metagriffin Michael Elsdörfer Michael Mior Michael Su diff --git a/docs/bundles.rst b/docs/bundles.rst index 7200585e..81c98510 100644 --- a/docs/bundles.rst +++ b/docs/bundles.rst @@ -43,6 +43,11 @@ arguments: .. warning:: Currently, using ``depends`` disables caching for a bundle. +* ``renderer`` - Name of the renderer used to render this bundle's + assets in context. Note that the renderer must be either one of the + webassets-provided renderers (currently ``"css"`` and ``"js"``) or a + renderer registered via ``Environment.register_renderer()`` or + ``register_global_renderer()`` (see :doc:`/renderer` for more info). Nested bundles -------------- @@ -155,6 +160,22 @@ which allows you do something like this: {% assets filters="cssmin,datauri", output="gen/packed.css", "common/jquery.css", "site/base.css", "site/widgets.css" %} ... +You can also delegate contextual rendering of asset references to +webassets (here, using Mako): + +.. code-block:: mako + + % for asset in my_webassets_env['assets'].renderers(): + ${asset.render()|n} + % endfor + +which will correctly render references to the assets via CSS "" +and JavaScript " + + ... + + + +Renderer Registration +===================== + +Webassets provides default renderers for CSS (named ``"css"``), +JavaScript (named ``"js"``), and LESS CSS (named ``"less"``). If you +need to add more renderers, or change the default rendering, this can +be done via renderer registration. + +A renderer is either a string in `str.format syntax +`_, +or a callable that receives the following keyword arguments: + +* `type`: the renderer type, i.e. the `name`. +* `bundle`: the Bundle object being rendered. +* `url`: the currently being rendered asset URL. +* `content`: the asset content (for inline renderings only). +* `env`: the environment currently in effect for the rendering. + +Finally, renderers can also specify whether or not their content can +be merged with another renderer. For example, a "less" file *can* be +merged with a "css" file if and only if the less is being compiled to +css. If in debug mode and "less_run_in_debug" is falsy, then "less" +and "css" cannot be merged. A "merge_checker" is specified as the +third argument in the registration (or via keyword), and must accept +the following keyword arguments: + +* `parent`: the container bundle renderer type (e.g. ``"css"``). +* `child`: the contained bundle renderer type (e.g. ``"less"``). +* `env`: the environment currently in effect for the rendering. + +The `merge_checker` can return truthy (the contents can be merged), +falsy (the contents cannot be merged), or None (this renderer does not +know). All affected renderers will be queried regarding mergeability, +until a non-None response is given. + +An example custom ``svg`` renderer that can handle SVG being +rasterized either client-side or server-side (note that this assumes +that there is a filter that rasterizes SVGs to PNGs running that is +sensitive to the `debug` and `svg_run_in_debug` flags): + +.. code-block:: python + + def svg_renderer(type, bundle, url): + return '' + + def svg_inline_renderer(type, bundle, url, content): + from base64 import b64encode + dosvgc = not bundle.env.debug or bundle.env.config.get('svg_run_in_debug') + type = 'image/png' if dosvgc else 'image/svg' + return ''.format(type=type, content=content) + + def svg_mergeable(parent, child, env): + # (this is a completely bogus implementation -- see + # :func:`webassets.renderer.less_merge_checker` for a + # good example.) + if parent == 'svg' and child == 'xml-comment': + return True + return None + + +You can register renderers in particular ``Environment`` objects +(recommended) or you can also register renderers globally (only +recommended in rare situations). + +To register the renderer in an environment: + +.. code-block:: python + + env.register_renderer('svg', svg_renderer, svg_inline_renderer, svg_mergeable) + + +And to register the renderer globally (usually not recommended): + +.. code-block:: python + + from webassets.renderer import register_global_renderer + register_global_renderer('svg', svg_renderer, svg_inline_renderer, svg_mergeable) + + +Note that in the above examples, we registered both a referencing +renderer as well as an inline renderer. If we had specified only the +former, then the inline renderer would default to that one as well. + +And here an example of registering a simpler string-based renderer +(but which will always render a reference to the image even when +inlining is requested): + +.. code-block:: python + + env.register_renderer( + + # the name of the renderer: + 'svg', + + # the "by reference" rendering: + '' + + # an "inline" renderer is not specified, so it will + # default to the above "by reference" renderer + ) diff --git a/src/webassets/bundle.py b/src/webassets/bundle.py index 2cf66e10..9f83f1ed 100644 --- a/src/webassets/bundle.py +++ b/src/webassets/bundle.py @@ -56,6 +56,7 @@ def __init__(self, *contents, **options): self.depends = options.pop('depends', []) self.version = options.pop('version', []) self.extra = options.pop('extra', {}) + self.renderer = options.pop('renderer', None) if options: raise TypeError("got unexpected keyword argument '%s'" % list(options.keys())[0]) @@ -685,6 +686,56 @@ def urls(self, env=None, *args, **kwargs): urls.extend(bundle._urls(env, extra_filters, *args, **kwargs)) return urls + def renderers(self, types=None, inline=None, default=None, env=None, + *args, **kwargs): + ''' + Returns a generator of renderers for this bundle. + + This operates almost identically to :meth:`.urls()`, so this + may return one BundleRenderer (usually in production) or + multiple BundleRenderers (usually in debug mode or when the + bundles uses multiple renderer types). + + :Parameters: + + * `types` : {str, list(str)}, optional, default: null + + If specified, restricts the returned renderers to the + selected set. If the selection is a string-type, it is + converted into a list by splitting at commas (","). Then, + each potential renderer is matched against the set, and if + any match, the renderer is included. If the match starts + with a bang ("!"), it is a negative match, and the entire + set becomes an exclusive set instead of inclusive. It is a + syntax error to mix both positive and negative matches. + Examples: + + * ``"less,css"``: selects any "less" or "css" renderers + * ``"!js"``: selects any renderers that are not "js" + + * `inline` : bool, optional, default: null + + If specified (and not ``None``), this will be used as the + default inlining mode of each renderer. Note, however, that + this can be overridden then on a per-renderer basis. + + * `default` : str, optional, default: null + + If specified (and not ``None``), this will be used as the + value to the `default` parameter to each `render()` call. + Note, however, that this can be overridden then on a + per-renderer basis. + + * `env` : object, optional, default: null + + Override the default bundle rendering environment with the + specified `env`. + ''' + from .renderer import bundle_renderer_iter + return bundle_renderer_iter( + self, types, inline, default, self._get_env(env), + *args, **kwargs) + def pull_external(env, filename): """Helper which will pull ``filename`` into diff --git a/src/webassets/env.py b/src/webassets/env.py index 7872283a..4bc1a6b3 100644 --- a/src/webassets/env.py +++ b/src/webassets/env.py @@ -15,6 +15,7 @@ from .version import get_versioner, get_manifest from .updater import get_updater from .utils import urlparse +from .renderer import prepare_renderer __all__ = ('Environment', 'RegisterError') @@ -402,6 +403,7 @@ def __init__(self, **config): BundleRegistry.__init__(self) self._config = self.config_storage_class(self) self.resolver = self.resolver_class(self) + self.renderers = dict() # directory, url currently do not have default values # @@ -695,6 +697,17 @@ def _get_url_mapping(self): modifying this setting directly. """) + def register_renderer(self, name, renderer, + inline_renderer=None, merge_checker=None): + ''' + Registers renderers to be used only for renderings done + within the context of this environment. + + For details, and how to register renderers globally, see + :func:`webassets.renderer.register_global_renderer()`. + ''' + self.renderers[name] = prepare_renderer( + name, renderer, inline_renderer, merge_checker) class DictConfigStorage(ConfigStorage): """Using a lower-case dict for configuration values. diff --git a/src/webassets/loaders.py b/src/webassets/loaders.py index 478d7b62..53f4ad6a 100644 --- a/src/webassets/loaders.py +++ b/src/webassets/loaders.py @@ -64,7 +64,9 @@ def _get_bundle(self, data): output=data.get('output', None), debug=data.get('debug', None), extra=data.get('extra', {}), - depends=data.get('depends', None)) + depends=data.get('depends', None), + renderer=data.get('renderer', None), + ) return Bundle(*list(self._yield_bundle_contents(data)), **kwargs) def _get_bundles(self, obj, known_bundles=None): diff --git a/src/webassets/renderer.py b/src/webassets/renderer.py new file mode 100644 index 00000000..16aa0657 --- /dev/null +++ b/src/webassets/renderer.py @@ -0,0 +1,249 @@ +from webassets import six + +from .bundle import Bundle + +__all__ = 'register_global_renderer' + +global_renderers = dict() + +class Renderer(object): + def __init__(self, reference, inline, mergeable): + self.reference = reference + self.inline = inline + self.mergeable = mergeable + +def prepare_renderer(name, renderer, inline_renderer=None, merge_checker=None): + ''' + For internal use only -- prepares the renderers to be + stored in the internal lookup tables. + ''' + if isinstance(renderer, six.string_types): + renderer = make_template_renderer(renderer) + if isinstance(inline_renderer, six.string_types): + inline_renderer = make_template_renderer(inline_renderer) + if inline_renderer is None: + inline_renderer = renderer + return Renderer(renderer, inline_renderer, merge_checker) + + +def register_global_renderer(name, renderer, inline_renderer=None, merge_checker=None): + ''' + Register the `renderer` under the name `name` globally. If + `inline_renderer` is ``None``, it will default to the using the + non-inline `renderer`. + + Note that, as always, using globals is usually not a good. It is + usually a much better idea to register the renderers within the + specific :class:`webassets.env.Environment` context that they will + be used (the Environment class has a `register_renderer()` method + that is perfect for that). + + Renderers can be either a callable or a template string. If they + are a string, they will be converted to callables by + :func:`.make_template_renderer`. + + Renderers are called with the following keyword parameters: + + * `bundle`: the Bundle object being rendered. + * `type`: the renderer type, i.e. the `name`. + * `url`: the currently being rendered asset URL. + * `content`: the asset content (for inline renderings only). + * `env`: the current environment object. + + The optional parameter `merge_checker` specifies a callable that + is used when rendering bundles that contain renderers of different + types. It must return a boolean (mergeable or not mergeable) or + None (unknown). It is called with the following keyword + parameters: + + * `parent`: the "parent" (i.e. container bundle) renderer type. + * `child`: the "child" (i.e. the contained bundle) renderer type. + * `env`: the current environment object. + ''' + global_renderers[name] = prepare_renderer( + name, renderer, inline_renderer, merge_checker) + + +def make_template_renderer(template): + ''' + Returns a callable renderer from the provided string `template`. + The template is assumed to be in `str.format syntax + `_, + which has access to all parameters specified in + :func:`.register_global_renderer` (of which `url` and `content` + are most interesting). + ''' + return str(template).format + + +def get_renderer(env, name): + ret = None + if hasattr(env, 'renderers'): + ret = env.renderers.get(name, None) + if ret is None: + ret = global_renderers.get(name, None) + if ret is None: + raise ValueError('Cannot find renderer "%s"' % (str(name),)) + return ret + + +# register a default renderer that simply outputs the data as-is. not +# particularly useful, but at least that way it is visible, and +# developers will hopefully realize that it is a mis-configuration... + +register_global_renderer(None, '{url}', '{content}') + +# register some globally useful renderers for css and javascript. + +# todo: technically, the `content` should be CDATA-escaped here, but +# that is perhaps a little too much? afterall, the "]]>" sequence in +# css and javascript is pretty rare, i think (i've never seen it) + +register_global_renderer( + 'css', + '', + '') + +register_global_renderer( + 'js', + '', + '') + +# register a less renderer +# todo: perhaps this should be registered by the 'less' filter?... + +LESS_REFERENCE_FMT = '' +LESS_INLINE_FMT = '''\ +''' + +def less_renderer(type, bundle, url, env, **kw): + runlessc = not env.debug or env.config.get('less_run_in_debug', True) + rel = 'stylesheet' if runlessc else 'stylesheet/less' + return LESS_REFERENCE_FMT.format(rel=rel, url=url) + +def less_inline_renderer(type, bundle, url, content, env, **kw): + runlessc = not env.debug or env.config.get('less_run_in_debug', True) + type = 'text/css' if runlessc else 'text/less' + return LESS_INLINE_FMT.format(type=type, content=content) + +def less_merge_checker(parent, child, env): + if parent == 'less' and child == 'css': + return True + if parent != 'css' or child != 'less': + return None + return not env.debug or env.config.get('less_run_in_debug', True) + +register_global_renderer( + 'less', less_renderer, less_inline_renderer, less_merge_checker) + + +def mergeable_renderer_types(env, parent, child): + if parent == child: # paranoia + return True + mergeable = get_renderer(env, parent).mergeable + if mergeable is not None: + ret = mergeable(parent=parent, child=child, env=env) + if ret is not None: + return ret + mergeable = get_renderer(env, child).mergeable + if mergeable is not None: + ret = mergeable(parent=parent, child=child, env=env) + if ret is not None: + return ret + return False + +def mergeable_renderer(env, bundle, renderer): + if bundle.renderer is not None and bundle.renderer != renderer: + return mergeable_renderer_types(env, renderer, bundle.renderer) + for sub in bundle.contents: + if isinstance(sub, Bundle): + if not mergeable_renderer(env, sub, renderer): + return False + return True + +def matches_types(types, type): + if types is None: + return True + default = False + for typ in types: + if typ.startswith('!'): + default = True + typ = typ[1:] + if typ == type: + return not default + return default + +def bundle_renderer_iter(bundle, types, inline, default, env, *args, **kwargs): + if types is not None and isinstance(types, six.string_types): + types = [typ.strip() for typ in types.split(',')] + default = bundle.renderer or default + # first, check for mixed-renderer bundles + if mergeable_renderer(env, bundle, default): + for sub, extra_filters in bundle.iterbuild(env): + for url in sub._urls(env, extra_filters, *args, **kwargs): + if not matches_types(types, sub.renderer or default): + continue + yield BundleRenderer(env, sub, url, inline, default) + return + def copy(bundle, renderer, index=0): + ret = Bundle(renderer=renderer) + # copying all attributes except 'contents' and 'renderer'... + for attr in ('env', 'output', 'filters', 'debug', \ + 'depends', 'version', 'extra'): + setattr(ret, attr, getattr(bundle, attr)) + if index != 0: + # todo: this is a hack. the problem is that when a bundle + # gets fragmented for multi-renderer support, it needs a + # different output location... + if ret.output is None: + import uuid + ret.output = 'bundle-fragment-' + str(uuid.uuid4()).replace('-', '') + ret.output += ':%d' % (index,) + return (ret, index + 1) + cur, idx = copy(bundle, default) + for sub in bundle.contents: + if not isinstance(sub, Bundle) or mergeable_renderer(env, sub, default): + if cur is None: + cur, idx = copy(bundle, default, idx) + cur.contents += (sub,) + continue + if cur and cur.contents: + for br in bundle_renderer_iter(cur, types, inline, default, env, *args, **kwargs): + yield br + cur = None + for br in bundle_renderer_iter(sub, types, inline, default, env, *args, **kwargs): + yield br + if cur and cur.contents: + for br in bundle_renderer_iter(cur, types, inline, default, env, *args, **kwargs): + yield br + + +class BundleRenderer(object): + + def __init__(self, env, bundle, url, inline=None, default=None): + self.env = env + self.bundle = bundle + self.url = url + self.inline = inline + self.default = default + + def render(self, inline=None, default=None): + if inline or ( inline is None and self.inline ): + return self._render_inline(default=default or self.default) + return self._render_ref(default=default or self.default) + + def _render_ref(self, default=None): + typ = self.bundle.renderer or default + return get_renderer(self.env, typ).reference( + type=typ, bundle=self.bundle, url=self.url, env=self.env) + + def _render_inline(self, default=None): + typ = self.bundle.renderer or default + buf = six.StringIO() + self.bundle.build(force=True, output=buf, env=self.env) + buf = buf.getvalue() + return get_renderer(self.env, typ).inline( + type=typ, bundle=self.bundle, url=self.url, content=buf, env=self.env) + diff --git a/tests/test_renderer.py b/tests/test_renderer.py new file mode 100644 index 00000000..74cf08ea --- /dev/null +++ b/tests/test_renderer.py @@ -0,0 +1,225 @@ +''' +Tests for the "rendering" aspect of :class:`Bundle` via +:class:`BundleRenderer`. +''' + + +import unittest +import sys + +from tests.test_bundle_urls import BaseUrlsTester + +class TestRenderer(BaseUrlsTester, unittest.TestCase): + + def setUp(self): + self.setup() + class MockBundleWithContent(self.MockBundle): + def _build(self, *a, **kw): + super(MockBundleWithContent, self)._build(*a, **kw) + output = kw.get('output') + if output: + output.write(self.extra.get('data', 'MockData')) + self.MockBundle = MockBundleWithContent + self.env.register_renderer('custom', '<{url}>', '[{url}]') + + def test_missing_renderer(self): + b = self.MockBundle('a', output='out', renderer='no-such-renderer') + with self.assertRaises(ValueError) as cm: + [r.render() for r in b.renderers()] + self.assertEqual( + str(cm.exception), 'Cannot find renderer "no-such-renderer"') + + def test_reference_css(self): + b = self.MockBundle('a', output='out.css', renderer='css') + self.assertEqual( + [r.render() for r in b.renderers()], + ['']) + + def test_reference_less(self): + b = self.MockBundle('a', output='out.css', renderer='less') + self.assertEqual( + [r.render() for r in b.renderers()], + ['']) + + def test_reference_less_debug(self): + self.env.debug = True + self.env.config['less_run_in_debug'] = False + b = self.MockBundle('a', output='out.css', renderer='less') + self.assertEqual( + [r.render() for r in b.renderers()], + ['']) + + def test_reference_js(self): + b = self.MockBundle('a', output='out.js', renderer='js') + self.assertEqual( + [r.render() for r in b.renderers()], + ['']) + + def test_reference_custom(self): + b = self.MockBundle('a', output='out.cstm', renderer='custom') + self.assertEqual( + [r.render() for r in b.renderers()], + ['']) + + def test_inline_css(self): + b = self.MockBundle('a', output='out.css', renderer='css', + extra=dict(data='some-css')) + self.assertEqual( + [r.render(inline=True) for r in b.renderers()], + ['''\ +''']) + + def test_inline_less(self): + b = self.MockBundle('a', output='out.css', renderer='less', + extra=dict(data='some-less')) + self.assertEqual( + [r.render(inline=True) for r in b.renderers()], + ['''\ +''']) + + + def test_inline_less_debug(self): + self.env.debug = True + self.env.config['less_run_in_debug'] = False + b = self.MockBundle('a', output='out.css', renderer='less', + extra=dict(data='some-less')) + self.assertEqual( + [r.render(inline=True) for r in b.renderers()], + ['''\ +''']) + + + def test_inline_js(self): + b = self.MockBundle('a', output='out.js', renderer='js', + extra=dict(data='some-js')) + self.assertEqual( + [r.render(inline=True) for r in b.renderers()], + ['''\ +''']) + + def test_defaultinline_custom(self): + b = self.MockBundle('a', output='a.out', renderer='custom') + self.assertEqual( + [r.render() for r in b.renderers(inline=True)], + ['[/a.out]']) + + def test_inherit(self): + a = self.MockBundle('a', output='a.out') + b = self.MockBundle('b', output='b.out') + c = self.MockBundle(a, b, output='c.out') + self.assertEqual( + [r.render() for r in c.renderers(default='custom')], + ['']) + + def test_inherit_debug(self): + self.env.debug = True + a = self.MockBundle('a', output='a.out') + b = self.MockBundle('b', output='b.out') + c = self.MockBundle(a, b, output='c.out') + self.assertEqual( + [r.render() for r in c.renderers(default='custom')], + ['', '']) + + def test_multiple(self): + a = self.MockBundle('a', output='a.out', renderer='custom') + b = self.MockBundle('b', output='b.out', renderer='custom') + c = self.MockBundle(a, b, output='c.out', renderer='custom') + self.assertEqual( + [r.render() for r in c.renderers(inline=False)], + ['']) + self.assertEqual( + [r.render() for r in c.renderers(inline=True)], + ['[/c.out]']) + + def test_multiple_debug(self): + self.env.debug = True + a = self.MockBundle('a', output='a.out', renderer='custom') + b = self.MockBundle('b', output='b.out', renderer='custom') + c = self.MockBundle(a, b, output='c.out', renderer='custom') + self.assertEqual( + [r.render() for r in c.renderers(inline=False)], + ['', '']) + self.assertEqual( + [r.render() for r in c.renderers(inline=True)], + ['[/a]', '[/b]']) + + def test_mixed(self): + self.env.register_renderer('custom2', '<<{url}>>', '[[{url}]]') + a = self.MockBundle('a', output='a.out', renderer='custom') + b = self.MockBundle('b', output='b.out', renderer='custom2') + c = self.MockBundle(a, b, output='c.out', renderer='custom') + self.assertEqual( + [r.render() for r in c.renderers(inline=False)], + ['', '<>']) + self.assertEqual( + [r.render() for r in c.renderers(inline=True)], + ['[/c.out]', '[[/b.out]]']) + + def test_mixed_debug(self): + self.env.debug = True + self.env.register_renderer('custom2', '<<{url}>>', '[[{url}]]') + a = self.MockBundle('a', output='a.out', renderer='custom') + b = self.MockBundle('b', output='b.out', renderer='custom2') + c = self.MockBundle(a, b, output='c.out', renderer='custom') + self.assertEqual( + [r.render() for r in c.renderers(inline=False)], + ['', '<>']) + self.assertEqual( + [r.render() for r in c.renderers(inline=True)], + ['[/a]', '[[/b]]']) + + def test_mixed_interleaved(self): + self.env.register_renderer('custom2', '<<{url}>>', '[[{url}]]') + a = self.MockBundle('a', output='a.out', renderer='custom') + b = self.MockBundle('b', output='b.out', renderer='custom2') + c = self.MockBundle('c', output='c.out', renderer='custom') + d = self.MockBundle(a, b, c, output='d.out', renderer='custom') + self.assertEqual( + [r.render() for r in d.renderers(inline=False)], + ['', '<>', '']) + self.assertEqual( + [r.render() for r in d.renderers(inline=True)], + ['[/d.out]', '[[/b.out]]', '[/d.out:1]']) + + def test_mixed_interleaved_debug(self): + self.env.debug = True + self.env.register_renderer('custom2', '<<{url}>>', '[[{url}]]') + a = self.MockBundle('a', output='a.out', renderer='custom') + b = self.MockBundle('b', output='b.out', renderer='custom2') + c = self.MockBundle('c', output='c.out', renderer='custom') + d = self.MockBundle(a, b, c, output='d.out', renderer='custom') + self.assertEqual( + [r.render() for r in d.renderers(inline=False)], + ['', '<>', '']) + self.assertEqual( + [r.render() for r in d.renderers(inline=True)], + ['[/a]', '[[/b]]', '[/c]']) + + def test_selection(self): + self.env.register_renderer('ra', '', '[a:{url}]') + self.env.register_renderer('rb', '', '[b:{url}]') + self.env.register_renderer('rc', '', '[c:{url}]') + a = self.MockBundle('a', output='a.out', renderer='ra') + b = self.MockBundle('b', output='b.out', renderer='rb') + c = self.MockBundle('c', output='c.out', renderer='rc') + d = self.MockBundle(a, b, c, output='d.out') + self.assertEqual( + [r.render() for r in d.renderers('ra')], + ['']) + self.assertEqual( + [r.render() for r in d.renderers('ra,rb')], + ['', '']) + self.assertEqual( + [r.render() for r in d.renderers('!rc')], + ['', '']) + self.assertEqual( + [r.render() for r in d.renderers('!ra,!rc')], + [''])