{{ username }}
+{{ age }}
+ + + \ No newline at end of file diff --git a/panther/__init__.py b/panther/__init__.py index a00ec50..ae39158 100644 --- a/panther/__init__.py +++ b/panther/__init__.py @@ -1,6 +1,6 @@ from panther.main import Panther # noqa: F401 -__version__ = '4.2.6' +__version__ = '4.3.0' def version(): diff --git a/panther/_load_configs.py b/panther/_load_configs.py index 62b3e7d..03fdb65 100644 --- a/panther/_load_configs.py +++ b/panther/_load_configs.py @@ -29,6 +29,7 @@ 'load_user_model', 'load_log_queries', 'load_middlewares', + 'load_templates_dir', 'load_auto_reformat', 'load_background_tasks', 'load_default_cache_exp', @@ -82,6 +83,11 @@ def load_timezone(_configs: dict, /) -> None: config.TIMEZONE = timezone +def load_templates_dir(_configs: dict, /) -> None: + if templates_dir := _configs.get('TEMPLATES_DIR'): + config.TEMPLATES_DIR = templates_dir + + def load_database(_configs: dict, /) -> None: database_config = _configs.get('DATABASE', {}) if 'engine' in database_config: diff --git a/panther/configs.py b/panther/configs.py index 39d4d12..b5b7c0d 100644 --- a/panther/configs.py +++ b/panther/configs.py @@ -67,6 +67,7 @@ class Config: STARTUPS: list[Callable] SHUTDOWNS: list[Callable] TIMEZONE: str + TEMPLATES_DIR: str | list[str] AUTO_REFORMAT: bool QUERY_ENGINE: typing.Callable | None DATABASE: typing.Callable | None @@ -110,6 +111,7 @@ def refresh(self): 'STARTUPS': [], 'SHUTDOWNS': [], 'TIMEZONE': 'UTC', + 'TEMPLATES_DIR': 'templates', 'AUTO_REFORMAT': False, 'QUERY_ENGINE': None, 'DATABASE': None, diff --git a/panther/main.py b/panther/main.py index 109d4a5..b3c987d 100644 --- a/panther/main.py +++ b/panther/main.py @@ -58,6 +58,7 @@ def load_configs(self) -> None: load_throttling(self._configs_module) load_user_model(self._configs_module) load_log_queries(self._configs_module) + load_templates_dir(self._configs_module) load_middlewares(self._configs_module) load_auto_reformat(self._configs_module) load_background_tasks(self._configs_module) diff --git a/panther/response.py b/panther/response.py index 2cf49fa..432fc7f 100644 --- a/panther/response.py +++ b/panther/response.py @@ -1,11 +1,13 @@ import asyncio from types import NoneType -from typing import Generator, AsyncGenerator, Any, Type +from typing import Generator, AsyncGenerator, Any, LiteralString, Type import orjson as json from pydantic import BaseModel +from jinja2 import Environment, FileSystemLoader from panther import status +from panther.configs import config from panther._utils import to_async_generator from panther.db.cursor import Cursor from pantherdb import Cursor as PantherDBCursor @@ -215,3 +217,29 @@ def body(self) -> bytes: if isinstance(self.data, bytes): return self.data return self.data.encode() + + +class TemplateResponse(HTMLResponse): + environment = Environment(loader=FileSystemLoader(config.TEMPLATES_DIR)) + + def __init__( + self, + source: str | LiteralString | NoneType = None, + path: str | NoneType = None, + context: dict | NoneType = None, + headers: dict | NoneType = None, + status_code: int = status.HTTP_200_OK, + pagination: Pagination | NoneType = None, + ): + """ + :param source: should be a string + :param path: should be path of template file + :param context: should be dict of items + :param headers: should be dict of headers + :param status_code: should be int + :param pagination: instance of Pagination or None + Its template() method will be used + """ + + template = self.environment.get_template(path) if path is not None else self.environment.from_string(source) + super().__init__(template.render(context), headers, status_code, pagination=pagination) diff --git a/setup.py b/setup.py index 10fefba..0d88801 100644 --- a/setup.py +++ b/setup.py @@ -56,6 +56,7 @@ def panther_version() -> str: 'rich~=13.7.1', 'uvicorn~=0.27.1', 'pytz~=2024.1', + 'Jinja2~=3.1', ], extras_require=EXTRAS_REQUIRE, ) diff --git a/tests/test_response.py b/tests/test_response.py index f00cc54..50236e3 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -2,7 +2,7 @@ from panther import Panther from panther.app import API, GenericAPI -from panther.response import Response, HTMLResponse, PlainTextResponse, StreamingResponse +from panther.response import Response, HTMLResponse, PlainTextResponse, StreamingResponse, TemplateResponse from panther.test import APIClient @@ -116,6 +116,18 @@ def get(self): return HTMLResponse('{{ content }}
', context={'content': 'Hello World'}) + + +class ReturnTemplateResponse(GenericAPI): + def get(self) -> TemplateResponse: + return TemplateResponse( + source='{{ content }}
', context={'content': 'Hello World'} + ) + + @API() async def return_plain_response(): return PlainTextResponse('Hello World') @@ -160,7 +172,7 @@ def get(self): 'response-tuple': return_response_tuple, 'html': return_html_response, 'plain': return_plain_response, - + 'template': return_template_response, 'nothing-cls': ReturnNothing, 'none-cls': ReturnNone, 'dict-cls': ReturnDict, @@ -172,8 +184,8 @@ def get(self): 'response-list-cls': ReturnResponseList, 'response-tuple-cls': ReturnResponseTuple, 'html-cls': ReturnHTMLResponse, + 'template-cls': ReturnTemplateResponse, 'plain-cls': ReturnPlainResponse, - 'stream': ReturnStreamingResponse, 'async-stream': ReturnAsyncStreamingResponse, 'invalid-status-code': ReturnInvalidStatusCode, @@ -406,6 +418,26 @@ async def test_response_html_cls(self): assert res.headers['Access-Control-Allow-Origin'] == '*' assert res.headers['Content-Length'] == '41' + async def test_response_template(self) -> None: + res: Response = await self.client.get('template/') + assert res.status_code == 200 + assert res.data == 'Hello World
' + assert res.body == b'Hello World
' + assert set(res.headers.keys()) == {'Content-Type', 'Access-Control-Allow-Origin', 'Content-Length'} + assert res.headers['Content-Type'] == 'text/html; charset=utf-8' + assert res.headers['Access-Control-Allow-Origin'] == '*' + assert res.headers['Content-Length'] == '44' + + async def test_response_template_cls(self) -> None: + res: Response = await self.client.get('template-cls/') + assert res.status_code == 200 + assert res.data == 'Hello World
' + assert res.body == b'Hello World
' + assert set(res.headers.keys()) == {'Content-Type', 'Access-Control-Allow-Origin', 'Content-Length'} + assert res.headers['Content-Type'] == 'text/html; charset=utf-8' + assert res.headers['Access-Control-Allow-Origin'] == '*' + assert res.headers['Content-Length'] == '44' + async def test_response_plain(self): res = await self.client.get('plain/') assert res.status_code == 200