From d5362354446b54fc43e23c6ee2b7f39f7f4b5e21 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 17 Nov 2023 17:32:07 -0600 Subject: [PATCH] feat(template): add ``template_str`` for rendering from strings (#2689) * 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 Co-authored-by: alc-alc <45509143+Alc-Alc@users.noreply.github.com> --- .../templating/returning_templates_jinja.py | 9 ++- .../templating/returning_templates_mako.py | 11 +++- .../returning_templates_minijinja.py | 11 +++- docs/usage/templating.rst | 56 +++++++++++++++-- litestar/app.py | 3 +- litestar/config/app.py | 3 +- litestar/contrib/jinja.py | 15 ++++- litestar/contrib/mako.py | 19 +++++- litestar/contrib/minijinja.py | 21 +++++++ litestar/response/template.py | 40 ++++++++---- litestar/template/base.py | 12 ++++ .../test_returning_templates.py | 32 +++++----- tests/unit/test_contrib/test_minijinja.py | 10 +++ tests/unit/test_template/test_template.py | 62 ++++++++++++++++--- 14 files changed, 247 insertions(+), 57 deletions(-) diff --git a/docs/examples/templating/returning_templates_jinja.py b/docs/examples/templating/returning_templates_jinja.py index 4c3bd10c8a..eaf4ebc5a7 100644 --- a/docs/examples/templating/returning_templates_jinja.py +++ b/docs/examples/templating/returning_templates_jinja.py @@ -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 Jinja using strings", context={"name": name}) app = Litestar( diff --git a/docs/examples/templating/returning_templates_mako.py b/docs/examples/templating/returning_templates_mako.py index d06d22db74..0c3d57146e 100644 --- a/docs/examples/templating/returning_templates_mako.py +++ b/docs/examples/templating/returning_templates_mako.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from pathlib import Path from litestar import Litestar, get @@ -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 Mako using strings", context={"name": name}) app = Litestar( diff --git a/docs/examples/templating/returning_templates_minijinja.py b/docs/examples/templating/returning_templates_minijinja.py index 446824b002..8e0539a6d7 100644 --- a/docs/examples/templating/returning_templates_minijinja.py +++ b/docs/examples/templating/returning_templates_minijinja.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from pathlib import Path from litestar import Litestar, get @@ -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 Minijinja using strings", context={"name": name}) app = Litestar( diff --git a/docs/usage/templating.rst b/docs/usage/templating.rst index 77e978dcae..ffee9b684d 100644 --- a/docs/usage/templating.rst +++ b/docs/usage/templating.rst @@ -2,8 +2,8 @@ Templating ========== Litestar has built-in support for `Jinja2 `_ -, `Mako `_ and `Minijinja `_ template engines, as well as abstractions to -make use of any template engine you wish. +, `Mako `_ and `Minijinja `_ +template engines, as well as abstractions to make use of any template engine you wish. Template engines ---------------- @@ -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:: @@ -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 ` 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 ---------------- diff --git a/litestar/app.py b/litestar/app.py index 356952a446..80c2a0f970 100644 --- a/litestar/app.py +++ b/litestar/app.py @@ -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, @@ -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, diff --git a/litestar/config/app.py b/litestar/config/app.py index 810f3f27be..2cf513cdd0 100644 --- a/litestar/config/app.py +++ b/litestar/config/app.py @@ -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, @@ -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.""" diff --git a/litestar/contrib/jinja.py b/litestar/contrib/jinja.py index ee8246df2d..3bf7eb82ff 100644 --- a/litestar/contrib/jinja.py +++ b/litestar/contrib/jinja.py @@ -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. @@ -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. diff --git a/litestar/contrib/mako.py b/litestar/contrib/mako.py index 56470ef4c0..eb6cd28bbe 100644 --- a/litestar/contrib/mako.py +++ b/litestar/contrib/mako.py @@ -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") @@ -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. @@ -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. diff --git a/litestar/contrib/minijinja.py b/litestar/contrib/minijinja.py index d78ce1f6d5..7b53b9c31b 100644 --- a/litestar/contrib/minijinja.py +++ b/litestar/contrib/minijinja.py @@ -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. diff --git a/litestar/response/template.py b/litestar/response/template.py index 7b6ece5cf7..5c8cbd3717 100644 --- a/litestar/response/template.py +++ b/litestar/response/template.py @@ -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 @@ -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, @@ -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 World"``. background: A :class:`BackgroundTask <.background_tasks.BackgroundTask>` instance or :class:`BackgroundTasks <.background_tasks.BackgroundTasks>` to execute after the response is finished. Defaults to ``None``. @@ -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"", @@ -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. @@ -110,7 +120,7 @@ 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 @@ -118,17 +128,25 @@ def to_asgi_response( 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, diff --git a/litestar/template/base.py b/litestar/template/base.py index 446d9bde60..a9711880f2 100644 --- a/litestar/template/base.py +++ b/litestar/template/base.py @@ -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: diff --git a/tests/examples/test_templating/test_returning_templates.py b/tests/examples/test_templating/test_returning_templates.py index 9d8ef226f3..a23bfb5345 100644 --- a/tests/examples/test_templating/test_returning_templates.py +++ b/tests/examples/test_templating/test_returning_templates.py @@ -1,23 +1,23 @@ +import pytest from docs.examples.templating.returning_templates_jinja import app as jinja_app -from docs.examples.templating.returning_templates_jinja import app as minijinja_app from docs.examples.templating.returning_templates_mako import app as mako_app +from docs.examples.templating.returning_templates_minijinja import app as minijinja_app from litestar.testing import TestClient +apps_with_expected_responses = [ + (jinja_app, "Jinja", "Hello Jinja", "Hello Jinja using strings"), + (mako_app, "Mako", "Hello Mako\n", "Hello Mako using strings"), + (minijinja_app, "Minijinja", "Hello Minijinja", "Hello Minijinja using strings"), +] -def test_returning_templates_jinja(): - with TestClient(jinja_app) as client: - response = client.get("/", params={"name": "Jinja"}) - assert response.text == "Hello Jinja" - -def test_returning_templates_mako(): - with TestClient(mako_app) as client: - response = client.get("/", params={"name": "Mako"}) - assert response.text.strip() == "Hello Mako" - - -def test_returning_templates_minijinja(): - with TestClient(minijinja_app) as client: - response = client.get("/", params={"name": "Minijinja"}) - assert response.text == "Hello Minijinja" +@pytest.mark.parametrize("app, app_name, file_response, string_response", apps_with_expected_responses) +@pytest.mark.parametrize("template_type", ["file", "string"]) +def test_returning_templates(app, app_name, file_response, string_response, template_type): + with TestClient(app) as client: + response = client.get(f"/{template_type}", params={"name": app_name}) + if template_type == "file": + assert response.text == file_response + elif template_type == "string": + assert response.text.strip() == string_response diff --git a/tests/unit/test_contrib/test_minijinja.py b/tests/unit/test_contrib/test_minijinja.py index c8e115cc9a..628fa0b85d 100644 --- a/tests/unit/test_contrib/test_minijinja.py +++ b/tests/unit/test_contrib/test_minijinja.py @@ -33,6 +33,16 @@ def test_mini_jinja_template_render_raises_template_not_found(tmp_path: Path) -> tmpl.render() +def test_mini_jinja_template_render_string_raises_template_not_found(tmp_path: Path) -> None: + template_engine = MiniJinjaTemplateEngine(engine_instance=Environment()) + + good_template = template_engine.render_string("template as a string", context={}) + assert good_template == "template as a string" + + with pytest.raises(TypeError): + template_engine.render_string(None, context={}) # type: ignore[arg-type] + + def test_from_environment() -> None: engine = Environment() template_engine = MiniJinjaTemplateEngine.from_environment(engine) diff --git a/tests/unit/test_template/test_template.py b/tests/unit/test_template/test_template.py index 61e3b07fcf..328c25e5de 100644 --- a/tests/unit/test_template/test_template.py +++ b/tests/unit/test_template/test_template.py @@ -1,6 +1,8 @@ +from __future__ import annotations + import sys from pathlib import Path -from typing import TYPE_CHECKING, Optional, Type, Union +from typing import TYPE_CHECKING import pytest @@ -10,12 +12,12 @@ from litestar.contrib.minijinja import MiniJinjaTemplateEngine from litestar.exceptions import ImproperlyConfiguredException from litestar.response.template import Template +from litestar.template import TemplateEngineProtocol from litestar.template.config import TemplateConfig from litestar.testing import create_test_client if TYPE_CHECKING: from litestar import Request - from litestar.template import TemplateEngineProtocol def test_handler_raise_for_no_template_engine() -> None: @@ -29,11 +31,12 @@ def invalid_path() -> Template: assert response.json() == {"detail": "Internal Server Error", "status_code": 500} -def test_engine_passed_to_callback(tmp_path: "Path") -> None: - received_engine: Optional[JinjaTemplateEngine] = None +def test_engine_passed_to_callback(tmp_path: Path) -> None: + received_engine: JinjaTemplateEngine | None = None - def callback(engine: JinjaTemplateEngine) -> None: + def callback(engine: TemplateEngineProtocol) -> None: nonlocal received_engine + assert isinstance(engine, JinjaTemplateEngine), "Engine must be a JinjaTemplateEngine" received_engine = engine app = Litestar( @@ -50,7 +53,7 @@ def callback(engine: JinjaTemplateEngine) -> None: @pytest.mark.parametrize("engine", (JinjaTemplateEngine, MakoTemplateEngine, MiniJinjaTemplateEngine)) -def test_engine_instance(engine: Type["TemplateEngineProtocol"], tmp_path: "Path") -> None: +def test_engine_instance(engine: type[TemplateEngineProtocol], tmp_path: Path) -> None: engine_instance = engine(directory=tmp_path, engine_instance=None) if isinstance(engine_instance, JinjaTemplateEngine): assert engine_instance.engine.autoescape is True @@ -63,19 +66,19 @@ def test_engine_instance(engine: Type["TemplateEngineProtocol"], tmp_path: "Path @pytest.mark.parametrize("engine", (JinjaTemplateEngine, MakoTemplateEngine, MiniJinjaTemplateEngine)) -def test_directory_validation(engine: Type["TemplateEngineProtocol"], tmp_path: "Path") -> None: +def test_directory_validation(engine: type[TemplateEngineProtocol], tmp_path: Path) -> None: with pytest.raises(ImproperlyConfiguredException): TemplateConfig(engine=engine) @pytest.mark.parametrize("engine", (JinjaTemplateEngine, MakoTemplateEngine, MiniJinjaTemplateEngine)) -def test_instance_and_directory_validation(engine: Type["TemplateEngineProtocol"], tmp_path: "Path") -> None: +def test_instance_and_directory_validation(engine: type[TemplateEngineProtocol], tmp_path: Path) -> None: with pytest.raises(ImproperlyConfiguredException): TemplateConfig(engine=engine, instance=engine(directory=tmp_path, engine_instance=None)) @pytest.mark.parametrize("media_type", [MediaType.HTML, MediaType.TEXT, "text/arbitrary"]) -def test_media_type(media_type: Union[MediaType, str], tmp_path: Path) -> None: +def test_media_type(media_type: MediaType | str, tmp_path: Path) -> None: (tmp_path / "hello.tpl").write_text("hello") @get("/", media_type=media_type) @@ -126,7 +129,7 @@ def index() -> Template: def test_before_request_handler_content_type(tmp_path: Path) -> None: template_loc = tmp_path / "about.html" - def before_request_handler(_: "Request") -> None: + def before_request_handler(_: Request) -> None: template_loc.write_text("before request") @get("/", before_request=before_request_handler) @@ -140,3 +143,42 @@ def index() -> Template: assert res.status_code == 200 assert res.headers["content-type"].startswith(MediaType.HTML.value) assert res.text == "before request" + + +test_cases = [ + {"name": "both", "template_name": "dummy.html", "template_str": "Dummy", "raises": ValueError}, + {"name": "none", "template_name": None, "template_str": None, "status_code": 500}, + {"name": "name_only", "template_name": "dummy.html", "template_str": None, "status_code": 200}, + {"name": "str_only", "template_name": None, "template_str": "Dummy", "status_code": 200}, +] + + +@pytest.mark.parametrize("engine", (JinjaTemplateEngine, MakoTemplateEngine, MiniJinjaTemplateEngine)) +@pytest.mark.parametrize("test_case", test_cases, ids=[case["name"] for case in test_cases]) # type: ignore[index] +def test_template_scenarios(tmp_path: Path, engine: TemplateEngineProtocol, test_case: dict) -> None: + if test_case["template_name"]: + template_loc = tmp_path / test_case["template_name"] + template_loc.write_text("Test content for template") + + @get("/") + def index() -> Template: + return Template(template_name=test_case["template_name"], template_str=test_case["template_str"]) + + with create_test_client([index], template_config=TemplateConfig(directory=tmp_path, engine=engine)) as client: + if "raises" in test_case and test_case["raises"] is ValueError: + response = client.get("/") + assert response.status_code == 500 + assert "ValueError" in response.text + + else: + response = client.get("/") + assert response.status_code == test_case["status_code"] + + if test_case["status_code"] == 200: + if test_case["template_str"]: + assert response.text == test_case["template_str"] + else: + assert response.text == "Test content for template" + + elif test_case["status_code"] == 500: + assert "Either template_name or template_str must be provided" in response.text