Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(template): add template_str for rendering from strings #2689

Merged
merged 29 commits into from
Nov 17, 2023
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
7fe586a
feat(template): add ``template_str`` for rendering from strings
JacobCoffee Nov 17, 2023
ad97176
fix: match media type options for template files
JacobCoffee Nov 17, 2023
9ebf9ab
fix: warn of precedence
JacobCoffee Nov 17, 2023
56cfbed
feat: add mako
JacobCoffee Nov 17, 2023
f93715d
fix: media type setting wrong
JacobCoffee Nov 17, 2023
77ea25c
feat: minijinja
JacobCoffee Nov 17, 2023
93cb42c
chore: remove testing method
JacobCoffee Nov 17, 2023
681e87d
chore: apply @alc-alc suggestion
JacobCoffee Nov 17, 2023
aea9916
docs: add usage documentation
JacobCoffee Nov 17, 2023
21c5a72
tests: add tests
JacobCoffee Nov 17, 2023
511ae87
ci: mypy
JacobCoffee Nov 17, 2023
d9a7115
fix(tests): caught bug that was running minijinja tests as jinja
JacobCoffee Nov 17, 2023
5f94080
docs: properly link to HTMX page
JacobCoffee Nov 17, 2023
cfca8b8
ci(coverage): fill in missing coverage
JacobCoffee Nov 17, 2023
5fe1718
tests: test catching ``None`` template types
JacobCoffee Nov 17, 2023
eca6c14
Merge branch 'main' into 2687-template-str
JacobCoffee Nov 17, 2023
6f5042e
tests: fix none type tests
JacobCoffee Nov 17, 2023
f6e2439
Update litestar/response/template.py
JacobCoffee Nov 17, 2023
baeaffd
fix: raise error if both template_name and template_str are defined
JacobCoffee Nov 17, 2023
8a3521d
docs: remove stale statement about precedence
JacobCoffee Nov 17, 2023
d111657
ci(mypy): make happy the type checking goddess
JacobCoffee Nov 17, 2023
4d30716
Merge branch 'main' into 2687-template-str
JacobCoffee Nov 17, 2023
8092dad
chore: apply suggestion from @guacs
JacobCoffee Nov 17, 2023
c218e4e
chore: apply suggestion from @peterschutt
JacobCoffee Nov 17, 2023
8226e6e
fix: remove unneeded check
JacobCoffee Nov 17, 2023
8ad618b
test: rework test to get new error raised
JacobCoffee Nov 17, 2023
e653101
ci(typing): make mypy happy-ish
JacobCoffee Nov 17, 2023
2dbc4f1
refactor: narrowing away optional template engine
peterschutt Nov 17, 2023
6ae576d
ci: apply pre-commit
JacobCoffee Nov 17, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions docs/examples/templating/returning_templates_jinja.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,15 @@
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 | None) -> Template:
JacobCoffee marked this conversation as resolved.
Show resolved Hide resolved
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})
elif not template_type:
# Return something that should raise an error
return Template(template_str=None)


app = Litestar(
Expand Down
14 changes: 11 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,15 @@
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 | None) -> 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})
elif not template_type:
# Return something that should raise an error
return Template(template_str=None)


app = Litestar(
Expand Down
14 changes: 11 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,15 @@
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 | None) -> 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})
elif not template_type:
# Return something that should raise an error
return Template(template_str=None)


app = Litestar(
Expand Down
74 changes: 69 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

.. 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[jinja]`` for Jinja2
* ``pip install litestar[mako]`` for Mako
* ``pip install litestar[minijinja]`` for Minijinja
pip install litestar[minijinja]

.. tip::

Expand Down Expand Up @@ -163,6 +181,52 @@ 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"})

.. tab-item:: File name and string

.. code-block:: python
:caption: When defining both, string will take precedence

@get()
async def example() -> Template:
template_string = "{{ hello }}"
return Template(
template_name="test.html",
template_str=template_string,
context={"hello": "world"},
)


.. warning:: If you pass both ``template_str`` and ``template_name``, the ``template_str`` will
take precedence.

Template context
----------------

Expand Down
16 changes: 15 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,20 @@ def register_template_callable(
"""
self.engine.globals[key] = pass_context(template_callable)

def render_string(self, template_string: str, context: Mapping[str, Any] | None = None) -> 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.
"""
context = context or {}
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
20 changes: 17 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,21 @@ def register_template_callable(
"""
self._template_callables.append((key, template_callable))

@staticmethod
def render_string(template_string: str, context: Mapping[str, Any] | None = None) -> 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.
"""
context = context or {}
template = _MakoTemplate(template_string)
return template.render(**context) # type: ignore[no-any-return]
JacobCoffee marked this conversation as resolved.
Show resolved Hide resolved

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

def render_string(self, template_string: str, context: Mapping[str, Any] | None = None) -> str:
JacobCoffee marked this conversation as resolved.
Show resolved Hide resolved
"""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.
"""
context = context or {}

if template_string is None:
raise TemplateNotFoundException("No template string provided", template_name="template_from_string")
JacobCoffee marked this conversation as resolved.
Show resolved Hide resolved

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
37 changes: 32 additions & 5 deletions litestar/response/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
JacobCoffee marked this conversation as resolved.
Show resolved Hide resolved
*,
template_str: str | None = None,
background: BackgroundTask | BackgroundTasks | None = None,
context: dict[str, Any] | None = None,
cookies: ResponseCookies | None = None,
Expand All @@ -45,8 +47,12 @@ def __init__(
) -> None:
"""Handle the rendering of a given template into a bytes string.

.. note:: Either ``template_name`` or ``template_str`` must be provided.
If both are provided, ``template_str`` will be used.
JacobCoffee marked this conversation as resolved.
Show resolved Hide resolved

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 @@ -70,6 +76,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 @@ -117,7 +124,7 @@ def to_asgi_response(
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:
if not media_type and self.template_name:
suffixes = PurePath(self.template_name).suffixes
for suffix in suffixes:
if _type := guess_type(f"name{suffix}")[0]:
Expand All @@ -126,9 +133,16 @@ def to_asgi_response(
else:
media_type = MediaType.TEXT

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 = self._render_from_string(self.template_str, request)
media_type = media_type or "text/html"
JacobCoffee marked this conversation as resolved.
Show resolved Hide resolved
else:
if not self.template_name and not self.template_str:
raise ValueError("Either template_name or template_str must be provided")

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

return ASGIResponse(
background=self.background or background,
Expand All @@ -142,3 +156,16 @@ def to_asgi_response(
media_type=media_type,
status_code=self.status_code or status_code,
)

def _render_from_string(self, template_str: str, request: Request) -> bytes:
"""Render the template from a string.

Args:
template_str: A string representing the template.
request: A :class:`Request <.connection.Request>` instance.

Returns:
Rendered content as bytes.
"""
context = self.create_template_context(request)
return request.app.template_engine.render_string(template_str, context).encode(self.encoding) # type: ignore[no-any-return]
JacobCoffee marked this conversation as resolved.
Show resolved Hide resolved
Loading
Loading