Source code for feincms3.renderer

import inspect
import warnings
from collections import deque

from content_editor.contents import contents_for_item
from django.core.cache import cache
from django.core.exceptions import ImproperlyConfigured
from django.template import Context, Engine
from django.utils.functional import SimpleLazyObject
from django.utils.html import mark_safe


__all__ = (
    "PluginNotRegisteredError",
    "default_context",
    "render_in_context",
    "template_renderer",
    "RegionRenderer",
    "TemplatePluginRenderer",
)


[docs] class PluginNotRegisteredError(Exception): """ Exception raised when encountering a plugin which isn't known to the renderer. """
# Backwards compatibility PluginNotRegistered = PluginNotRegisteredError
[docs] def default_context(plugin, context): """ Return the default context for plugins rendered with a template, which simply is a single variable named ``plugin`` containing the plugin instance. """ return {"plugin": plugin}
[docs] def render_in_context(context, template, local_context=None): """Render using a template rendering context This utility avoids the problem of ``render_to_string`` requiring a ``dict`` and not a full-blown ``Context`` instance which would needlessly burn CPU cycles.""" if context is None: context = Context() if not hasattr(template, "render"): # Quacks like a template? try: engine = context.template.engine except AttributeError: engine = Engine.get_default() if isinstance(template, (list, tuple)): template = engine.select_template(template) else: template = engine.get_template(template) with context.push(local_context): return template.render(context)
[docs] def template_renderer(template_name, local_context=default_context, /): """ Build a renderer for the region renderer which uses a template (or a list of templates) and optionally a local context function. The context contains the site-wide context variables too when invoked via ``{% render_region %}`` """ return lambda plugin, context: render_in_context( context, template_name, local_context(plugin, context) )
# See RegionRenderer.register() _default_marks = {"default"} _plugin = 0 _renderer = 1 _subregion = 2 _marks = 3 _fetch = 4 _CLOSE_SECTION = "_close_section"
[docs] class RegionRenderer: """ The region renderer knows how to render single plugins and also complete regions. The basic usage is to instantiate the region renderer, register plugins and render a full region with it. """ def __init__(self): self._plugins = {} self._handlers = { key[7:]: getattr(self, key) for key in dir(self) if key.startswith("handle_") }
[docs] def copy(self): """ Return a shallow copy of the renderer """ obj = self.__class__() obj._plugins = dict(self._plugins) return obj
[docs] def unregister(self, *plugins, keep=()): """ Unregister plugins You can either pass a list of plugins which should be unregistered: .. code-block:: python renderer.unregister(HTML, RichText) Or you can specify which plugins should be kept: .. code-block:: python renderer.unregister(keep=(HTML, RichText)) You cannot do both at the same time. Plugins can either be the plugin classes themselves or base classes. """ if bool(plugins) == bool(keep): raise ImproperlyConfigured( "Only ever provide either a list of plugins or a list of plugins to keep." ) test = ( (lambda cls: not issubclass(cls, plugins)) if plugins else (lambda cls: issubclass(cls, tuple(keep))) ) self._plugins = { plugin: cfg for plugin, cfg in self._plugins.items() if test(plugin) }
[docs] def register( self, plugin, renderer, /, *, subregion="default", marks=_default_marks, fetch=True, ): """ Register a plugin class The renderer is either a static value or a function which always receives two arguments: The plugin instance and the context. When using ``{% render_region %}`` and the Django template language the context will be a Django template ``Context`` (or even ``RequestContext``) instance. The three optional keyword arguments' are: - ``subregion: str = "default"``: The subregion for this plugin as a string or as a callable accepting a single plugin instance. A matching ``handle_<subregion>`` callable has to exist on the region renderer instance or rendering **will** crash loudly. - ``marks: Set[str] = {"default"}``: The marks of this plugin. Marks only have the meaning you assign to them. Marks are preferrable to running ``isinstance`` on plugin instances especially when using the same region renderer class for different content types (e.g. pages and blog articles). - ``fetch = True``: By default a plugin is fetched from the database; setting this to ``False`` allows registering plugin classes which shouldn't be fetched from the database. """ if callable(renderer) and len(inspect.signature(renderer).parameters) < 2: raise ImproperlyConfigured( f"The renderer function {renderer} has less than the two required arguments." ) if not isinstance(plugin, (list, tuple)): plugin = [plugin] for p in plugin: if p in self._plugins: warnings.warn( f"The plugin {p} has already been registered with {self.__class__} before.", stacklevel=2, ) if p._meta.proxy and fetch: warnings.warn( f'The plugin {p} is a proxy but is also registered with the default "fetch=True". This can cause plugins to be fetched (and rendered) twice.', stacklevel=2, ) self._plugins[p] = (p, renderer, subregion, marks, fetch)
[docs] def plugins(self, *, fetch=True): """ Return a list of all registered plugin classes By default and because of backwards compatibility concerns the method only returns plugins which have been registered for fetching. """ return [ cfg[_plugin] for cfg in self._plugins.values() if (cfg[_fetch] if fetch else True) ]
@property def handlers(self): warnings.warn( "The handlers aren't really supposed to be public. Access _handlers if you know what you're doing or propose a better API instead.", DeprecationWarning, stacklevel=2, ) return self._handlers
[docs] def render_plugin(self, plugin, context): """ Render a single plugin using the registered renderer """ try: renderer = self._plugins[plugin.__class__][_renderer] except KeyError as exc: raise PluginNotRegisteredError( f"Plugin {plugin._meta.label_lower} is not registered" ) from exc if callable(renderer): return renderer(plugin, context) return renderer
[docs] def subregion(self, plugin): """ Return the subregion of a plugin instance """ try: subregion = self._plugins[plugin.__class__][_subregion] except KeyError as exc: raise PluginNotRegisteredError( f"Plugin {plugin._meta.label_lower} is not registered" ) from exc if callable(subregion): return subregion(plugin) return subregion
[docs] def takewhile_subregion(self, plugins, subregion): """ Yield all plugins from the head of the ``plugins`` deque as long as their subregion equals ``subregion``. """ while plugins and self.subregion(plugins[0]) == subregion: yield plugins.popleft()
[docs] def marks(self, plugin): """ Return the marks of a plugin instance """ try: marks = self._plugins[plugin.__class__][_marks] except KeyError as exc: raise PluginNotRegisteredError( f"Plugin {plugin._meta.label_lower} is not registered" ) from exc if callable(marks): return marks(plugin) return marks
[docs] def takewhile_mark(self, plugins, mark): """ Yield all plugins from the head of the ``plugins`` deque as long as their marks include ``mark``. """ while plugins and mark in self.marks(plugins[0]): yield plugins.popleft()
[docs] def handle(self, plugins, context): """ Runs the ``handle_<subregion>`` handler for the head of the ``plugins`` deque. This method requires that a matching handler for all values returned by ``self.subregion()`` exists. You probably want to call this method when overriding the rendering of a complete region. """ plugins = deque(plugins) while plugins: yield from self._handlers[self.subregion(plugins[0])](plugins, context)
[docs] def handle_default(self, plugins, context): """ Renders plugins from the queue as long as there are plugins belonging to the ``default`` subregion. """ for plugin in self.takewhile_subregion(plugins, "default"): yield self.render_plugin(plugin, context)
[docs] def render_region(self, *, region, contents, context): """ Render one region. """ return mark_safe("".join(self.handle(contents[region.key], context)))
[docs] def render_regions(self, *, regions, contents, context): """ Render multiple regions. This method should return a dictionary. """ return { region.key: self.render_region( region=region, contents=contents, context=context ) for region in regions }
# Sections support
[docs] def render_section_plugins(self, section, plugins, context): """ Helper for implementing section rendering with section supporting optional nesting etc. You need: - A close section plugin - One or more open section plugins Here's some example code to hopefully get you started: .. code-block:: python from django.utils.html import conditional_escape, mark_safe from feincms3.renderer import render_in_context class SectionRenderer(RegionRenderer): def handle_section(self, plugins, context): section = plugins.popleft() content = self.render_section_plugins(section, plugins, context) yield render_in_context( context, # That's just an example: f"sections/{section.__class__.__name__.lower()}.html", { "section": section, "content": mark_safe("".join(map(conditional_escape, content))), }, ) renderer = SectionRenderer() renderer.register_section_close(models.CloseSection) renderer.register(models.Accordion, "", subregion="section") This code automatically determines the template name from the class name, making it easier to reuse for different section types. Subregions are automatically closed when subregions change. Sections only end when encountering an explicit section closing plugin or when there are no more plugins in the current region at all. Sections can contain other sections and subregions making them quite powerful when for organizing and grouping content. """ out = [] while plugins: subregion = self.subregion(plugins[0]) if subregion is _CLOSE_SECTION: plugins.popleft() break elif subregion is not None: out.extend(self._handlers[subregion](plugins, context)) else: out.append(self.render_plugin(plugins.popleft(), context)) return out
def register_section_close(self, plugin, renderer="", **kwargs): kwargs["subregion"] = _CLOSE_SECTION self.register(plugin, renderer, **kwargs) def handle__close_section(self, plugins, context): plugins.popleft() yield from () # Main external rendering API
[docs] def regions_from_contents(self, contents, **kwargs): """ Return an opaque object encapsulating :mod:`content_editor.contents.Contents` and the logic required to render them. All you need to know is that the return value has a ``regions`` attribute containing a list of regions and a ``render`` method accepting a region key and a context instance. """ return _Regions(contents=contents, renderer=self, **kwargs)
[docs] def regions_from_item(self, item, /, *, inherit_from=None, timeout=None, **kwargs): """ Return an opaque object, see :func:`~feincms3.renderer.RegionRenderer.regions_from_contents` Automatically caches the return value if ``timeout`` is truthy. The default cache key only takes the ``item``'s class and primary key into account. You may have to override the cache key by passing ``cache_key`` if you're doing strange^Wadvanced things. """ if timeout and kwargs.get("cache_key") is None: kwargs["cache_key"] = f"regions-{item._meta.label_lower}-{item.pk}" contents = SimpleLazyObject( lambda: contents_for_item(item, self.plugins(), inherit_from=inherit_from) ) return self.regions_from_contents(contents, timeout=timeout, **kwargs)
# TemplatePluginRenderer compatibility
[docs] def register_string_renderer(self, plugin, renderer): """Backwards compatibility for ``TemplatePluginRenderer``. It is deprecated, don't use in new code.""" warnings.warn( "register_string_renderer is deprecated. Use register instead." " (Hint: register_string_renderer(plugin, renderer) can be replaced by" " register(plugin, renderer) most of the time. The renderer has to be" " changed to accept an additional `context` argument.)", DeprecationWarning, stacklevel=2, ) if callable(renderer): self.register(plugin, lambda plugin, context: renderer(plugin)) else: self.register(plugin, renderer)
[docs] def register_template_renderer( self, plugin, template_name, context=default_context ): """Backwards compatibility for ``TemplatePluginRenderer``. It is deprecated, don't use in new code.""" warnings.warn( "register_template_renderer is deprecated. Use register instead." " (Hint: register_template_renderer(plugin, template_name, local_context)" " can be replaced by register(plugin, template_renderer(template_name, local_context))" " most of the time.)", DeprecationWarning, stacklevel=2, ) self.register(plugin, _compat_template_renderer(template_name, context))
[docs] def render_plugin_in_context(self, plugin, context=None): """Backwards compatibility for ``TemplatePluginRenderer``. It is deprecated, don't use in new code.""" warnings.warn( "render_plugin_in_context is deprecated. Use render_plugin instead." " (Hint: render_plugin works exactly the same except that the `context` argument" " is required.)", DeprecationWarning, stacklevel=2, ) return self.render_plugin(plugin, context)
class _Regions: """ Opaque object implementing the following interface: - ``regions``: A list of regions used for the content editor ``Contents`` instance. - ``render(region_key, context)``: A function which actually runs the renderer. """ def __init__(self, *, contents, renderer, cache_key=None, timeout=None): self._contents = contents self._renderer = renderer self._cache_key = cache_key self._timeout = timeout def _rendered(self, context): caching = self._cache_key and self._timeout if caching and (result := cache.get(self._cache_key)): return result result = self._renderer.render_regions( regions=self.regions, contents=self._contents, context=context ) if caching: cache.set(self._cache_key, result, timeout=self._timeout) return result @property def regions(self): return self._contents.regions def render(self, region_key, context): rendered = self._rendered(context) return rendered.get(region_key, "") def _compat_template_renderer(_tpl, _ctx=default_context, /): """Compatibility implementation which accepts an optionally callable template and an optionally callable context function.""" def renderer(plugin, context): template_name = _tpl(plugin) if callable(_tpl) else _tpl local_context = _ctx(plugin, context) if callable(_ctx) else _ctx return render_in_context(context, template_name, local_context) return renderer
[docs] class TemplatePluginRenderer(RegionRenderer): """ TemplatePluginRenderer is a deprecated alias for :class:`~feincms3.renderer.RegionRenderer`. """ def __init__(self, *args, **kwargs): warnings.warn( "TemplatePluginRenderer is a deprecated alias for the new RegionRenderer." " (Hint: An incremental upgrade is supported. You can start by" " replacing TemplatePluginRenderer with RegionRenderer.", DeprecationWarning, stacklevel=2, ) super().__init__(*args, **kwargs)