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),
),
)