Skip to content

Commit

Permalink
Merge pull request #29 from volfpeter/error-rendering
Browse files Browse the repository at this point in the history
Error rendering
  • Loading branch information
volfpeter authored Aug 27, 2024
2 parents 10bf17b + 1566f5f commit 33f374e
Show file tree
Hide file tree
Showing 10 changed files with 279 additions and 44 deletions.
4 changes: 4 additions & 0 deletions docs/api/jinja.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
options:
show_root_heading: true

## ::: fasthx.JinjaPath
options:
show_root_heading: true

## ::: fasthx.JinjaContextFactory
members:
- __call__
Expand Down
1 change: 1 addition & 0 deletions fasthx/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from .dependencies import get_hx_request as get_hx_request
from .jinja import Jinja as Jinja
from .jinja import JinjaContext as JinjaContext
from .jinja import JinjaPath as JinjaPath
from .jinja import TemplateHeader as TemplateHeader
from .typing import HTMLRenderer as HTMLRenderer
from .typing import JinjaContextFactory as JinjaContextFactory
61 changes: 51 additions & 10 deletions fasthx/core_decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@


def hx(
render: HTMLRenderer[T], *, no_data: bool = False
render: HTMLRenderer[T],
*,
no_data: bool = False,
render_error: HTMLRenderer[Exception] | None = None,
) -> Callable[[MaybeAsyncFunc[P, T]], Callable[P, Coroutine[None, None, T | Response]]]:
"""
Decorator that converts a FastAPI route's return value into HTML if the request was
Expand All @@ -21,6 +24,8 @@ def hx(
Arguments:
render: The render function converting the route's return value to HTML.
no_data: If set, the route will only accept HTMX requests.
render_error: Optional render function for handling exceptions raised by the decorated route.
If not `None`, it is expected to raise an error if the exception can not be rendered.
Returns:
The rendered HTML for HTMX requests, otherwise the route's unchanged return value.
Expand All @@ -36,14 +41,33 @@ async def wrapper(
status.HTTP_400_BAD_REQUEST, "This route can only process HTMX requests."
)

result = await execute_maybe_sync_func(func, *args, **kwargs)
try:
result = await execute_maybe_sync_func(func, *args, **kwargs)
renderer = render
except Exception as e:
# Reraise if not HX request, because the checks later don't differentiate between
# error and non-error result objects.
if render_error is None or __hx_request is None:
raise e

result = e # type: ignore[assignment]
renderer = render_error # type: ignore[assignment]

if __hx_request is None or isinstance(result, Response):
return result

response = get_response(kwargs)
rendered = await execute_maybe_sync_func(render, result, context=kwargs, request=__hx_request)
rendered = await execute_maybe_sync_func(renderer, result, context=kwargs, request=__hx_request)

return (
HTMLResponse(rendered, headers=None if response is None else response.headers)
HTMLResponse(
rendered,
# The default status code of the FastAPI Response dependency is None
# (not allowed by the typing but required for FastAPI).
status_code=getattr(response, "status_code", 200) or 200,
headers=getattr(response, "headers", None),
background=getattr(response, "background", None),
)
if isinstance(rendered, str)
else rendered
)
Expand All @@ -62,27 +86,44 @@ async def wrapper(

def page(
render: HTMLRenderer[T],
*,
render_error: HTMLRenderer[Exception] | None = None,
) -> Callable[[MaybeAsyncFunc[P, T]], Callable[P, Coroutine[None, None, Response]]]:
"""
Decorator that converts a FastAPI route's return value into HTML.
Arguments:
render: The render function converting the route's return value to HTML.
render_error: Optional render function for handling exceptions raised by the decorated route.
If not `None`, it is expected to raise an error if the exception can not be rendered.
"""

def decorator(func: MaybeAsyncFunc[P, T]) -> Callable[P, Coroutine[None, None, Response]]:
@wraps(func) # type: ignore[arg-type]
async def wrapper(*args: P.args, __page_request: Request, **kwargs: P.kwargs) -> T | Response:
result = await execute_maybe_sync_func(func, *args, **kwargs)
if isinstance(result, Response):
return result
try:
result = await execute_maybe_sync_func(func, *args, **kwargs)
renderer = render
except Exception as e:
if render_error is None:
raise e

result = e # type: ignore[assignment]
renderer = render_error # type: ignore[assignment]

response = get_response(kwargs)
rendered: str | Response = await execute_maybe_sync_func(
render, result, context=kwargs, request=__page_request
rendered = await execute_maybe_sync_func(
renderer, result, context=kwargs, request=__page_request
)
return (
HTMLResponse(rendered, headers=None if response is None else response.headers)
HTMLResponse(
rendered,
# The default status code of the FastAPI Response dependency is None
# (not allowed by the typing but required for FastAPI).
status_code=getattr(response, "status_code", 200) or 200,
headers=getattr(response, "headers", None),
background=getattr(response, "background", None),
)
if isinstance(rendered, str)
else rendered
)
Expand Down
92 changes: 72 additions & 20 deletions fasthx/jinja.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,28 @@
from fastapi.templating import Jinja2Templates

from .core_decorators import hx, page
from .typing import ComponentSelector, JinjaContextFactory, MaybeAsyncFunc, P, RequestComponentSelector
from .typing import (
ComponentSelector,
HTMLRenderer,
JinjaContextFactory,
MaybeAsyncFunc,
P,
RequestComponentSelector,
)


class JinjaPath(str):
"""
String subclass that can be used to mark a template path as "absolute".
In this context "absolute" means the template path should be exempt from any prefixing behavior
during template name resolution.
Note: calling any of the "mutation" methods (e.g. `.lower()`) of an instance will
result in a plain `str` object.
"""

...


class JinjaContext:
Expand Down Expand Up @@ -185,6 +206,8 @@ class TemplateHeader:
By default this class treats template keys as case-insensitive. If you'd like to disable
this behavior, set `case_sensitive` to `True`.
This class can also handle route errors if the `error` property is set.
Implements:
- `RequestComponentSelector`.
"""
Expand All @@ -195,6 +218,9 @@ class TemplateHeader:
templates: dict[str, str]
"""Dictionary that maps template keys to template (file) names."""

error: type[Exception] | tuple[type[Exception], ...] | None = field(default=None, kw_only=True)
"""The accepted error or errors."""

default: str | None = field(default=None, kw_only=True)
"""The template to use when the client didn't request a specific one."""

Expand All @@ -209,7 +235,7 @@ def __post_init__(self) -> None:
{k.lower(): v for k, v in self.templates.items()},
)

def get_component_id(self, request: Request) -> str:
def get_component_id(self, request: Request, error: Exception | None) -> str:
"""
Returns the name of the template that was requested by the client.
Expand All @@ -220,6 +246,9 @@ def get_component_id(self, request: Request) -> str:
KeyError: If the client requested a specific template but it's unknown, or
if no template was requested and there's no default either.
"""
if error is not None and (self.error is None or not isinstance(error, self.error)):
raise error

if (key := request.headers.get(self.header, None)) is not None:
if not self.case_sensitive:
key = key.lower()
Expand Down Expand Up @@ -248,6 +277,7 @@ def hx(
self,
template: ComponentSelector,
*,
error_template: ComponentSelector | None = None,
no_data: bool = False,
make_context: JinjaContextFactory | None = None,
prefix: str | None = None,
Expand All @@ -269,20 +299,19 @@ def hx(
# No route-specific override.
make_context = self.make_context

def render(result: Any, *, context: dict[str, Any], request: Request) -> str | Response:
return self._make_response(
template,
jinja_context=make_context(route_result=result, route_context=context),
prefix=prefix,
request=request,
)

return hx(render, no_data=no_data)
return hx(
self._make_render_function(template, make_context=make_context, prefix=prefix),
render_error=None
if error_template is None
else self._make_render_function(error_template, make_context=make_context, prefix=prefix),
no_data=no_data,
)

def page(
self,
template: ComponentSelector,
*,
error_template: ComponentSelector | None = None,
make_context: JinjaContextFactory | None = None,
prefix: str | None = None,
) -> Callable[[MaybeAsyncFunc[P, Any]], Callable[P, Coroutine[None, None, Any | Response]]]:
Expand All @@ -300,22 +329,44 @@ def page(
# No route-specific override.
make_context = self.make_context

return page(
self._make_render_function(template, make_context=make_context, prefix=prefix),
render_error=None
if error_template is None
else self._make_render_function(error_template, make_context=make_context, prefix=prefix),
)

def _make_render_function(
self,
template: ComponentSelector,
*,
make_context: JinjaContextFactory,
prefix: str | None,
) -> HTMLRenderer[Any]:
"""
Creates an `HTMLRenderer` with the given configuration.
Arguments:
template: The template the renderer function should use.
make_context: The Jinja rendering context factory to use.
prefix: Optional template name prefix.
"""

def render(result: Any, *, context: dict[str, Any], request: Request) -> str | Response:
template_name = self._resolve_template_name(template, prefix=prefix, request=request)
return self._make_response(
template,
template_name,
jinja_context=make_context(route_result=result, route_context=context),
prefix=prefix,
request=request,
)

return page(render)
return render

def _make_response(
self,
template: ComponentSelector,
template: str,
*,
jinja_context: dict[str, Any],
prefix: str | None = None,
request: Request,
) -> str | Response:
"""
Expand All @@ -327,7 +378,6 @@ def _make_response(
prefix: Optional template name prefix.
request: The current request.
"""
template_name = self._resolve_template_name(template, prefix=prefix, request=request)
# The reason for returning string from this method is to let `hx()` or `page()` create
# the HTML response - that way they can copy response headers and do other convenience
# conversions.
Expand All @@ -336,16 +386,17 @@ def _make_response(
# dependencies in the Jinja context. Then this method can be overridden to take the Response
# object from the context and copy the header from it into TemplateResponse.
result = self.templates.TemplateResponse(
name=template_name,
name=template,
context=jinja_context,
request=request,
)
return result.body.decode(result.charset)
return bytes(result.body).decode(result.charset)

def _resolve_template_name(
self,
template: ComponentSelector,
*,
error: Exception | None = None,
prefix: str | None,
request: Request,
) -> str:
Expand All @@ -365,13 +416,14 @@ def _resolve_template_name(
"""
if isinstance(template, RequestComponentSelector):
try:
result = template.get_component_id(request)
result = template.get_component_id(request, error)
except KeyError as e:
raise ValueError("Failed to resolve template name from request.") from e
elif isinstance(template, str):
result = template
else:
raise ValueError("Unknown template selector.")

prefix = None if isinstance(result, JinjaPath) else prefix
result = result.lstrip("/")
return f"{prefix}/{result}" if prefix else result
19 changes: 18 additions & 1 deletion fasthx/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,29 @@ class RequestComponentSelector(Protocol):
The protocol is runtime-checkable, so it can be used in `isinstance()`, `issubclass()` calls.
"""

def get_component_id(self, request: Request) -> str:
def get_component_id(self, request: Request, error: Exception | None) -> str:
"""
Returns the identifier of the component that was requested by the client.
The caller should ensure that `error` will be the exception that was raised by the
route or `None` if the route returned normally.
If an implementation can not or does not want to handle route errors, then the method
should re-raise the received exception. Example:
```python
class MyComponentSelector:
def get_component_id(self, request: Request, error: Exception | None) -> str:
if error is not None:
raise error
...
```
Raises:
KeyError: If the component couldn't be identified.
Exception: The received `error` argument if it was not `None` and the implementation
can not handle route errors.
"""
...

Expand Down
16 changes: 8 additions & 8 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "fasthx"
version = "1.1.1"
version = "2.0.0-rc1"
description = "FastAPI data APIs with HTMX support."
authors = ["Peter Volf <[email protected]>"]
readme = "README.md"
Expand All @@ -13,15 +13,15 @@ typing-extensions = ">=4.5.0"

[tool.poetry.group.dev.dependencies]
httpx = "^0.26.0"
jinja2 = "^3.1.3"
mkdocs-material = "^9.5.29"
mkdocstrings = {extras = ["python"], version = "^0.25.1"}
mypy = "^1.10.0"
jinja2 = "^3.1.4"
mkdocs-material = "^9.5.32"
mkdocstrings = {extras = ["python"], version = "^0.25.2"}
mypy = "^1.11.1"
poethepoet = "^0.27.0"
pytest = "^8.0.1"
pytest = "^8.3.2"
pytest-random-order = "^1.1.1"
ruff = "^0.5.0"
uvicorn = "^0.27.1"
ruff = "^0.6.2"
uvicorn = "^0.30.6"

[build-system]
requires = ["poetry-core"]
Expand Down
1 change: 1 addition & 0 deletions tests/templates/hello-world.jinja
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Hello World!
1 change: 0 additions & 1 deletion tests/templates/random_number.jinja

This file was deleted.

Loading

0 comments on commit 33f374e

Please sign in to comment.