diff --git a/docs/docs/configs.md b/docs/docs/configs.md index 3a37775..194ff2e 100644 --- a/docs/docs/configs.md +++ b/docs/docs/configs.md @@ -67,6 +67,14 @@ It is used when you set `cache=True` in `@API` decorator _Example:_ `DEFAULT_CACHE_EXP = timedelta(seconds=10)` +--- +### [TEMPLATES_DIR](https://pantherpy.github.io/templates_dir) +> Type: `str | list[str]` (Default: `'tempaltes'`) + +We use it when want to have different template directories + +_Example:_ `TEMPLATES_DIR = ['templates', 'app/templates'] + --- ### [THROTTLING](https://pantherpy.github.io/throttling) > Type: `Throttling | None` (Default: `None`) diff --git a/docs/docs/release_notes.md b/docs/docs/release_notes.md index 10cf2a8..b40d6be 100644 --- a/docs/docs/release_notes.md +++ b/docs/docs/release_notes.md @@ -1,3 +1,6 @@ +### 4.3.0 +- Support `Jinja2 Template Engine` + ### 4.2.0 - Support `OPTIONS` method diff --git a/example/app/apis.py b/example/app/apis.py index 45dac10..a4e7d54 100644 --- a/example/app/apis.py +++ b/example/app/apis.py @@ -22,7 +22,7 @@ from panther.generics import ListAPI from panther.pagination import Pagination from panther.request import Request -from panther.response import HTMLResponse, Response, StreamingResponse +from panther.response import HTMLResponse, Response, StreamingResponse, TemplateResponse from panther.throttling import Throttling from panther.websocket import close_websocket_connection, send_message_to_websocket @@ -167,6 +167,11 @@ def get(self, *args, **kwargs): return HTMLResponse(data=html_data) +class TemplateAPI(GenericAPI): + def get(self, *args, **kwargs) -> TemplateResponse: + return TemplateResponse(path='index.html', context={'username': 'Ali', 'age': 12}) + + @API() async def send_message_to_websocket_api(connection_id: str): await send_message_to_websocket(connection_id=connection_id, data='Hello From API') @@ -222,6 +227,7 @@ def logout_api(request: Request): def reader(): from faker import Faker import time + f = Faker() for _ in range(5): name = f.name() diff --git a/example/app/urls.py b/example/app/urls.py index 7adb05c..3a7018f 100644 --- a/example/app/urls.py +++ b/example/app/urls.py @@ -32,6 +32,7 @@ async def test(*args, **kwargs): 'patch-user-class/': PatchUser, 'file-class/': FileAPI, 'html-response/': HTMLAPI, + 'template-response/': TemplateAPI, '': single_user, 'ws//': UserWebsocket, 'send//': send_message_to_websocket_api, diff --git a/example/core/configs.py b/example/core/configs.py index cff954d..7e205f1 100644 --- a/example/core/configs.py +++ b/example/core/configs.py @@ -65,6 +65,8 @@ # THROTTLING = Throttling(rate=10, duration=timedelta(seconds=10)) +# TEMPLATES_DIR = 'templates' + async def startup(): print('Starting Up') diff --git a/example/templates/index.html b/example/templates/index.html new file mode 100644 index 0000000..b34ffb2 --- /dev/null +++ b/example/templates/index.html @@ -0,0 +1,9 @@ + + + + +

{{ 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('') +@API() +async def return_template_response() -> TemplateResponse: + return TemplateResponse(source='

{{ 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