Source code for evariste.plugins.renderer.jinja2

# Copyright Louis Paternault 2015-2025
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

"""Abstract class for jinja2 renderers.

See also :ref:`plugin_renderer_jinja2`.

.. autoclass:: Jinja2Renderer
   :members:

"""

# Can be removed starting with python3.11
from __future__ import annotations

import contextlib
import datetime
import functools
import importlib.resources
import os
import pathlib
import textwrap
import typing

import jinja2

from ....hooks import contexthook, iterhook
from ....utils import expand_path, smart_open
from ... import NoMatch, Plugin, errors, utils
from .file import Jinja2FileRenderer
from .readme import Jinja2ReadmeRenderer

if typing.TYPE_CHECKING:
    from ....builder import Builder
    from ....shared import Shared
    from ....tree import Tree

NOW = datetime.datetime.now()


[docs] class Jinja2Renderer(Plugin): """Abstract class for jinja2 renderers. To write your own renderer: - subclass this class; - define a default template name: :attr:`Jinja2Renderer.template`; - write such a template file, and place it in one of the :ref:`templatedirs <jinja2_options>`. The following template variables are defined and can be used in the template: :ref:`jinja2_template`; - you can also overwrite the methods defined here. You might also have a look at the implementation of the :class:`HTML renderer <evariste.plugins.renderer.html.HTMLRenderer>`. - Each file can be rendered in its own way: see :class:`~evariste.plugins.renderer.jinja2.file.Jinja2FileRenderer` (for instance, you might want to add a nice thumbnail to files that are images); - To define how files are annotated, see :class:`~evariste.plugins.renderer.jinja2.readme.Jinja2ReadmeRenderer`. """ # pylint: disable=too-few-public-methods default_templatevar = { "date": NOW.strftime("%x"), "time": NOW.strftime("%X"), "datetime": NOW.strftime("%c"), } #: Name of the default template. template: str = None default_setup = {"destfile": "output"} def __init__(self, shared: Shared): super().__init__(shared) # Manage destination directory if self.local.setup["destdir"] is None: self.destdir = self.keyword else: self.destdir = utils.expand_path(self.local.setup["destdir"]) try: os.makedirs(self.destdir, exist_ok=True) except FileExistsError as error: raise errors.EvaristeError( f"Cannot create directory '{self.destdir}'." ) from error # Create Jinja2 environment self.environment = jinja2.Environment( loader=jinja2.FileSystemLoader(self._templatedirs()), ) self.environment.filters["basename"] = os.path.basename self.environment.filters["yesno"] = utils.yesno # Dictionary of README files and renderers self.readmes = {}
[docs] @iterhook("Tree.prune_before") def get_readme(self, tree: Tree) -> Tree: """Iterate the only README file for ``tree``. If there is such a README file, iterate over it (a single value); otherwise, iterate nothing. Side effect: Store a (partial) function in ``self.readmes[tree.from_source]`` to render this README file. """ for plugin_type in self.iter_subplugins("readme"): try: renderer = self.shared.builder.plugins.match(plugin_type, tree) readme = renderer.get_readme(tree) self.readmes[tree.from_source] = functools.partial( renderer.render, readme ) yield readme.from_source return except NoMatch: continue return
[docs] @utils.cached_iterator def iter_subplugins(self, subtype: str) -> typing.Iterable[Plugin]: """Iterate over subplugins of type ``subtype``. This method iterates plugins (as their keywords) ``{keyword}.{subtype}``, where ``keyword`` is the attribute of this class, or its subclasses. For instance, given that: - the correct plugins are loaded; - plugin :class:`renderer.html <evariste.plugins.renderer.html.HTMLRenderer>` is a subclass of :class:`renderer.jinja2 <Jinja2Renderer>`, call to ``Jinja2Renderer.iter_subplugins(HtmlRenderer(), "readme")`` will yield: ``renderer.html.readme``, ``renderer.html.readme.markdown``… """ for parent in self.__class__.mro(): # pylint: disable=no-member if not hasattr(parent, "keyword"): break if parent.keyword is None: continue yield f"{parent.keyword}.{subtype}"
def _templatedirs(self): """Iterator over the directories in which templates may exist. - Directories are returned as strings; - directories may not exist. """ if self.local.setup["templatedirs"] is not None: yield from utils.expand_path(self.local.setup["templatedirs"]).split() yield importlib.resources.files( self.__class__.__module__ ) / "data" / "templates" yield from [ os.path.join(utils.expand_path(path), "templates") for path in [ ".evariste", "~/.config/evariste", "~/.evariste", "/usr/share/evariste", ] ]
[docs] def render_tree(self, tree: Tree) -> str: """Render the tree using templates, and return the string.""" # Copy targets to destination for file in tree.walk(dirs=False, files=True): if file.report.success: for target in file.report.targets: utils.copy( (file.root.from_fs / target).as_posix(), (pathlib.Path(self.destdir) / target).as_posix(), ) # Select main template if self.local.setup["template"] is None: template = self.template else: template = self.local.setup["template"] # Create template loading file renderers content = "" for plugin_type in self.iter_subplugins("file"): for subrenderer in self.shared.builder.plugins.values(plugin_type): if subrenderer.extension is None: subtemplate = subrenderer.template else: subtemplate = f"{subrenderer.template}.{subrenderer.extension}" content += textwrap.dedent(f"""\ {{% from "file/{subtemplate}" import file as file_{subrenderer.template} with context %}} """) content += f"""{{% include "{template}" %}}""" # Render template return self.environment.from_string(content).render( { "destdir": pathlib.Path(self.destdir), "shared": self.shared, "local": self.local, "sourcepath": self._sourcepath, "render_file": self._render_file, "render_readme": self._render_readme, "render_template": self._render_template, "templatevar": self._get_templatevar(), "tree": tree, } )
[docs] @contexthook("Builder.compile") @contextlib.contextmanager def render(self, builder: Builder) -> None: """Render the tree as a file, and write result into the destination file.""" yield with smart_open(expand_path(self.local.setup["destfile"]), "w") as destfile: destfile.write(self.render_tree(builder.tree))
def _get_templatevar(self): """Return the template variables. - First, update it with the default variables of this class (`self.default_templatevar`), then its ancestors. - Then, update it with the variables defined in the setup file. """ templatevar = {} for parent in reversed(self.__class__.mro()): # pylint: disable=no-member templatevar.update(getattr(parent, "default_templatevar", {})) templatevar.update(self.shared.setup[f"{self.keyword}.templatevar"]) return templatevar @jinja2.pass_context def _render_file(self, context, filename): """Render ``context['file']``, which is a :class:`pathlib.Path`.""" for plugin_type in self.iter_subplugins("file"): try: return self.shared.builder.plugins.match(plugin_type, filename).render( filename, context ) except NoMatch: continue return "" @jinja2.pass_context def _sourcepath(self, context, tree): """Return the path to the source file or archive. This functions builds the archive before returning its path. It can be called several times: the archive will be built only once. """ return tree.make_archive(context["destdir"]) @jinja2.pass_context def _render_readme(self, context, tree): """Return the code for the readme of `tree`.""" # pylint: disable=unused-argument if tree.from_source in self.readmes: return self.readmes[tree.from_source]() return "" @jinja2.pass_context def _render_template(self, context, template): """Render template given in argument.""" return textwrap.indent( self.environment.get_or_select_template(template).render(context), " " )