diff --git a/README.md b/README.md index 180a43c..4d43666 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,12 @@ To start serving HTMX requests, all you need to do is create an instance of `fas from fastapi import FastAPI from fastapi.templating import Jinja2Templates from fasthx import Jinja +from pydantic import BaseModel + +# Pydantic model of the data the example API is using. +class User(BaseModel): + first_name: str + last_name: str # Create the app. app = FastAPI() @@ -47,16 +53,20 @@ app = FastAPI() # FastHX Jinja instance that will serve as your decorator. jinja = Jinja(Jinja2Templates("templates")) -@app.get("/htmx-or-data") -@jinja("user-list.html") # Render the response with the user-list.html template. -def htmx_or_data() -> dict[str, list[dict[str, str]]]: - return {"users": [{"name": "Joe"}]} - -@app.get("/htmx-only") -@jinja.template("user-list.html", no_data=True) # Render the response with the user-list.html template. -def htmx_only() -> dict[str, list[dict[str, str]]]: - # no_data is set to True, so this route can not serve JSON, it only responds to HTMX requests. - return {"users": [{"name": "Joe"}]} +@app.get("/user-list") +@jinja("user-list.html") +def htmx_or_data() -> list[User]: + return [ + User(first_name="John", last_name="Lennon"), + User(first_name="Paul", last_name="McCartney"), + User(first_name="George", last_name="Harrison"), + User(first_name="Ringo", last_name="Starr"), + ] + +@app.get("/admin-list") +@jinja.template("user-list.html", no_data=True) +def htmx_only() -> list[User]: + return [User(first_name="Billy", last_name="Shears")] ``` For full example, see the [examples/template-with-jinja](https://github.com/volfpeter/fasthx/tree/main/examples) folder. diff --git a/docs/api-JinjaContext.md b/docs/api-JinjaContext.md new file mode 100644 index 0000000..9d22114 --- /dev/null +++ b/docs/api-JinjaContext.md @@ -0,0 +1,3 @@ +# `JinjaContext` + +::: fasthx.main.JinjaContext diff --git a/docs/index.md b/docs/index.md index 86ad12c..edc0143 100644 --- a/docs/index.md +++ b/docs/index.md @@ -39,6 +39,12 @@ To start serving HTMX requests, all you need to do is create an instance of `fas from fastapi import FastAPI from fastapi.templating import Jinja2Templates from fasthx import Jinja +from pydantic import BaseModel + +# Pydantic model of the data the example API is using. +class User(BaseModel): + first_name: str + last_name: str # Create the app. app = FastAPI() @@ -47,16 +53,20 @@ app = FastAPI() # FastHX Jinja instance that will serve as your decorator. jinja = Jinja(Jinja2Templates("templates")) -@app.get("/htmx-or-data") -@jinja("user-list.html") # Render the response with the user-list.html template. -def htmx_or_data() -> dict[str, list[dict[str, str]]]: - return {"users": [{"name": "Joe"}]} - -@app.get("/htmx-only") -@jinja.template("user-list.html", no_data=True) # Render the response with the user-list.html template. -def htmx_only() -> dict[str, list[dict[str, str]]]: - # no_data is set to True, so this route can not serve JSON, it only responds to HTMX requests. - return {"users": [{"name": "Joe"}]} +@app.get("/user-list") +@jinja("user-list.html") +def htmx_or_data() -> list[User]: + return [ + User(first_name="John", last_name="Lennon"), + User(first_name="Paul", last_name="McCartney"), + User(first_name="George", last_name="Harrison"), + User(first_name="Ringo", last_name="Starr"), + ] + +@app.get("/admin-list") +@jinja.template("user-list.html", no_data=True) +def htmx_only() -> list[User]: + return [User(first_name="Billy", last_name="Shears")] ``` For full example, see the [examples/template-with-jinja](https://github.com/volfpeter/fasthx/tree/main/examples) folder. diff --git a/examples/template-with-jinja/main.py b/examples/template-with-jinja/main.py index 9763ba5..528b2f9 100644 --- a/examples/template-with-jinja/main.py +++ b/examples/template-with-jinja/main.py @@ -3,49 +3,44 @@ from fastapi import FastAPI, Request from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates +from pydantic import BaseModel from fasthx import Jinja + +# Pydantic model of the data the example API is using. +class User(BaseModel): + first_name: str + last_name: str + + basedir = os.path.abspath(os.path.dirname(__file__)) # Create the app instance. app = FastAPI() + # Create a FastAPI Jinja2Templates instance. This will be used in FastHX Jinja instance. templates = Jinja2Templates(directory=os.path.join(basedir, "templates")) + # FastHX Jinja instance is initialized with the Jinja2Templates instance. jinja = Jinja(templates) @app.get("/user-list") @jinja("user-list.html") # Render the response with the user-list.html template. -def htmx_or_data() -> dict[str, list[dict[str, str]]]: +def htmx_or_data() -> tuple[User, ...]: """This route can serve both JSON and HTML, depending on if the request is an HTMX request or not.""" - return { - "users": [ - { - "first_name": "Peter", - "last_name": "Volf", - }, - { - "first_name": "Hasan", - "last_name": "Tasan", - }, - ] - } + return ( + User(first_name="Peter", last_name="Volf"), + User(first_name="Hasan", last_name="Tasan"), + ) @app.get("/admin-list") @jinja.template("user-list.html", no_data=True) # Render the response with the user-list.html template. -def htmx_only() -> dict[str, list[dict[str, str]]]: +def htmx_only() -> list[User]: """This route can only serve HTML, because the no_data parameter is set to True.""" - return { - "users": [ - { - "first_name": "John", - "last_name": "Doe", - }, - ] - } + return [User(first_name="John", last_name="Doe")] @app.get("/") diff --git a/examples/template-with-jinja/templates/index.html b/examples/template-with-jinja/templates/index.html index 64d12b8..5b726da 100644 --- a/examples/template-with-jinja/templates/index.html +++ b/examples/template-with-jinja/templates/index.html @@ -8,14 +8,14 @@

fasthx example with Jinja2

Normal Request to `/user-list` endpoint

-Get User List Data +Get User List Data

HTMX Request to `/user-list` endpoint

Normal Request to `/admin-list` endpoint

-Get Admin List Data +Get Admin List Data

HTMX Request to `/admin-list` endpoint

diff --git a/examples/template-with-jinja/templates/user-list.html b/examples/template-with-jinja/templates/user-list.html index fe67479..19fc06c 100644 --- a/examples/template-with-jinja/templates/user-list.html +++ b/examples/template-with-jinja/templates/user-list.html @@ -1,5 +1,5 @@ diff --git a/fasthx/__init__.py b/fasthx/__init__.py index 94ac8a7..197bae9 100644 --- a/fasthx/__init__.py +++ b/fasthx/__init__.py @@ -1,5 +1,7 @@ from .main import DependsHXRequest as DependsHXRequest from .main import HTMXRenderer as HTMXRenderer from .main import Jinja as Jinja +from .main import JinjaContext as JinjaContext +from .main import JinjaContextFactory as JinjaContextFactory from .main import get_hx_request as get_hx_request from .main import hx as hx diff --git a/fasthx/main.py b/fasthx/main.py index da4891a..03672ab 100644 --- a/fasthx/main.py +++ b/fasthx/main.py @@ -1,5 +1,5 @@ import inspect -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Collection, Iterable from dataclasses import dataclass from functools import wraps from typing import Annotated, Any, ParamSpec, Protocol, TypeVar @@ -119,6 +119,102 @@ async def wrapper( return decorator +class JinjaContextFactory(Protocol): + """ + Protocol definition for methods that convert a FastAPI route's result and route context + (i.e. the route's arguments) into a Jinja context (`dict`). + + Arguments: + route_result: The result of the route. + route_context: Every keyword argument the route received. + + Returns: + The Jinja context dictionary. + + Raises: + ValueError: If converting the arguments to a Jinja context fails. + """ + + def __call__(self, *, route_result: Any, route_context: dict[str, Any]) -> dict[str, Any]: + ... + + +class JinjaContext: + """ + Core `JinjaContextFactory` implementations. + """ + + @classmethod + def unpack_result(cls, *, route_result: Any, route_context: dict[str, Any]) -> dict[str, Any]: + """ + Jinja context factory that tries to reasonably convert non-`dict` route results + to valid Jinja contexts (the `route_context` argument is ignored). + + Supports `dict` and `Collection` instances, plus anything with `__dict__` or `__slots__` + attributes, for example Pydantic models, dataclasses, or "standard" class instances. + + Conversion rules: + + - `dict`: returned as is. + - `Collection`: returned as `{"items": route_context}`, available in templates as `items`. + - Objects with `__dict__` or `__slots__`: known keys are taken from `__dict__` or `__slots__` + and the context will be created as `{key: getattr(route_result, key) for key in keys}`, + omitting property names starting with an underscore. + + Raises: + ValueError: If `route_result` can not be handled by any of the conversion rules. + """ + if isinstance(route_result, dict): + return route_result + + # Covers lists, tuples, sets, etc.. + if isinstance(route_result, Collection): + return {"items": route_result} + + object_keys: Iterable[str] | None = None + + # __dict__ should take priority if an object has both this and __slots__. + if hasattr(route_result, "__dict__"): + # Covers Pydantic models and standard classes. + object_keys = route_result.__dict__.keys() + elif hasattr(route_result, "__slots__"): + # Covers classes with with __slots__. + object_keys = route_result.__slots__ + + if object_keys is not None: + return {key: getattr(route_result, key) for key in object_keys if not key.startswith("_")} + + raise ValueError("Result conversion failed, unknown result type.") + + @classmethod + def unpack_result_with_route_context( + cls, + *, + route_result: Any, + route_context: dict[str, Any], + ) -> dict[str, Any]: + """ + Jinja context factory that tries to reasonably convert non-`dict` route results + to valid Jinja contexts, also including every key-value pair from `route_context`. + + Supports everything that `JinjaContext.unpack_result()` does and follows the same + conversion rules. + + Raises: + ValueError: If `JinjaContext.unpack_result()` raises an error or if there's + a key conflict between `route_result` and `route_context`. + """ + result = cls.unpack_result(route_result=route_result, route_context=route_context) + if len(set(result.keys()) & set(route_context.keys())) > 0: + raise ValueError("Overlapping keys in route result and route context.") + + # route_context is the keyword args of the route collected into a dict. Update and + # return this dict rather than result, as the result might be the same object that + # was returned by the route and someone may have a reference to it. + route_context.update(result) + return route_context + + @dataclass(frozen=True, slots=True) class Jinja: """Jinja2 (renderer) decorator factory.""" @@ -126,8 +222,18 @@ class Jinja: templates: Jinja2Templates """The Jinja2 templates of the application.""" + make_context: JinjaContextFactory = JinjaContext.unpack_result + """ + Function that will be used by default to convert a route's return value into + a Jinja rendering context. The default value is `JinjaContext.unpack_result`. + """ + def __call__( - self, template_name: str, *, no_data: bool = False + self, + template_name: str, + *, + no_data: bool = False, + make_context: JinjaContextFactory | None = None, ) -> Callable[[Callable[_P, Any | Awaitable[Any]]], Callable[_P, Awaitable[Any | Response]]]: """ Decorator for rendering a route's return value to HTML using the Jinja2 template @@ -136,15 +242,39 @@ def __call__( Arguments: template_name: The name of the Jinja2 template to use. no_data: If set, the route will only accept HTMX requests. + make_context: Route-specific override for the `make_context` property. """ + if make_context is None: + # No route-specific override. + make_context = self.make_context def render(result: Any, *, context: dict[str, Any], request: Request) -> HTMLResponse: - return self.templates.TemplateResponse(name=template_name, request=request, context=result) + return self._make_response( + template_name, + jinja_context=make_context(route_result=result, route_context=context), + request=request, + ) return hx(render, no_data=no_data) def template( - self, template_name: str, *, no_data: bool = False - ) -> Callable[[Callable[_P, _T | Awaitable[_T]]], Callable[_P, Awaitable[_T | Response]]]: + self, + template_name: str, + *, + no_data: bool = False, + make_context: JinjaContextFactory | None = None, + ) -> Callable[[Callable[_P, Any | Awaitable[Any]]], Callable[_P, Awaitable[Any | Response]]]: """Alias for `__call__()`.""" - return self(template_name, no_data=no_data) + return self(template_name, no_data=no_data, make_context=make_context) + + def _make_response( + self, + template_name: str, + *, + jinja_context: dict[str, Any], + request: Request, + ) -> HTMLResponse: + """ + Creates the HTML response using the given Jinja template name and context. + """ + return self.templates.TemplateResponse(name=template_name, context=jinja_context, request=request) diff --git a/mkdocs.yaml b/mkdocs.yaml index d823637..78753c0 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -45,6 +45,7 @@ nav: - API Reference: - api-hx.md - api-Jinja.md + - api-JinjaContext.md - api-HTMXRenderer.md - api-get_hx_request.md - api-DependsHXRequest.md diff --git a/tests/data.py b/tests/data.py index d814324..81cded2 100644 --- a/tests/data.py +++ b/tests/data.py @@ -9,11 +9,14 @@ class User(BaseModel): name: str active: bool + def __hash__(self) -> int: + return hash((User, self.name, self.active)) -users: list[User] = [ - User(name="Billy Shears", active=True), - User(name="Lucy", active=True), -] + +billy = User(name="Billy Shears", active=True) +lucy = User(name="Lucy", active=True) + +users: list[User] = [billy, lucy] def get_random_number() -> int: @@ -22,5 +25,9 @@ def get_random_number() -> int: DependsRandomNumber = Annotated[int, Depends(get_random_number)] -html_user_list = "" -json_user_list = to_json(users).decode() +billy_html = "Billy Shears (active=True)" +billy_json = billy.model_dump_json() +lucy_html = "Lucy (active=True)" +lucy_json = lucy.model_dump_json() +user_list_html = "" +user_list_json = to_json(users).decode() diff --git a/tests/templates/profile.html b/tests/templates/profile.html new file mode 100644 index 0000000..16a819f --- /dev/null +++ b/tests/templates/profile.html @@ -0,0 +1 @@ +{{i.name}} (active={{i.active}}) \ No newline at end of file diff --git a/tests/templates/random_number.html b/tests/templates/random_number.html new file mode 100644 index 0000000..1aecbec --- /dev/null +++ b/tests/templates/random_number.html @@ -0,0 +1 @@ +

{{random_number}}

\ No newline at end of file diff --git a/tests/templates/user-list.html b/tests/templates/user-list.html index caa755c..7100d6d 100644 --- a/tests/templates/user-list.html +++ b/tests/templates/user-list.html @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/tests/test_hx.py b/tests/test_hx.py index 1c32817..f141b48 100644 --- a/tests/test_hx.py +++ b/tests/test_hx.py @@ -6,7 +6,7 @@ from fasthx import hx -from .data import DependsRandomNumber, User, html_user_list, json_user_list, users +from .data import DependsRandomNumber, User, user_list_html, user_list_json, users def render_user_list(result: list[User], *, context: dict[str, Any], request: Request) -> str: @@ -45,10 +45,10 @@ def hx_client(hx_app: FastAPI) -> TestClient: @pytest.mark.parametrize( ("route", "headers", "status", "expected"), ( - ("/htmx-or-data", {"HX-Request": "true"}, 200, html_user_list), - ("/htmx-or-data", None, 200, json_user_list), - ("/htmx-or-data", {"HX-Request": "false"}, 200, json_user_list), - ("/htmx-only", {"HX-Request": "true"}, 200, html_user_list), + ("/htmx-or-data", {"HX-Request": "true"}, 200, user_list_html), + ("/htmx-or-data", None, 200, user_list_json), + ("/htmx-or-data", {"HX-Request": "false"}, 200, user_list_json), + ("/htmx-only", {"HX-Request": "true"}, 200, user_list_html), ("/htmx-only", None, 400, ""), ("/htmx-only", {"HX-Request": "false"}, 400, ""), ), diff --git a/tests/test_jinja.py b/tests/test_jinja.py index dcca8ee..eefb771 100644 --- a/tests/test_jinja.py +++ b/tests/test_jinja.py @@ -1,11 +1,13 @@ +from typing import Any + import pytest from fastapi import FastAPI from fastapi.templating import Jinja2Templates from fastapi.testclient import TestClient -from fasthx import Jinja +from fasthx import Jinja, JinjaContext -from .data import DependsRandomNumber, User, html_user_list, json_user_list, users +from .data import DependsRandomNumber, User, billy, lucy, user_list_html, user_list_json, users @pytest.fixture @@ -17,12 +19,31 @@ def jinja_app() -> FastAPI: @app.get("/htmx-or-data") @jinja("user-list.html") def htmx_or_data() -> dict[str, list[User]]: - return {"users": users} + return {"items": users} + + @app.get("/htmx-or-data-with-template-alias") + @jinja.template("user-list.html") + def htmx_or_data_with_template_alias() -> list[User]: + return users + + @app.get("/htmx-or-data/") + @jinja("profile.html") + def htmx_or_data_by_id(id: int) -> User: + return billy @app.get("/htmx-only") @jinja("user-list.html", no_data=True) - async def htmx_only(random_number: DependsRandomNumber) -> dict[str, list[User]]: - return {"users": users} + async def htmx_only(random_number: DependsRandomNumber) -> set[User]: + return {billy, lucy} + + @app.get("/htmx-only-with-template-alias") + @jinja.template( + "random_number.html", + no_data=True, + make_context=JinjaContext.unpack_result_with_route_context, + ) + async def htmx_only_with_template_alias(random_number: DependsRandomNumber) -> list[User]: + return users return app @@ -35,12 +56,18 @@ def jinja_client(jinja_app: FastAPI) -> TestClient: @pytest.mark.parametrize( ("route", "headers", "status", "expected"), ( - ("/htmx-or-data", {"HX-Request": "true"}, 200, html_user_list), - ("/htmx-or-data", None, 200, f'{{"users":{json_user_list}}}'), - ("/htmx-or-data", {"HX-Request": "false"}, 200, f'{{"users":{json_user_list}}}'), - ("/htmx-only", {"HX-Request": "true"}, 200, html_user_list), + ("/htmx-or-data", {"HX-Request": "true"}, 200, user_list_html), + ("/htmx-or-data", None, 200, f'{{"items":{user_list_json}}}'), + ("/htmx-or-data", {"HX-Request": "false"}, 200, f'{{"items":{user_list_json}}}'), + ("/htmx-or-data-with-template-alias", {"HX-Request": "true"}, 200, user_list_html), + ("/htmx-or-data-with-template-alias", None, 200, user_list_json), + ("/htmx-or-data-with-template-alias", {"HX-Request": "false"}, 200, user_list_json), + ("/htmx-only", {"HX-Request": "true"}, 200, user_list_html), ("/htmx-only", None, 400, ""), ("/htmx-only", {"HX-Request": "false"}, 400, ""), + ("/htmx-only-with-template-alias", {"HX-Request": "true"}, 200, "

4

"), + ("/htmx-only-with-template-alias", None, 400, ""), + ("/htmx-only-with-template-alias", {"HX-Request": "false"}, 400, ""), ), ) def test_jinja( @@ -57,3 +84,27 @@ def test_jinja( result = response.text assert result == expected + + +class TestJinjaContext: + @pytest.mark.parametrize( + ("route_result", "route_converted"), + ( + (billy, billy.model_dump()), + (lucy, lucy.model_dump()), + ((billy, lucy), {"items": (billy, lucy)}), + ([billy, lucy], {"items": [billy, lucy]}), + ({billy, lucy}, {"items": {billy, lucy}}), + ({"billy": billy, "lucy": lucy}, {"billy": billy, "lucy": lucy}), + ), + ) + def test_unpack_methods(self, route_result: Any, route_converted: dict[str, Any]) -> None: + route_context = {"extra": "added"} + + result = JinjaContext.unpack_result(route_result=route_result, route_context=route_context) + assert result == route_converted + + result = JinjaContext.unpack_result_with_route_context( + route_result=route_result, route_context=route_context + ) + assert result == {**route_context, **route_converted}