Skip to content

Commit

Permalink
added support for non-dict return values to Jinja, refs #4 #10
Browse files Browse the repository at this point in the history
  • Loading branch information
volfpeter committed Feb 16, 2024
1 parent 0ebd991 commit 6dc8819
Show file tree
Hide file tree
Showing 14 changed files with 267 additions and 63 deletions.
35 changes: 24 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,18 @@ $ pip install fasthx
To start serving HTMX requests, all you need to do is create an instance of `fasthx.Jinja` and use it as a decorator on your routes like this:

```python
from fastapi import FastAPI
import os

from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
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()
Expand All @@ -47,16 +56,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() -> set[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.
Expand Down
3 changes: 3 additions & 0 deletions docs/api-JinjaContext.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# `JinjaContext`

::: fasthx.main.JinjaContext
39 changes: 17 additions & 22 deletions examples/template-with-jinja/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("/")
Expand Down
4 changes: 2 additions & 2 deletions examples/template-with-jinja/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@
<h1 class="mb-3">fasthx example with Jinja2</h1>

<p class="my-3">Normal Request to `/user-list` endpoint</p>
<a href="/user-list">Get User List Data</a>
<a href="/user-list" target="_blank">Get User List Data</a>


<p class="my-3">HTMX Request to `/user-list` endpoint</p>
<button type="button" class="btn btn-primary" hx-get="/user-list/" hx-swap="outerHTML">User List Data</button>

<p class="my-3">Normal Request to `/admin-list` endpoint</p>
<a href="/admin-list">Get Admin List Data</a>
<a href="/admin-list" target="_blank">Get Admin List Data</a>


<p class="my-3">HTMX Request to `/admin-list` endpoint</p>
Expand Down
2 changes: 1 addition & 1 deletion examples/template-with-jinja/templates/user-list.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<ul class="list-group">
{% for user in users %}
{% for user in items %}
<li class="list-group-item"> {{user.first_name}} {{user.last_name}}</li>
{% endfor %}
</ul>
2 changes: 2 additions & 0 deletions fasthx/__init__.py
Original file line number Diff line number Diff line change
@@ -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
142 changes: 136 additions & 6 deletions fasthx/main.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -119,15 +119,121 @@ 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."""

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
Expand All @@ -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)
1 change: 1 addition & 0 deletions mkdocs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
19 changes: 13 additions & 6 deletions tests/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -22,5 +25,9 @@ def get_random_number() -> int:

DependsRandomNumber = Annotated[int, Depends(get_random_number)]

html_user_list = "<ul><li>Billy Shears (active=True)</li><li>Lucy (active=True)</li></ul>"
json_user_list = to_json(users).decode()
billy_html = "<span>Billy Shears (active=True)</span>"
billy_json = billy.model_dump_json()
lucy_html = "<span>Lucy (active=True)</span>"
lucy_json = lucy.model_dump_json()
user_list_html = "<ul><li>Billy Shears (active=True)</li><li>Lucy (active=True)</li></ul>"
user_list_json = to_json(users).decode()
1 change: 1 addition & 0 deletions tests/templates/profile.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<span>{{i.name}} (active={{i.active}})</span>
1 change: 1 addition & 0 deletions tests/templates/random_number.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<h1>{{random_number}}</h1>
2 changes: 1 addition & 1 deletion tests/templates/user-list.html
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<ul>{% for u in users %}<li>{{u.name}} (active={{u.active}})</li>{% endfor %}</ul>
<ul>{% for i in items %}<li>{{i.name}} (active={{i.active}})</li>{% endfor %}</ul>
Loading

0 comments on commit 6dc8819

Please sign in to comment.