Source code for anza.data.input.setup
"""Setup: load the templates once and build the :class:`~anza.Engine`.
:class:`~anza.Setup` describes where the templates are and how to render and
sign them. :meth:`Setup.run() <anza.Setup.run>` validates the root and the
signing keys, loads and parses every ``.html`` file under ``root`` and
returns the :class:`~anza.Engine` that :class:`~anza.Page`,
:class:`~anza.Fragment` and :class:`~anza.Stream` render with.
.. code-block:: python
from anza import Setup, SignOptions
engine = Setup(
root="./templates",
signing=SignOptions(mode="hmac", secret=secret), # 32+ bytes
fragments=["feed/", "partials/menu.html"], # fragment allowlist
escape=True, # escape {{x}}, keep {{{x}}} raw
max_template_bytes=512 * 1024, # default 1 MiB
watch=True, # reload on change
).run()
engine.close() # stops the watcher
.. rubric:: Template layout
Templates are named by their path relative to ``root``, with ``/``
separators: ``pages/home.html``, ``layout/shell.html``, ``feed/card.html``.
- ``pages/`` holds the page templates :class:`~anza.Page` resolves routes to
(``/`` is ``pages/home.html``, ``pages/index.html`` is the catch-all).
- ``layout/shell.html`` is the optional document every page goes into, as
``{{content}}``.
- Any other template can be rendered as a fragment, unless ``fragments``
limits them.
A template name is a relative path: not empty, no leading ``/``, no ``\\``
or NUL, and no empty, ``.`` or ``..`` segment. A malformed name is a
``VALIDATION`` error. Lookups only use the templates loaded at setup: a
cache miss is ``NOT_FOUND`` and request input never reaches the filesystem.
.. rubric:: Options
- ``fragments`` limits which templates a fragment can render. An entry ending
in ``/`` allows that prefix, any other entry that exact name. Anything else
is reported as ``Template '<name>' not found``. Empty (the default) allows
every template.
- ``escape`` HTML-escapes ``{{slot}}`` values; ``{{{slot}}}`` stays raw.
**Off by default**: values are inserted as is.
- ``max_template_bytes`` makes a larger file a ``TEMPLATE`` error at load.
- ``watch=True`` starts a polling thread that reloads the templates when
``.html`` files are added, changed or removed. Stop it with
:meth:`Engine.close() <anza.Engine.close>`.
- ``signing`` takes :class:`~anza.SignOptions`; see :doc:`/guide/signing`.
"""
from dataclasses import dataclass, field
from pathlib import Path
from typing import Union
from ...engine.cache.engine import Engine, SignOptions
from ...engine.cache.store import CacheStore
from ...engine.file.load import DEFAULT_MAX_TEMPLATE_BYTES, all as load_all
from ...engine.file.watch import listen
from ...crypto.signer import Signer
from ...errors import AnzaError
[docs]
@dataclass
class Setup:
"""Where the templates are and how to render and sign them.
Build one at startup and call :meth:`run` to get the
:class:`~anza.Engine`.
Example:
>>> from anza import Setup, Page
>>> engine = Setup(root="templates").run()
>>> Page("/", {"title": "Hello"}).run(engine).html
'<html><body><h1>Hello</h1></body></html>'
Warning:
With the default ``escape=False`` slot values are inserted **without
HTML escaping**. Turn ``escape`` on, or pass values through
:func:`~anza.escape_html`, when they can contain user input.
"""
#: Directory holding the templates (every ``.html`` file under it is
#: loaded).
root: Union[str, Path]
#: How fragments are signed (off by default); keys are validated by
#: :meth:`run`.
signing: SignOptions = field(default_factory=SignOptions)
#: Reload templates when files under ``root`` change (a polling thread;
#: stop it with :meth:`Engine.close() <anza.Engine.close>`).
watch: bool = False
#: Fragment allowlist. Empty (the default) lets fragments use any
#: template; otherwise an entry ending in ``/`` allows that prefix (e.g.
#: ``"fragments/"``) and any other entry allows that exact name.
fragments: list[str] = field(default_factory=list)
#: HTML-escape ``{{slot}}`` values; ``{{{slot}}}`` stays raw. Off by
#: default: values are inserted as is.
escape: bool = False
#: Refuse template files larger than this (default 1 MiB).
max_template_bytes: int = DEFAULT_MAX_TEMPLATE_BYTES
[docs]
def validate(self) -> None:
"""Check that ``root`` exists and is a directory.
Raises:
AnzaError: ``VALIDATION`` when it is missing or not a directory.
"""
p = Path(self.root)
if not p.exists():
raise AnzaError.validation(f"Templates root directory '{self.root}' does not exist")
if not p.is_dir():
raise AnzaError.validation(f"Templates root path '{self.root}' is not a directory")
[docs]
def run(self) -> Engine:
"""Validate the root and the signing keys, load every template and
start the watcher when :attr:`watch` is set.
Returns:
Engine: The engine to render with. Call
:meth:`Engine.close() <anza.Engine.close>` to stop the watcher.
Raises:
AnzaError: ``VALIDATION`` for a bad root; ``CRYPTO`` for a missing,
short or mismatched signing key (see :class:`~anza.SignOptions`);
``TEMPLATE`` for a template that is not UTF-8, is too large or
has a malformed placeholder; ``NOT_FOUND`` for a template that
resolves (through a symlink) outside ``root``.
"""
self.validate()
signer = Signer.from_options(self.signing)
def load() -> dict:
return load_all(self.root, escape=self.escape, max_bytes=self.max_template_bytes)
cache = CacheStore(load())
engine = Engine(self.root, cache, signer, fragments=self.fragments)
if self.watch:
def reload() -> None:
# Swap the whole dict: readers see the old or the new tree.
cache.templates = load()
engine.watcher = listen(self.root, reload)
return engine