Source code for render_static.engine

import os
from collections import Counter, namedtuple
from pathlib import Path
from shutil import copy2
from typing import Callable, Dict, Generator, List, Optional, Tuple, Union, cast

from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.template import Context, Template
from django.template.backends.django import Template as DjangoTemplate
from django.template.exceptions import TemplateDoesNotExist
from django.template.utils import InvalidTemplateEngineError
from django.utils.functional import cached_property
from django.utils.module_loading import import_string

from render_static.backends.base import StaticEngine
from render_static.context import resolve_context
from render_static.exceptions import InvalidContext

__all__ = ["StaticTemplateEngine", "Render"]


class Render(namedtuple("_Render", ["selector", "config", "template", "destination"])):
    """
    A named tuple that holds all the pertinent information for a template
    including:

        - The selector used to select it
        - Its configuration from settings, if any
        - Its template engine Template class - could be a Django or Jinja2
            template
        - The destination where it will be/was rendered
    """

    def __str__(self) -> str:
        app = getattr(self.template.origin, "app", None)
        if app:
            return (
                f"[{app.label}] {self.template.origin.template_name} -> "
                f"{self.destination}"
            )
        return f"{self.template.origin.template_name} -> {self.destination}"

    @property
    def is_dir(self) -> bool:
        """
        True if the destination is a directory, false otherwise.
        """
        return getattr(getattr(self.template, "template", None), "is_dir", False)


def _resolve_context(
    context: Optional[Union[Dict, Callable, str, Path]], template: Optional[str] = None
) -> Dict:
    """
    Resolve a context configuration parameter into a context dictionary. If
    the context is a string it is treated as an importable string pointing to
    a callable, if it is a callable it is called and if it is a dictionary it
    is simply returned. Any failure to resolve a dictionary from the
    configuration.

    :param context: Either an importable string pointing to a callable, a
        callable instance or a dictionary
    :return: dictionary holding the context
    :raises ImproperlyConfigured: if there is a failure to produce a
        dictionary context
    """
    try:
        return resolve_context(context)
    except InvalidContext as inval_ctx:
        raise ImproperlyConfigured(
            f"STATIC_TEMPLATES 'context' configuration directive"
            f"{' for ' + template if template else ''} must be a dictionary "
            f"or a callable that returns a dictionary!"
        ) from inval_ctx


[docs] class StaticTemplateEngine: """ An engine for rendering static templates to disk based on a standard :setting:`STATIC_TEMPLATES` configuration either passed in at construction or obtained from settings. Static templates are most usually generated by a run of :django-admin:`renderstatic` preceding :django-admin:`collectstatic`, but this class encapsulates all the behavior of the static engine, may be used independently and can override configured parameters including contexts and render destinations: .. code-block:: python from render_static.engine import StaticTemplateEngine from django.conf import settings from pathlib import Path # This engine uses the settings.STATIC_TEMPLATE config engine = StaticTemplateEngine() # This engine uses a custom configuration engine = StaticTemplateEngine({ 'ENGINES': [{ 'BACKEND': 'render_static.backends.jinja2.StaticJinja2Templates', 'APP_DIRS': True }], 'context': { 'var1': 'value1' }, 'templates': { 'app/html/my_template.html': { 'context': { 'var1': 'value2' } } } }) # this will render the my_template.html template to # app/static/app/html/my_template.html with the context: # { 'settings': settings, 'var1': 'value2' } engine.render_to_disk('app/html/my_template.html') # using the engine directly we can override configuration directives, # this will render the template with the context: # { 'settings': settings, 'var1': 'value3' } @ the custom location # 'static_dir/rendered.html' engine.render_to_disk( 'app/html/my_template.html', context={'var1': 'value3'}, destination=Path(settings.BASE_DIR) / 'static_dir/rendered.html' ) """ app_dirname: str config_: Dict = {} DEFAULT_ENGINE_CONFIG = [ { "BACKEND": "render_static.backends.StaticDjangoTemplates", "OPTIONS": { "loaders": ["render_static.loaders.StaticAppDirectoriesBatchLoader"], "builtins": ["render_static.templatetags.render_static"], }, } ]
[docs] class TemplateConfig: """ Container for template specific configuration parameters. """ context_: Dict = {} dest_: Optional[Path] = None
[docs] def __init__( self, name: str, dest: Optional[Union[Path, str]] = None, context: Optional[Union[Dict, Callable, str]] = None, ) -> None: """ :param name: The name of the template :param dest: The absolute destination directory where the template will be written. May be None which indicates the template will be written to its owning app's static directory if it was loaded with an app directory loader :param context: A specific dictionary context to use for this template, may also be an import string to a callable or a callable that generates a dictionary. This may override global context parameters. :raises: :exc:`~django.core.exceptions.ImproperlyConfigured` If there are any unexpected or misconfigured parameters """ self.name = name if dest is not None: if not isinstance(dest, (str, Path)): raise ImproperlyConfigured( f"Template {name} 'dest' parameter in STATIC_TEMPLATES" f" must be a string or path-like object, not " f"{type(dest)}" ) self.dest_ = Path(dest) if not self.dest_.is_absolute(): raise ImproperlyConfigured( f"In STATIC_TEMPLATES, template {name} dest must be absolute!" ) context = _resolve_context(context, template=name) if context: self.context_ = context
@property def context(self) -> Dict: """ The context specific to this template. This will not include global parameters only the context as specified in the template configuration. """ return self.context_ @property def dest(self) -> Optional[Path]: """ The location this template should be saved to, if specified. """ return self.dest_
[docs] def __init__(self, config: Optional[Dict] = None) -> None: """ :param config: If provided use this configuration instead of the one from settings :raises: :exc:`~django.core.exceptions.ImproperlyConfigured`: If there are any errors in the configuration passed in or specified in settings. """ if config: self.config_ = config
@cached_property def config(self) -> dict: """ Lazy configuration property. Fetch the :setting:`STATIC_TEMPLATES` configuration dictionary which will either be the configuration passed in on initialization or the config specified in the :setting:`STATIC_TEMPLATES` setting. :return: The :setting:`STATIC_TEMPLATES` configuration this engine has initialized from :raises: :exc:`~django.core.exceptions.ImproperlyConfigured` If there are any terminal errors with the configurations """ if not self.config_: self.config_ = getattr(settings, "STATIC_TEMPLATES", {}) or {} unrecognized_keys = [ key for key in self.config_.keys() if key not in ["ENGINES", "templates", "context"] ] if unrecognized_keys: raise ImproperlyConfigured( f"Unrecognized STATIC_TEMPLATES configuration directives: " f"{unrecognized_keys}" ) return self.config_ @cached_property def context(self) -> dict: """ Lazy context property. Fetch the global context that will be fed to all templates. This includes the settings object and anything listed in the context dictionary in the :setting:`STATIC_TEMPLATES` configuration. :return: A dictionary containing the global template context :raises: :exc:`~django.core.exceptions.ImproperlyConfigured` If the template context is specified and is not a dictionary. """ return { "settings": settings, **_resolve_context(self.config.get("context", {})), } @cached_property def templates(self) -> List[Tuple[str, TemplateConfig]]: """ Lazy template property Fetch the dictionary mapping template names to TemplateConfig objects initializing them if necessary. This function transforms all acceptable `template` specifications into the canonical type as a list of name, config pairs. :return: A dictionary mapping template names to configurations :raises: :exc:`~django.core.exceptions.ImproperlyConfigured` If there are any configuration issues with the templates """ try: templates = self.config.get("templates", {}) templates = [ (name, StaticTemplateEngine.TemplateConfig(name=name, **config)) for name, config in ( templates.items() if isinstance(templates, dict) else [ (entry, {}) if isinstance(entry, str) else (entry[0], entry[1] or {} if len(entry) > 1 else {}) for entry in templates ] ) ] except ImproperlyConfigured: raise except Exception as exp: raise ImproperlyConfigured( f"Invalid 'templates' in STATIC_TEMPLATE: {exp}!" ) from exp return templates
[docs] def get_templates(self, name: str) -> List[TemplateConfig]: """ Get a list of registered template configurations that belong to the given template name. :param name: The name of the template :return: A list of TemplateConfig objects that are configured in the settings for rendering that correspond to a template of the given name or an empty list if no configurations match the name. """ templates = [] for tmpl, config in self.templates: if name == tmpl: templates.append(config) return templates
@cached_property def engines(self) -> dict: """ Lazy engines property. Fetch the dictionary of engine names to engine instances based on the configuration, initializing said entities if necessary. :return: A dictionary mapping engine names to instances :raises: :exc:`~django.core.exceptions.ImproperlyConfigured` If there are configuration problems with the engine backends. """ engine_defs = self.config.get("ENGINES", None) if engine_defs is None: self.config["ENGINES"] = self.DEFAULT_ENGINE_CONFIG elif not hasattr(engine_defs, "__iter__"): raise ImproperlyConfigured( f"ENGINES in STATIC_TEMPLATES setting must be an iterable " f"containing engine configurations! Encountered: " f"{type(engine_defs)}" ) engines = {} backend_names = [] for backend in self.config.get("ENGINES", []): try: # This will raise an exception if 'BACKEND' doesn't exist or # isn't a string containing at least one dot. default_name = backend["BACKEND"].rsplit(".", 2)[-1] except Exception as exp: invalid_backend = backend.get("BACKEND", "<not defined>") raise ImproperlyConfigured( f"Invalid BACKEND for a static template engine: " f"{invalid_backend}. Check your STATIC_TEMPLATES setting." ) from exp # set defaults backend = { "NAME": default_name, "DIRS": [], "APP_DIRS": False, "OPTIONS": {}, **backend, } engines[backend["NAME"]] = backend backend_names.append(backend["NAME"]) counts = Counter(backend_names) duplicates = [alias for alias, count in counts.most_common() if count > 1] if duplicates: raise ImproperlyConfigured( f"Template engine aliases are not unique, duplicates: " f"{', '.join(duplicates)}. Set a unique NAME for each engine " f"in settings.STATIC_TEMPLATES." ) for alias, config in engines.items(): params = config.copy() backend = params.pop("BACKEND") engines[alias] = import_string(backend)(params) return engines
[docs] def __getitem__(self, alias: str) -> StaticEngine: """ Accessor for backend instances indexed by name. :param alias: The name of the backend to fetch :return: The backend instance :raises: :exc:`~django.template.utils.InvalidTemplateEngineError` If a backend of the given alias does not exist """ try: return self.engines[alias] except KeyError as key_error: raise InvalidTemplateEngineError( f"Could not find config for '{alias}' in settings.STATIC_TEMPLATES" ) from key_error
def __iter__(self): """ Iterate through the backends. """ return iter(self.engines)
[docs] def all(self) -> List[StaticEngine]: """ Get a list of all registered engines in order of precedence. :return: A list of engine instances in order of precedence """ return [self[alias] for alias in self]
[docs] @staticmethod def resolve_destination( config: TemplateConfig, template: DjangoTemplate, batch: bool, dest: Optional[Union[str, Path]] = None, ) -> Path: """ Resolve the destination for a template, given all present configuration parameters for it and arguments passed in. :param config: The template configuration :param template: The template object created by the backend, could be a Jinja2 or Django template :param batch: True if this is part of a batch render, false otherwise :param dest: The destination passed in from the command line :return: An absolute destination path :raises ImproperlyConfigured: if a render destination cannot be determined """ app = getattr(template.origin, "app", None) if dest is None: dest = config.dest if dest is None: if app: dest = Path(app.path) / "static" else: try: dest = Path(settings.STATIC_ROOT) except (AttributeError, TypeError) as err: raise ImproperlyConfigured( f"Template {template.template.name} must either be " f"configured with a 'dest' or STATIC_ROOT must be " f"defined in settings, because it was not loaded from " f"an app!" ) from err dest /= template.template.name or "" elif batch or Path(dest).is_dir(): dest = Path(dest) / (template.template.name or "") return Path(dest if dest else "")
[docs] def render_to_disk( self, selector: str, context: Optional[Dict] = None, dest: Optional[Union[str, Path]] = None, first_engine: bool = False, first_loader: bool = False, first_preference: bool = False, exclude: Optional[List[Path]] = None, render_contents: bool = True, ) -> List[Render]: """ Wrap render_each generator function and return the whole list of rendered templates for the given selector. :param selector: The name of the template to render to disk :param context: Additional context parameters that will override configured context parameters :param dest: Override the configured path to render the template at this path, either a string path, or Path like object. If the selector resolves to multiple templates, dest will be considered a directory. If the the selector resolves to a single template, dest will be considered the final file path, unless it already exists as a directory. :param first_engine: If true, render only the set of template names that match the selector that are found by the first rendering engine. By default (False) any templates that match the selector from any engine will be rendered. :param first_loader: If True, render only the set of template names from the first loader that matches any part of the selector. By default (False) any template name that matches the selector from any loader will be rendered. :param first_preference: If true, render only the templates that match the first preference for each loader. When combined with first_loader will render only the first preference(s) of the first loader. Preferences are loader specific and documented on the loader. :param exclude: A list of template paths to exclude. If the path is a directory, any template below that directory will be excluded. This parameter only makes sense to use if your selector is a glob pattern. :param render_contents: If False, do not render the contents of the template. If the destination path is a template it will still be rendered against the context to produce the final path. :return: Render object for all the template(s) rendered to disk :raises TemplateDoesNotExist: if no template by the given name is found :raises ImproperlyConfigured: if not enough information was given to render and write the template """ return [ render for render in self.render_each( selector, context=context, dest=dest, first_engine=first_engine, first_loader=first_loader, first_preference=first_preference, exclude=exclude, render_contents=render_contents, ) ]
[docs] def find( self, *selectors: str, dest: Optional[Union[str, Path]] = None, first_engine: bool = False, first_loader: bool = False, first_preference: bool = False, exclude: Optional[List[Path]] = None, ) -> Generator[Render, None, None]: """ Search for all templates that match the given selectors and yield Render objects for each one. :param selectors: The name(s) of the template(s) to render to disk :param dest: see render_each :param first_engine: See render_each :param first_loader: See render_each :param first_preference: See render_each :param exclude: A list of template paths to exclude. If the path is a directory, any template below that directory will be excluded. This parameter only makes sense to use if your selector is a glob pattern. :yield: Render objects for each template to disk :raises TemplateDoesNotExist: if no template by the given name is found """ # all jobs are considered part of a batch if dest is provided and more # than one selector is provided batch = bool(len(selectors) > 1 and dest) for selector in selectors: for config in ( self.get_templates(selector) or # use a default config if no template configuration was found [StaticTemplateEngine.TemplateConfig(name=selector)] ): yield from self.resolve_renderings( selector, config, batch, dest=dest, first_engine=first_engine, first_loader=first_loader, first_preference=first_preference, exclude=exclude, )
[docs] def search( self, prefix: str, first_engine: bool = False, first_loader: bool = False, ) -> Generator[Template, None, None]: """ Search for all templates that match the given selectors and yield Render objects for each one. :param prefix: The name(s) of the template(s) to render to disk :param dest: see render_each :param first_engine: See render_each :param first_loader: See render_each :yield: Templates found that start with the given prefix. """ # all jobs are considered part of a batch if dest is provided and more # than one selector is provided for engine in self.all()[0 : 1 if first_engine else None]: yield from engine.search_templates(prefix, first_loader=first_loader)
[docs] def render_each( self, *selectors: str, context: Optional[Union[Dict, str, Path, Callable]] = None, dest: Optional[Union[str, Path]] = None, first_engine: bool = False, first_loader: bool = False, first_preference: bool = False, exclude: Optional[List[Path]] = None, render_contents: bool = True, ) -> Generator[Render, None, None]: """ A generator function that renders all selected templates of the highest precedence for each matching template name to disk. The location of the directory of the rendered template will either be based on the `dest` configuration parameter for the template or the app the template was found in. :param selectors: The name(s) of the template(s) to render to disk :param context: Additional context parameters that will override configured context parameters :param dest: Override the configured path to render the template at this path, either a string path, or Path like object. If the selector(s) resolve to multiple templates, dest will be considered a directory. If the the selector(s) resolve to a single template, dest will be considered the final file path, unless it already exists as a directory. :param first_engine: If true, render only the set of template names that match the selector that are found by the first rendering engine. By default (False) any templates that match the selector from any engine will be rendered. :param first_loader: If True, render only the set of template names from the first loader that matches any part of the selector. By default (False) any template name that matches the selector from any loader will be rendered. :param first_preference: If true, render only the templates that match the first preference for each loader. When combined with first_loader will render only the first preference(s) of the first loader. Preferences are loader specific and documented on the loader. :param exclude: A list of template paths to exclude. If the path is a directory, any template below that directory will be excluded. This parameter only makes sense to use if your selector is a glob pattern. :param render_contents: If False, do not render the contents of the template. If the destination path is a template it will still be rendered against the context to produce the final path. :yield: Render objects for each template to disk :raises TemplateDoesNotExist: if no template by the given name is found :raises ImproperlyConfigured: if not enough information was given to render and write the template """ if context: context = resolve_context(context) for render in self.find( *selectors, dest=dest, first_engine=first_engine, first_loader=first_loader, first_preference=first_preference, exclude=exclude, ): ctx = render.config.context.copy() if context is not None: ctx.update(context) r_ctx = {**self.context, **ctx} dest = Template(render.destination).render(Context(r_ctx)) if render.is_dir: os.makedirs(str(dest), exist_ok=True) else: os.makedirs(Path(dest or "").parent, exist_ok=True) if render_contents: with open(str(dest), "w", encoding="UTF-8") as out: out.write(render.template.render(r_ctx)) else: copy2(Path(render.template.origin.name), Path(dest)) yield render
[docs] def resolve_renderings( self, selector: str, config: TemplateConfig, batch: bool, exclude: Optional[List[Path]] = None, **kwargs, ) -> Generator[Render, None, None]: """ Resolve the given parameters to a or a set of Render objects containing all the information necessary to render a template to disk. :param selector: The template selector (name string) :param config: The TemplateConfig to apply to the selector. :param batch: True if this is a batch rendering, false otherwise. :param exclude: A list of template paths to exclude. If the path is a directory, any template below that directory will be excluded. This parameter only makes sense to use if your selector is a glob pattern. :param kwargs: Pass through parameters from render_each :yield: Render objects """ templates: Dict[str, DjangoTemplate] = {} chain = [] excluded_dirs = [] excluded_files = [] for xcl in exclude or []: if xcl.is_dir(): excluded_dirs.append(xcl.absolute()) else: excluded_files.append(xcl.absolute()) for engine in self.all(): try: for template_name in engine.select_templates( selector, first_loader=kwargs.get("first_loader", False), first_preference=kwargs.get("first_preference", False), ): try: templates.setdefault( template_name, cast( DjangoTemplate, engine.get_template(template_name.replace(os.sep, "/")), ), ) except TemplateDoesNotExist as tdne: # pragma: no cover # this should be impossible w/o a loader bug! if len(templates): raise RuntimeError( f"Selector resolved to template " f"{template_name} which is not " f"loadable: {tdne}" ) from tdne if kwargs.get("first_engine", False) and templates: break except TemplateDoesNotExist as tdne: chain.append(tdne) continue if not templates: raise TemplateDoesNotExist(selector, chain=chain) for _, template in templates.items(): tmpl_abs_path = Path(template.origin.name).absolute() if any( ( excl == tmpl_abs_path or excl in tmpl_abs_path.parents for excl in excluded_dirs ) ): continue if any((tmpl_abs_path == excl for excl in excluded_files)): continue yield Render( selector=selector, config=config, template=template, destination=self.resolve_destination( config, template, # each selector is a batch if it resolves to # more than one template bool(batch or len(templates) > 1), kwargs.get("dest", None), ), )