Skip to content

Commit

Permalink
feat(template): add template_str for rendering from strings (#2689)
Browse files Browse the repository at this point in the history
* feat(template): add ``template_str`` for rendering from strings

* fix: match media type options for template files

* fix: warn of precedence

* feat: add mako

* fix: media type setting wrong

* feat: minijinja

* chore: remove testing method

* chore: apply @Alc-Alc suggestion

* docs: add usage documentation

* tests: add tests

* ci: mypy

* fix(tests): caught bug that was running minijinja tests as jinja

* docs: properly link to HTMX page

* ci(coverage): fill in missing coverage

* tests: test catching ``None`` template types

* tests: fix none type tests

converge boilerplate 3x into 1x

* Update litestar/response/template.py

Co-authored-by: guacs <126393040+guacs@users.noreply.github.com>

* fix: raise error if both template_name and template_str are defined

* docs: remove stale statement about precedence

* ci(mypy): make happy the type checking goddess

* chore: apply suggestion from @guacs

* chore: apply suggestion from @peterschutt

* fix: remove unneeded check

* test: rework test to get new error raised

* ci(typing): make mypy happy-ish

* refactor: narrowing away optional template engine

* ci: apply pre-commit

---------

Co-authored-by: guacs <126393040+guacs@users.noreply.github.com>
Co-authored-by: Peter Schutt <peter.github@proton.me>
Co-authored-by: alc-alc <45509143+Alc-Alc@users.noreply.github.com>
  • Loading branch information
4 people committed Nov 17, 2023
1 parent 38f3b19 commit d536235
Show file tree
Hide file tree
Showing 14 changed files with 247 additions and 57 deletions.
9 changes: 6 additions & 3 deletions docs/examples/templating/returning_templates_jinja.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@
from litestar.template.config import TemplateConfig


@get(path="/", sync_to_thread=False)
def index(name: str) -> Template:
return Template(template_name="hello.html.jinja2", context={"name": name})
@get(path="/{template_type: str}", sync_to_thread=False)
def index(name: str, template_type: str) -> Template:
if template_type == "file":
return Template(template_name="hello.html.jinja2", context={"name": name})
elif template_type == "string":
return Template(template_str="Hello <strong>Jinja</strong> using strings", context={"name": name})


app = Litestar(
Expand Down
11 changes: 8 additions & 3 deletions docs/examples/templating/returning_templates_mako.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

from pathlib import Path

from litestar import Litestar, get
Expand All @@ -6,9 +8,12 @@
from litestar.template.config import TemplateConfig


@get(path="/", sync_to_thread=False)
def index(name: str) -> Template:
return Template(template_name="hello.html.mako", context={"name": name})
@get(path="/{template_type: str}", sync_to_thread=False)
def index(name: str, template_type: str) -> Template:
if template_type == "file":
return Template(template_name="hello.html.mako", context={"name": name})
elif template_type == "string":
return Template(template_str="Hello <strong>Mako</strong> using strings", context={"name": name})


app = Litestar(
Expand Down
11 changes: 8 additions & 3 deletions docs/examples/templating/returning_templates_minijinja.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

from pathlib import Path

from litestar import Litestar, get
Expand All @@ -6,9 +8,12 @@
from litestar.template.config import TemplateConfig


@get(path="/")
def index(name: str) -> Template:
return Template(template_name="hello.html.minijinja", context={"name": name})
@get(path="/{template_type: str}", sync_to_thread=False)
def index(name: str, template_type: str) -> Template:
if template_type == "file":
return Template(template_name="hello.html.minijinja", context={"name": name})
elif template_type == "string":
return Template(template_str="Hello <strong>Minijinja</strong> using strings", context={"name": name})


app = Litestar(
Expand Down
56 changes: 51 additions & 5 deletions docs/usage/templating.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ Templating
==========

Litestar has built-in support for `Jinja2 <https://jinja.palletsprojects.com/en/3.0.x/>`_
, `Mako <https://www.makotemplates.org/>`_ and `Minijinja <https://github.com/mitsuhiko/minijinja/tree/main/minijinja-py>`_ template engines, as well as abstractions to
make use of any template engine you wish.
, `Mako <https://www.makotemplates.org/>`_ and `Minijinja <https://github.com/mitsuhiko/minijinja/tree/main/minijinja-py>`_
template engines, as well as abstractions to make use of any template engine you wish.

Template engines
----------------
Expand All @@ -12,10 +12,28 @@ To stay lightweight, a Litestar installation does not include the *Jinja*, *Mako
libraries themselves. Before you can start using them, you have to install it via the
respective extra:

.. tab-set::

.. tab-item:: Jinja
:sync: jinja

* ``pip install litestar[jinja]`` for Jinja2
* ``pip install litestar[mako]`` for Mako
* ``pip install litestar[minijinja]`` for Minijinja
.. code-block:: shell
pip install litestar[jinja]
.. tab-item:: Mako
:sync: mako

.. code-block:: shell
pip install litestar[mako]
.. tab-item:: MiniJinja
:sync: minijinja

.. code-block:: shell
pip install litestar[minijinja]
.. tip::

Expand Down Expand Up @@ -163,6 +181,34 @@ your route handlers:
* ``context`` is a dictionary containing arbitrary data that will be passed to the template
engine's ``render`` method. For Jinja and Mako, this data will be available in the `template context <#template-context>`_

Template Files vs. Strings
--------------------------

When you define a template response, you can either pass a template file name or a string
containing the template. The latter is useful if you want to define the template inline
for small templates or :doc:`HTMX </usage/htmx>` responses for example.

.. tab-set::

.. tab-item:: File name

.. code-block:: python
:caption: Template via file
@get()
async def example() -> Template:
return Template(template_name="test.html", context={"hello": "world"})
.. tab-item:: String

.. code-block:: python
:caption: Template via string
@get()
async def example() -> Template:
template_string = "{{ hello }}"
return Template(template_str=template_string, context={"hello": "world"})
Template context
----------------

Expand Down
3 changes: 2 additions & 1 deletion litestar/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
from litestar.openapi.spec.open_api import OpenAPI
from litestar.static_files.config import StaticFilesConfig
from litestar.stores.base import Store
from litestar.template import TemplateEngineProtocol
from litestar.template.config import TemplateConfig
from litestar.types import (
AfterExceptionHookHandler,
Expand Down Expand Up @@ -216,7 +217,7 @@ def __init__(
static_files_config: Sequence[StaticFilesConfig] | None = None,
stores: StoreRegistry | dict[str, Store] | None = None,
tags: Sequence[str] | None = None,
template_config: TemplateConfig | None = None,
template_config: TemplateConfig[TemplateEngineProtocol] | None = None,
type_encoders: TypeEncodersMap | None = None,
type_decoders: TypeDecodersSequence | None = None,
websocket_class: type[WebSocket] | None = None,
Expand Down
3 changes: 2 additions & 1 deletion litestar/config/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from litestar.static_files.config import StaticFilesConfig
from litestar.stores.base import Store
from litestar.stores.registry import StoreRegistry
from litestar.template import TemplateEngineProtocol
from litestar.template.config import TemplateConfig
from litestar.types import (
AfterExceptionHookHandler,
Expand Down Expand Up @@ -196,7 +197,7 @@ class AppConfig:
"""
tags: list[str] = field(default_factory=list)
"""A list of string tags that will be appended to the schema of all route handlers under the application."""
template_config: TemplateConfig | None = field(default=None)
template_config: TemplateConfig[TemplateEngineProtocol] | None = field(default=None)
"""An instance of :class:`TemplateConfig <.template.TemplateConfig>`."""
type_encoders: TypeEncodersMap | None = field(default=None)
"""A mapping of types to callables that transform them into types supported for serialization."""
Expand Down
15 changes: 14 additions & 1 deletion litestar/contrib/jinja.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ def __init__(
directory: Path | list[Path] | None = None,
engine_instance: Environment | None = None,
) -> None:
"""Jinja based TemplateEngine.
"""Jinja-based TemplateEngine.
Args:
directory: Direct path or list of directory paths from which to serve templates.
Expand Down Expand Up @@ -88,6 +88,19 @@ def register_template_callable(
"""
self.engine.globals[key] = pass_context(template_callable)

def render_string(self, template_string: str, context: Mapping[str, Any]) -> str:
"""Render a template from a string with the given context.
Args:
template_string: The template string to render.
context: A dictionary of variables to pass to the template.
Returns:
The rendered template as a string.
"""
template = self.engine.from_string(template_string)
return template.render(context)

@classmethod
def from_environment(cls, jinja_environment: Environment) -> JinjaTemplateEngine:
"""Create a JinjaTemplateEngine from an existing jinja Environment instance.
Expand Down
19 changes: 16 additions & 3 deletions litestar/contrib/mako.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,13 @@
try:
from mako.exceptions import TemplateLookupException as MakoTemplateNotFound # type: ignore[import-untyped]
from mako.lookup import TemplateLookup # type: ignore[import-untyped]
from mako.template import Template as _MakoTemplate # type: ignore[import-untyped]
except ImportError as e:
raise MissingDependencyException("mako") from e

if TYPE_CHECKING:
from pathlib import Path

from mako.template import Template as _MakoTemplate # type: ignore[import-untyped]

__all__ = ("MakoTemplate", "MakoTemplateEngine")

P = ParamSpec("P")
Expand Down Expand Up @@ -64,7 +63,7 @@ def render(self, *args: Any, **kwargs: Any) -> str:


class MakoTemplateEngine(TemplateEngineProtocol[MakoTemplate, Mapping[str, Any]]):
"""Mako based TemplateEngine."""
"""Mako-based TemplateEngine."""

def __init__(self, directory: Path | list[Path] | None = None, engine_instance: Any | None = None) -> None:
"""Initialize template engine.
Expand Down Expand Up @@ -121,6 +120,20 @@ def register_template_callable(
"""
self._template_callables.append((key, template_callable))

@staticmethod
def render_string(template_string: str, context: Mapping[str, Any]) -> str:
"""Render a template from a string with the given context.
Args:
template_string: The template string to render.
context: A dictionary of variables to pass to the template.
Returns:
The rendered template as a string.
"""
template = _MakoTemplate(template_string)
return template.render(**context) # type: ignore[no-any-return]

@classmethod
def from_template_lookup(cls, template_lookup: TemplateLookup) -> MakoTemplateEngine:
"""Create a template engine from an existing mako TemplateLookup instance.
Expand Down
21 changes: 21 additions & 0 deletions litestar/contrib/minijinja.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,27 @@ def register_template_callable(
"""
self.engine.add_global(key, pass_state(template_callable))

def render_string(self, template_string: str, context: Mapping[str, Any]) -> str:
"""Render a template from a string with the given context.
Args:
template_string: The template string to render.
context: A dictionary of variables to pass to the template.
Returns:
The rendered template as a string.
Raises:
TemplateNotFoundException: if no template is found.
"""
try:
return self.engine.render_str(template_string, **context) # type: ignore[no-any-return]
except MiniJinjaTemplateNotFound as err:
raise TemplateNotFoundException(
f"Error rendering template from string: {err}",
template_name="template_from_string",
) from err

@classmethod
def from_environment(cls, minijinja_environment: Environment) -> MiniJinjaTemplateEngine:
"""Create a MiniJinjaTemplateEngine from an existing minijinja Environment instance.
Expand Down
40 changes: 29 additions & 11 deletions litestar/response/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import itertools
from mimetypes import guess_type
from pathlib import PurePath
from typing import TYPE_CHECKING, Any, Iterable
from typing import TYPE_CHECKING, Any, Iterable, cast

from litestar.constants import SCOPE_STATE_CSRF_TOKEN_KEY
from litestar.enums import MediaType
Expand All @@ -28,13 +28,15 @@ class Template(Response[bytes]):

__slots__ = (
"template_name",
"template_str",
"context",
)

def __init__(
self,
template_name: str,
template_name: str | None = None,
*,
template_str: str | None = None,
background: BackgroundTask | BackgroundTasks | None = None,
context: dict[str, Any] | None = None,
cookies: ResponseCookies | None = None,
Expand All @@ -47,6 +49,7 @@ def __init__(
Args:
template_name: Path-like name for the template to be rendered, e.g. ``index.html``.
template_str: A string representing the template, e.g. ``tmpl = "Hello <strong>World</strong>"``.
background: A :class:`BackgroundTask <.background_tasks.BackgroundTask>` instance or
:class:`BackgroundTasks <.background_tasks.BackgroundTasks>` to execute after the response is finished.
Defaults to ``None``.
Expand All @@ -59,6 +62,12 @@ def __init__(
the media type based on the template name. If this fails, fall back to ``text/plain``.
status_code: A value for the response HTTP status code.
"""
if not (template_name or template_str):
raise ValueError("Either template_name or template_str must be provided.")

if template_name and template_str:
raise ValueError("Either template_name or template_str must be provided, not both.")

super().__init__(
background=background,
content=b"",
Expand All @@ -70,6 +79,7 @@ def __init__(
)
self.context = context or {}
self.template_name = template_name
self.template_str = template_str

def create_template_context(self, request: Request) -> dict[str, Any]:
"""Create a context object for the template.
Expand Down Expand Up @@ -110,25 +120,33 @@ def to_asgi_response(
alternative="request.app",
)

if not request.app.template_engine:
if not (template_engine := request.app.template_engine):
raise ImproperlyConfiguredException("Template engine is not configured")

headers = {**headers, **self.headers} if headers is not None else self.headers
cookies = self.cookies if cookies is None else itertools.chain(self.cookies, cookies)

media_type = self.media_type or media_type
if not media_type:
suffixes = PurePath(self.template_name).suffixes
for suffix in suffixes:
if _type := guess_type(f"name{suffix}")[0]:
media_type = _type
break
if self.template_name:
suffixes = PurePath(self.template_name).suffixes
for suffix in suffixes:
if _type := guess_type(f"name{suffix}")[0]:
media_type = _type
break
else:
media_type = MediaType.TEXT
else:
media_type = MediaType.TEXT
media_type = MediaType.HTML

template = request.app.template_engine.get_template(self.template_name)
context = self.create_template_context(request)
body = template.render(**context).encode(self.encoding)

if self.template_str is not None:
body = template_engine.render_string(self.template_str, context)
else:
# cast to str b/c we know that either template_name cannot be None if template_str is None
template = template_engine.get_template(cast("str", self.template_name))
body = template.render(**context).encode(self.encoding)

return ASGIResponse(
background=self.background or background,
Expand Down
12 changes: 12 additions & 0 deletions litestar/template/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,18 @@ def get_template(self, template_name: str) -> TemplateType_co:
"""
raise NotImplementedError

def render_string(self, template_string: str, context: Mapping[str, Any]) -> str:
"""Render a template from a string with the given context.
Args:
template_string: The template string to render.
context: A dictionary of variables to pass to the template.
Returns:
The rendered template as a string.
"""
raise NotImplementedError

def register_template_callable(
self, key: str, template_callable: TemplateCallableType[ContextType_co, P, R]
) -> None:
Expand Down

0 comments on commit d536235

Please sign in to comment.