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 @@
- {% for user in users %}
+ {% for user in items %}
{{user.first_name}} {{user.last_name}}
{% endfor %}
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 = "
"
+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 @@
-