fasthx 2.3.2__tar.gz → 3.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fasthx-2.3.2 → fasthx-3.0.0}/LICENSE +1 -1
- {fasthx-2.3.2 → fasthx-3.0.0}/PKG-INFO +19 -10
- {fasthx-2.3.2 → fasthx-3.0.0}/README.md +14 -7
- {fasthx-2.3.2 → fasthx-3.0.0}/fasthx/__init__.py +2 -6
- {fasthx-2.3.2 → fasthx-3.0.0}/fasthx/core_decorators.py +21 -28
- {fasthx-2.3.2 → fasthx-3.0.0}/fasthx/htmy.py +37 -17
- {fasthx-2.3.2 → fasthx-3.0.0}/fasthx/jinja.py +37 -109
- {fasthx-2.3.2 → fasthx-3.0.0}/fasthx/typing.py +13 -36
- {fasthx-2.3.2 → fasthx-3.0.0}/fasthx/utils.py +1 -1
- {fasthx-2.3.2 → fasthx-3.0.0}/pyproject.toml +28 -16
- {fasthx-2.3.2 → fasthx-3.0.0}/fasthx/component_selectors.py +0 -0
- {fasthx-2.3.2 → fasthx-3.0.0}/fasthx/dependencies.py +0 -0
- {fasthx-2.3.2 → fasthx-3.0.0}/fasthx/py.typed +0 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: fasthx
|
|
3
|
-
Version:
|
|
3
|
+
Version: 3.0.0
|
|
4
4
|
Summary: FastAPI server-side rendering with built-in HTMX support.
|
|
5
5
|
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
6
7
|
Author: Peter Volf
|
|
7
8
|
Author-email: do.volfp@gmail.com
|
|
8
9
|
Requires-Python: >=3.10,<4.0
|
|
@@ -12,10 +13,11 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
12
13
|
Classifier: Programming Language :: Python :: 3.11
|
|
13
14
|
Classifier: Programming Language :: Python :: 3.12
|
|
14
15
|
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
17
|
Provides-Extra: htmy
|
|
16
18
|
Provides-Extra: jinja
|
|
17
19
|
Requires-Dist: fastapi (>=0.100.0)
|
|
18
|
-
Requires-Dist: htmy (>=0.
|
|
20
|
+
Requires-Dist: htmy[lxml] (>=0.8.1) ; extra == "htmy"
|
|
19
21
|
Requires-Dist: jinja2 (>=3.0.0,<4.0.0) ; extra == "jinja"
|
|
20
22
|
Description-Content-Type: text/markdown
|
|
21
23
|
|
|
@@ -43,6 +45,14 @@ FastAPI server-side rendering with built-in HTMX support.
|
|
|
43
45
|
- **Correct typing** makes it possible to apply other (typed) decorators to your routes.
|
|
44
46
|
- Works with both **sync** and **async routes**.
|
|
45
47
|
|
|
48
|
+
## Testimonials
|
|
49
|
+
|
|
50
|
+
"Thank you for your work on `fasthx`, as well as `htmy`! I've never had an easier time developing with another stack." ([ref](https://github.com/volfpeter/fasthx/discussions/77))
|
|
51
|
+
|
|
52
|
+
"One of the main parts of the `FastAPI` -> `fasthx` -> `htmy` integration I'm falling in love with is its explicitness, and not too much magic happening." ([ref](https://github.com/volfpeter/fasthx/issues/54))
|
|
53
|
+
|
|
54
|
+
"Thank you for your work on `htmy` and `fasthx`, both have been very pleasant to use, and the APIs are both intuitive and simple. Great work." ([ref](https://github.com/volfpeter/fasthx/issues/54))
|
|
55
|
+
|
|
46
56
|
## Support
|
|
47
57
|
|
|
48
58
|
Consider supporting the development and maintenance of the project through [sponsoring](https://buymeacoffee.com/volfpeter), or reach out for [consulting](https://www.volfp.com/contact?subject=Consulting%20-%20FastHX) so you can get the most out of the library.
|
|
@@ -62,10 +72,9 @@ The package has optional dependencies for the following **official integrations*
|
|
|
62
72
|
|
|
63
73
|
## Core concepts
|
|
64
74
|
|
|
65
|
-
The core concept of FastHX is to let FastAPI routes do their usual job of handling the business logic and returning the result, while the FastHX decorators take care
|
|
66
|
-
of the entire rendering / presentation layer using a declarative, decorator-based approach.
|
|
75
|
+
The core concept of FastHX is to let FastAPI routes do their usual job of handling the business logic and returning the result, while the FastHX decorators take care of the entire rendering and presentation layer using a declarative, decorator-based approach.
|
|
67
76
|
|
|
68
|
-
|
|
77
|
+
Internally, FastHX decorators always have access to the decorated route's result, all of its arguments (sometimes called the request context), and the current request. Integrations convert these values into data that can be consumed by the used rendering engine (for example `htmy` or `jinja`), run the rendering engine with the selected component (more on this below) and the created data, and return the result to the client. For more details on how data conversion works and how it can be customized, please see the API documentation of the rendering engine integration of your choice.
|
|
69
78
|
|
|
70
79
|
The `ComponentSelector` abstraction makes it possible to declaratively specify and dynamically select the component that should be used to render the response to a given request. It is also possible to define an "error" `ComponentSelector` that is used if the decorated route raises an exception -- a typical use-case being error rendering for incorrect user input.
|
|
71
80
|
|
|
@@ -119,12 +128,12 @@ def index() -> None: ...
|
|
|
119
128
|
|
|
120
129
|
Requires: `pip install fasthx[jinja]`.
|
|
121
130
|
|
|
122
|
-
To start serving HTML and HTMX requests, all you need to do is create an instance of `fasthx.Jinja` and use its `hx()` or `page()` methods as decorators on your routes. `hx()` only triggers HTML rendering for HTMX requests, while `page()` unconditionally renders HTML. See the example code below:
|
|
131
|
+
To start serving HTML and HTMX requests, all you need to do is create an instance of `fasthx.jinja.Jinja` and use its `hx()` or `page()` methods as decorators on your routes. `hx()` only triggers HTML rendering for HTMX requests, while `page()` unconditionally renders HTML. See the example code below:
|
|
123
132
|
|
|
124
133
|
```python
|
|
125
134
|
from fastapi import FastAPI
|
|
126
135
|
from fastapi.templating import Jinja2Templates
|
|
127
|
-
from fasthx import Jinja
|
|
136
|
+
from fasthx.jinja import Jinja
|
|
128
137
|
from pydantic import BaseModel
|
|
129
138
|
|
|
130
139
|
# Pydantic model of the data the example API is using.
|
|
@@ -166,7 +175,7 @@ See the full working example [here](https://github.com/volfpeter/fasthx/tree/mai
|
|
|
166
175
|
|
|
167
176
|
Requires: `pip install fasthx`.
|
|
168
177
|
|
|
169
|
-
If you would like to use a rendering engine without FastHX integration, you can easily build on the `hx()` and `page()` decorators which give you all the functionality you will need. All you need to do is implement the `
|
|
178
|
+
If you would like to use a rendering engine without FastHX integration, you can easily build on the `hx()` and `page()` decorators which give you all the functionality you will need. All you need to do is implement the `RenderFunction` protocol.
|
|
170
179
|
|
|
171
180
|
Similarly to the Jinja case, `hx()` only triggers HTML rendering for HTMX requests, while `page()` unconditionally renders HTML. See the example code below:
|
|
172
181
|
|
|
@@ -179,7 +188,7 @@ from fasthx import hx, page
|
|
|
179
188
|
# Create the app.
|
|
180
189
|
app = FastAPI()
|
|
181
190
|
|
|
182
|
-
# Create a
|
|
191
|
+
# Create a dependency to see that its return value is available in the render function.
|
|
183
192
|
def get_random_number() -> int:
|
|
184
193
|
return 4 # Chosen by fair dice roll.
|
|
185
194
|
|
|
@@ -22,6 +22,14 @@ FastAPI server-side rendering with built-in HTMX support.
|
|
|
22
22
|
- **Correct typing** makes it possible to apply other (typed) decorators to your routes.
|
|
23
23
|
- Works with both **sync** and **async routes**.
|
|
24
24
|
|
|
25
|
+
## Testimonials
|
|
26
|
+
|
|
27
|
+
"Thank you for your work on `fasthx`, as well as `htmy`! I've never had an easier time developing with another stack." ([ref](https://github.com/volfpeter/fasthx/discussions/77))
|
|
28
|
+
|
|
29
|
+
"One of the main parts of the `FastAPI` -> `fasthx` -> `htmy` integration I'm falling in love with is its explicitness, and not too much magic happening." ([ref](https://github.com/volfpeter/fasthx/issues/54))
|
|
30
|
+
|
|
31
|
+
"Thank you for your work on `htmy` and `fasthx`, both have been very pleasant to use, and the APIs are both intuitive and simple. Great work." ([ref](https://github.com/volfpeter/fasthx/issues/54))
|
|
32
|
+
|
|
25
33
|
## Support
|
|
26
34
|
|
|
27
35
|
Consider supporting the development and maintenance of the project through [sponsoring](https://buymeacoffee.com/volfpeter), or reach out for [consulting](https://www.volfp.com/contact?subject=Consulting%20-%20FastHX) so you can get the most out of the library.
|
|
@@ -41,10 +49,9 @@ The package has optional dependencies for the following **official integrations*
|
|
|
41
49
|
|
|
42
50
|
## Core concepts
|
|
43
51
|
|
|
44
|
-
The core concept of FastHX is to let FastAPI routes do their usual job of handling the business logic and returning the result, while the FastHX decorators take care
|
|
45
|
-
of the entire rendering / presentation layer using a declarative, decorator-based approach.
|
|
52
|
+
The core concept of FastHX is to let FastAPI routes do their usual job of handling the business logic and returning the result, while the FastHX decorators take care of the entire rendering and presentation layer using a declarative, decorator-based approach.
|
|
46
53
|
|
|
47
|
-
|
|
54
|
+
Internally, FastHX decorators always have access to the decorated route's result, all of its arguments (sometimes called the request context), and the current request. Integrations convert these values into data that can be consumed by the used rendering engine (for example `htmy` or `jinja`), run the rendering engine with the selected component (more on this below) and the created data, and return the result to the client. For more details on how data conversion works and how it can be customized, please see the API documentation of the rendering engine integration of your choice.
|
|
48
55
|
|
|
49
56
|
The `ComponentSelector` abstraction makes it possible to declaratively specify and dynamically select the component that should be used to render the response to a given request. It is also possible to define an "error" `ComponentSelector` that is used if the decorated route raises an exception -- a typical use-case being error rendering for incorrect user input.
|
|
50
57
|
|
|
@@ -98,12 +105,12 @@ def index() -> None: ...
|
|
|
98
105
|
|
|
99
106
|
Requires: `pip install fasthx[jinja]`.
|
|
100
107
|
|
|
101
|
-
To start serving HTML and HTMX requests, all you need to do is create an instance of `fasthx.Jinja` and use its `hx()` or `page()` methods as decorators on your routes. `hx()` only triggers HTML rendering for HTMX requests, while `page()` unconditionally renders HTML. See the example code below:
|
|
108
|
+
To start serving HTML and HTMX requests, all you need to do is create an instance of `fasthx.jinja.Jinja` and use its `hx()` or `page()` methods as decorators on your routes. `hx()` only triggers HTML rendering for HTMX requests, while `page()` unconditionally renders HTML. See the example code below:
|
|
102
109
|
|
|
103
110
|
```python
|
|
104
111
|
from fastapi import FastAPI
|
|
105
112
|
from fastapi.templating import Jinja2Templates
|
|
106
|
-
from fasthx import Jinja
|
|
113
|
+
from fasthx.jinja import Jinja
|
|
107
114
|
from pydantic import BaseModel
|
|
108
115
|
|
|
109
116
|
# Pydantic model of the data the example API is using.
|
|
@@ -145,7 +152,7 @@ See the full working example [here](https://github.com/volfpeter/fasthx/tree/mai
|
|
|
145
152
|
|
|
146
153
|
Requires: `pip install fasthx`.
|
|
147
154
|
|
|
148
|
-
If you would like to use a rendering engine without FastHX integration, you can easily build on the `hx()` and `page()` decorators which give you all the functionality you will need. All you need to do is implement the `
|
|
155
|
+
If you would like to use a rendering engine without FastHX integration, you can easily build on the `hx()` and `page()` decorators which give you all the functionality you will need. All you need to do is implement the `RenderFunction` protocol.
|
|
149
156
|
|
|
150
157
|
Similarly to the Jinja case, `hx()` only triggers HTML rendering for HTMX requests, while `page()` unconditionally renders HTML. See the example code below:
|
|
151
158
|
|
|
@@ -158,7 +165,7 @@ from fasthx import hx, page
|
|
|
158
165
|
# Create the app.
|
|
159
166
|
app = FastAPI()
|
|
160
167
|
|
|
161
|
-
# Create a
|
|
168
|
+
# Create a dependency to see that its return value is available in the render function.
|
|
162
169
|
def get_random_number() -> int:
|
|
163
170
|
return 4 # Chosen by fair dice roll.
|
|
164
171
|
|
|
@@ -3,10 +3,6 @@ from .core_decorators import hx as hx
|
|
|
3
3
|
from .core_decorators import page as page
|
|
4
4
|
from .dependencies import DependsHXRequest as DependsHXRequest
|
|
5
5
|
from .dependencies import get_hx_request as get_hx_request
|
|
6
|
-
from .jinja import Jinja as Jinja
|
|
7
|
-
from .jinja import JinjaContext as JinjaContext
|
|
8
|
-
from .jinja import JinjaPath as JinjaPath
|
|
9
|
-
from .jinja import TemplateHeader as TemplateHeader
|
|
10
6
|
from .typing import ComponentSelector as ComponentSelector
|
|
11
|
-
from .typing import
|
|
12
|
-
from .typing import
|
|
7
|
+
from .typing import RenderFunction as RenderFunction
|
|
8
|
+
from .typing import RequestComponentSelector as RequestComponentSelector
|
|
@@ -7,15 +7,15 @@ from fastapi import HTTPException, Response, status
|
|
|
7
7
|
from fastapi.responses import HTMLResponse
|
|
8
8
|
|
|
9
9
|
from .dependencies import DependsHXRequest, DependsPageRequest
|
|
10
|
-
from .typing import
|
|
10
|
+
from .typing import MaybeAsyncFunc, P, RenderFunction, T
|
|
11
11
|
from .utils import append_to_signature, execute_maybe_sync_func, get_response
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
def hx(
|
|
15
|
-
render:
|
|
15
|
+
render: RenderFunction[T],
|
|
16
16
|
*,
|
|
17
17
|
no_data: bool = False,
|
|
18
|
-
render_error:
|
|
18
|
+
render_error: RenderFunction[Exception] | None = None,
|
|
19
19
|
) -> Callable[[MaybeAsyncFunc[P, T]], Callable[P, Coroutine[None, None, T | Response]]]:
|
|
20
20
|
"""
|
|
21
21
|
Decorator that converts a FastAPI route's return value into HTML if the request was
|
|
@@ -59,17 +59,13 @@ def hx(
|
|
|
59
59
|
response = get_response(kwargs)
|
|
60
60
|
rendered = await execute_maybe_sync_func(renderer, result, context=kwargs, request=__hx_request)
|
|
61
61
|
|
|
62
|
-
return (
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
background=getattr(response, "background", None),
|
|
70
|
-
)
|
|
71
|
-
if isinstance(rendered, str)
|
|
72
|
-
else rendered
|
|
62
|
+
return HTMLResponse(
|
|
63
|
+
rendered,
|
|
64
|
+
# The default status code of the FastAPI Response dependency is None
|
|
65
|
+
# (not allowed by the typing but required for FastAPI).
|
|
66
|
+
status_code=getattr(response, "status_code", 200) or 200,
|
|
67
|
+
headers=getattr(response, "headers", None),
|
|
68
|
+
background=getattr(response, "background", None),
|
|
73
69
|
)
|
|
74
70
|
|
|
75
71
|
return append_to_signature(
|
|
@@ -85,9 +81,9 @@ def hx(
|
|
|
85
81
|
|
|
86
82
|
|
|
87
83
|
def page(
|
|
88
|
-
render:
|
|
84
|
+
render: RenderFunction[T],
|
|
89
85
|
*,
|
|
90
|
-
render_error:
|
|
86
|
+
render_error: RenderFunction[Exception] | None = None,
|
|
91
87
|
) -> Callable[[MaybeAsyncFunc[P, T]], Callable[P, Coroutine[None, None, Response]]]:
|
|
92
88
|
"""
|
|
93
89
|
Decorator that converts a FastAPI route's return value into HTML.
|
|
@@ -102,7 +98,7 @@ def page(
|
|
|
102
98
|
@wraps(func)
|
|
103
99
|
async def wrapper(
|
|
104
100
|
__page_request: DependsPageRequest, *args: P.args, **kwargs: P.kwargs
|
|
105
|
-
) ->
|
|
101
|
+
) -> Response:
|
|
106
102
|
try:
|
|
107
103
|
result = await execute_maybe_sync_func(func, *args, **kwargs)
|
|
108
104
|
renderer = render
|
|
@@ -120,17 +116,14 @@ def page(
|
|
|
120
116
|
rendered = await execute_maybe_sync_func(
|
|
121
117
|
renderer, result, context=kwargs, request=__page_request
|
|
122
118
|
)
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
)
|
|
132
|
-
if isinstance(rendered, str)
|
|
133
|
-
else rendered
|
|
119
|
+
|
|
120
|
+
return HTMLResponse(
|
|
121
|
+
rendered,
|
|
122
|
+
# The default status code of the FastAPI Response dependency is None
|
|
123
|
+
# (not allowed by the typing but required for FastAPI).
|
|
124
|
+
status_code=getattr(response, "status_code", 200) or 200,
|
|
125
|
+
headers=getattr(response, "headers", None),
|
|
126
|
+
background=getattr(response, "background", None),
|
|
134
127
|
)
|
|
135
128
|
|
|
136
129
|
return append_to_signature(
|
|
@@ -9,7 +9,7 @@ from fastapi import Request, Response
|
|
|
9
9
|
|
|
10
10
|
from .component_selectors import ComponentHeader as _ComponentHeader
|
|
11
11
|
from .core_decorators import hx, page
|
|
12
|
-
from .typing import ComponentSelector,
|
|
12
|
+
from .typing import ComponentSelector, MaybeAsyncFunc, P, RenderFunction, RequestComponentSelector, T
|
|
13
13
|
|
|
14
14
|
if TYPE_CHECKING:
|
|
15
15
|
from typing_extensions import Self
|
|
@@ -23,7 +23,7 @@ HTMYComponentSelector: TypeAlias = ComponentSelector[HTMYComponentFactory[T]]
|
|
|
23
23
|
|
|
24
24
|
class ComponentHeader(_ComponentHeader[HTMYComponentFactory[T]]):
|
|
25
25
|
"""
|
|
26
|
-
`RequestComponentSelector` for HTMY components that
|
|
26
|
+
`RequestComponentSelector` for HTMY components that selects the rendered component
|
|
27
27
|
based on a request header.
|
|
28
28
|
"""
|
|
29
29
|
|
|
@@ -46,7 +46,7 @@ class CurrentRequest:
|
|
|
46
46
|
Loads the current `Request` instance from the given context.
|
|
47
47
|
|
|
48
48
|
Raises:
|
|
49
|
-
KeyError: If
|
|
49
|
+
KeyError: If there is no `Request` in the context.
|
|
50
50
|
TypeError: If invalid data is stored for `Request`.
|
|
51
51
|
"""
|
|
52
52
|
result = context[Request]
|
|
@@ -112,10 +112,10 @@ class HTMY:
|
|
|
112
112
|
- All route parameters (as a `RouteParams` instance) that can be retrieved with
|
|
113
113
|
`RouteParams.from_context()` in components.
|
|
114
114
|
- Everything added through `self.request_processors`.
|
|
115
|
-
- The default context of `self.
|
|
115
|
+
- The default context of `self.renderer`.
|
|
116
116
|
"""
|
|
117
117
|
|
|
118
|
-
|
|
118
|
+
renderer: h.RendererType = field(default_factory=h.Renderer)
|
|
119
119
|
"""The HTMY renderer to use."""
|
|
120
120
|
|
|
121
121
|
no_data: bool = field(default=False, kw_only=True)
|
|
@@ -129,12 +129,12 @@ class HTMY:
|
|
|
129
129
|
request_processors: list[RequestProcessor] = field(default_factory=list, kw_only=True)
|
|
130
130
|
"""
|
|
131
131
|
A list of functions that expect the current request and return an `htmy` `Context` that should
|
|
132
|
-
be used during rendering in addition to the default context of `self.
|
|
132
|
+
be used during rendering in addition to the default context of `self.renderer`.
|
|
133
133
|
"""
|
|
134
134
|
|
|
135
135
|
def hx(
|
|
136
136
|
self,
|
|
137
|
-
component_selector: HTMYComponentSelector[T],
|
|
137
|
+
component_selector: HTMYComponentSelector[T] | None = None,
|
|
138
138
|
*,
|
|
139
139
|
error_component_selector: HTMYComponentSelector[Exception] | None = None,
|
|
140
140
|
no_data: bool = False,
|
|
@@ -143,12 +143,15 @@ class HTMY:
|
|
|
143
143
|
Decorator for rendering the route's result if the request was an HTMX one.
|
|
144
144
|
|
|
145
145
|
Arguments:
|
|
146
|
-
component_selector:
|
|
146
|
+
component_selector: An optional component selector to use. If not provided, it is
|
|
147
|
+
assumed the route returns a component that should be rendered as is.
|
|
147
148
|
error_component_selector: The component selector to use for route error rendering.
|
|
148
149
|
no_data: If set, the route will only accept HTMX requests.
|
|
149
150
|
"""
|
|
150
151
|
return hx(
|
|
151
|
-
self._make_render_function(
|
|
152
|
+
self._make_render_function(
|
|
153
|
+
_default_component_selector if component_selector is None else component_selector
|
|
154
|
+
),
|
|
152
155
|
render_error=None
|
|
153
156
|
if error_component_selector is None
|
|
154
157
|
else self._make_error_render_function(error_component_selector),
|
|
@@ -157,7 +160,7 @@ class HTMY:
|
|
|
157
160
|
|
|
158
161
|
def page(
|
|
159
162
|
self,
|
|
160
|
-
component_selector: HTMYComponentSelector[T],
|
|
163
|
+
component_selector: HTMYComponentSelector[T] | None = None,
|
|
161
164
|
*,
|
|
162
165
|
error_component_selector: HTMYComponentSelector[Exception] | None = None,
|
|
163
166
|
) -> Callable[[MaybeAsyncFunc[P, T]], Callable[P, Coroutine[None, None, T | Response]]]:
|
|
@@ -167,11 +170,14 @@ class HTMY:
|
|
|
167
170
|
This decorator triggers HTML rendering regardless of whether the request was HTMX or not.
|
|
168
171
|
|
|
169
172
|
Arguments:
|
|
170
|
-
component_selector:
|
|
173
|
+
component_selector: An optional component selector to use. If not provided, it is
|
|
174
|
+
assumed the route returns a component that should be rendered as is.
|
|
171
175
|
error_component_selector: The component selector to use for route error rendering.
|
|
172
176
|
"""
|
|
173
177
|
return page(
|
|
174
|
-
self._make_render_function(
|
|
178
|
+
self._make_render_function(
|
|
179
|
+
_default_component_selector if component_selector is None else component_selector
|
|
180
|
+
),
|
|
175
181
|
render_error=None
|
|
176
182
|
if error_component_selector is None
|
|
177
183
|
else self._make_error_render_function(error_component_selector),
|
|
@@ -195,9 +201,9 @@ class HTMY:
|
|
|
195
201
|
Returns:
|
|
196
202
|
The rendered component.
|
|
197
203
|
"""
|
|
198
|
-
return await self.
|
|
204
|
+
return await self.renderer.render(component, self._make_render_context(request, {}))
|
|
199
205
|
|
|
200
|
-
def _make_render_function(self, component_selector: HTMYComponentSelector[T]) ->
|
|
206
|
+
def _make_render_function(self, component_selector: HTMYComponentSelector[T]) -> RenderFunction[T]:
|
|
201
207
|
"""
|
|
202
208
|
Creates a render function that uses the given component selector.
|
|
203
209
|
"""
|
|
@@ -208,13 +214,15 @@ class HTMY:
|
|
|
208
214
|
if isinstance(component_selector, RequestComponentSelector)
|
|
209
215
|
else component_selector
|
|
210
216
|
)
|
|
211
|
-
return await self.
|
|
217
|
+
return await self.renderer.render(
|
|
218
|
+
component(result), self._make_render_context(request, context)
|
|
219
|
+
)
|
|
212
220
|
|
|
213
221
|
return render
|
|
214
222
|
|
|
215
223
|
def _make_error_render_function(
|
|
216
224
|
self, component_selector: HTMYComponentSelector[Exception]
|
|
217
|
-
) ->
|
|
225
|
+
) -> RenderFunction[Exception]:
|
|
218
226
|
"""
|
|
219
227
|
Creates an error renderer function that uses the given component selector.
|
|
220
228
|
"""
|
|
@@ -225,7 +233,9 @@ class HTMY:
|
|
|
225
233
|
if isinstance(component_selector, RequestComponentSelector)
|
|
226
234
|
else component_selector
|
|
227
235
|
)
|
|
228
|
-
return await self.
|
|
236
|
+
return await self.renderer.render(
|
|
237
|
+
component(result), self._make_render_context(request, context)
|
|
238
|
+
)
|
|
229
239
|
|
|
230
240
|
return render
|
|
231
241
|
|
|
@@ -251,3 +261,13 @@ class HTMY:
|
|
|
251
261
|
result.update(cp(request))
|
|
252
262
|
|
|
253
263
|
return result
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _default_component_selector(route_result: Any) -> h.Component:
|
|
267
|
+
"""
|
|
268
|
+
Default component selector that returns the route result as is.
|
|
269
|
+
|
|
270
|
+
It is assumed (and not validated) that the route result is a `htmy.Component` when
|
|
271
|
+
this component selector is used. Otherwise rendering will fail.
|
|
272
|
+
"""
|
|
273
|
+
return route_result # type: ignore[no-any-return]
|
|
@@ -1,20 +1,35 @@
|
|
|
1
|
-
from collections.abc import Callable, Collection, Iterable
|
|
1
|
+
from collections.abc import Callable, Collection, Coroutine, Iterable
|
|
2
2
|
from dataclasses import dataclass, field
|
|
3
3
|
from functools import lru_cache
|
|
4
|
-
from typing import Any,
|
|
4
|
+
from typing import Any, Protocol
|
|
5
5
|
|
|
6
6
|
from fastapi import Request, Response
|
|
7
7
|
from fastapi.templating import Jinja2Templates
|
|
8
8
|
|
|
9
|
+
from .component_selectors import ComponentHeader as _ComponentHeader
|
|
9
10
|
from .core_decorators import hx, page
|
|
10
|
-
from .typing import
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
11
|
+
from .typing import ComponentSelector, MaybeAsyncFunc, P, RenderFunction, RequestComponentSelector
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class JinjaContextFactory(Protocol):
|
|
15
|
+
"""
|
|
16
|
+
Protocol definition for methods that convert a FastAPI route's result and route context
|
|
17
|
+
(i.e. the route's arguments) into a Jinja context (`dict`).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __call__(self, *, route_result: Any, route_context: dict[str, Any]) -> dict[str, Any]:
|
|
21
|
+
"""
|
|
22
|
+
Arguments:
|
|
23
|
+
route_result: The result of the route.
|
|
24
|
+
route_context: Every keyword argument the route received.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
The Jinja context dictionary.
|
|
28
|
+
|
|
29
|
+
Raises:
|
|
30
|
+
ValueError: If converting the arguments to a Jinja context fails.
|
|
31
|
+
"""
|
|
32
|
+
...
|
|
18
33
|
|
|
19
34
|
|
|
20
35
|
class JinjaPath(str):
|
|
@@ -176,7 +191,7 @@ class JinjaContext:
|
|
|
176
191
|
Arguments:
|
|
177
192
|
result_key: The key by which the `route_result` should be accessible in templates.
|
|
178
193
|
See `JinjaContextFactory` for `route_result` details.
|
|
179
|
-
context_key: The key by
|
|
194
|
+
context_key: The key by which the `route_context` should be accessible in templates.
|
|
180
195
|
If `None` (the default), then the `route_context` will not be accessible.
|
|
181
196
|
See `JinjaContextFactory` for `route_context` details.
|
|
182
197
|
|
|
@@ -200,71 +215,13 @@ class JinjaContext:
|
|
|
200
215
|
return wrap
|
|
201
216
|
|
|
202
217
|
|
|
203
|
-
|
|
204
|
-
class TemplateHeader:
|
|
218
|
+
class ComponentHeader(_ComponentHeader[str]):
|
|
205
219
|
"""
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
This class makes it possible for the client to submit the *key/ID* of the required template
|
|
209
|
-
to the server in a header. The Jinja decorators will then look up and render the requested
|
|
210
|
-
template if it exists. If the client doesn't request a specific template, then `default`
|
|
211
|
-
will be used if it was set, otherwise an exception will be raised.
|
|
212
|
-
|
|
213
|
-
By default this class treats template keys as case-insensitive. If you'd like to disable
|
|
214
|
-
this behavior, set `case_sensitive` to `True`.
|
|
215
|
-
|
|
216
|
-
This class can also handle route errors if the `error` property is set.
|
|
217
|
-
|
|
218
|
-
Implements:
|
|
219
|
-
- `RequestComponentSelector[str]`.
|
|
220
|
+
`RequestComponentSelector` for Jinja templates that selects the rendered template
|
|
221
|
+
based on a request header.
|
|
220
222
|
"""
|
|
221
223
|
|
|
222
|
-
|
|
223
|
-
"""The header which is used by the client to communicate the *key* of the requested template."""
|
|
224
|
-
|
|
225
|
-
templates: dict[str, str]
|
|
226
|
-
"""Dictionary that maps template keys to template (file) names."""
|
|
227
|
-
|
|
228
|
-
error: type[Exception] | tuple[type[Exception], ...] | None = field(default=None, kw_only=True)
|
|
229
|
-
"""The accepted error or errors."""
|
|
230
|
-
|
|
231
|
-
default: str | None = field(default=None, kw_only=True)
|
|
232
|
-
"""The template to use when the client didn't request a specific one."""
|
|
233
|
-
|
|
234
|
-
case_sensitive: bool = field(default=False, kw_only=True)
|
|
235
|
-
"""Whether the keys of `templates` are case-sensitive or not (default is `False`)."""
|
|
236
|
-
|
|
237
|
-
def __post_init__(self) -> None:
|
|
238
|
-
if not self.case_sensitive:
|
|
239
|
-
object.__setattr__(
|
|
240
|
-
self,
|
|
241
|
-
"templates",
|
|
242
|
-
{k.lower(): v for k, v in self.templates.items()},
|
|
243
|
-
)
|
|
244
|
-
|
|
245
|
-
def get_component(self, request: Request, error: Exception | None) -> str:
|
|
246
|
-
"""
|
|
247
|
-
Returns the name of the template that was requested by the client.
|
|
248
|
-
|
|
249
|
-
If the request doesn't contain a header (with the name `self.header`),
|
|
250
|
-
then `self.default` will be returned if it's not `None`.
|
|
251
|
-
|
|
252
|
-
Raises:
|
|
253
|
-
KeyError: If the client requested a specific template but it's unknown, or
|
|
254
|
-
if no template was requested and there's no default either.
|
|
255
|
-
"""
|
|
256
|
-
if error is not None and (self.error is None or not isinstance(error, self.error)):
|
|
257
|
-
raise error
|
|
258
|
-
|
|
259
|
-
if (key := request.headers.get(self.header, None)) is not None:
|
|
260
|
-
if not self.case_sensitive:
|
|
261
|
-
key = key.lower()
|
|
262
|
-
|
|
263
|
-
return self.templates[key]
|
|
264
|
-
elif self.default is None:
|
|
265
|
-
raise KeyError("Default template was not set and header was not found.")
|
|
266
|
-
else:
|
|
267
|
-
return self.default
|
|
224
|
+
...
|
|
268
225
|
|
|
269
226
|
|
|
270
227
|
@dataclass(frozen=True, slots=True)
|
|
@@ -363,9 +320,9 @@ class Jinja:
|
|
|
363
320
|
make_context: JinjaContextFactory,
|
|
364
321
|
prefix: str | None,
|
|
365
322
|
error_renderer: bool = False,
|
|
366
|
-
) ->
|
|
323
|
+
) -> RenderFunction[Any]:
|
|
367
324
|
"""
|
|
368
|
-
Creates
|
|
325
|
+
Creates a `RenderFunction` with the given configuration.
|
|
369
326
|
|
|
370
327
|
Arguments:
|
|
371
328
|
template: The template the renderer function should use.
|
|
@@ -374,51 +331,22 @@ class Jinja:
|
|
|
374
331
|
error_renderer: Whether this is an error renderer creation.
|
|
375
332
|
"""
|
|
376
333
|
|
|
377
|
-
def render(result: Any, *, context: dict[str, Any], request: Request) -> str
|
|
334
|
+
def render(result: Any, *, context: dict[str, Any], request: Request) -> str:
|
|
378
335
|
template_name = self._resolve_template_name(
|
|
379
336
|
template,
|
|
380
337
|
error=result if error_renderer else None,
|
|
381
338
|
prefix=prefix,
|
|
382
339
|
request=request,
|
|
383
340
|
)
|
|
384
|
-
|
|
385
|
-
template_name,
|
|
386
|
-
|
|
341
|
+
result = self.templates.TemplateResponse(
|
|
342
|
+
name=template_name,
|
|
343
|
+
context=make_context(route_result=result, route_context=context),
|
|
387
344
|
request=request,
|
|
388
345
|
)
|
|
346
|
+
return bytes(result.body).decode(result.charset)
|
|
389
347
|
|
|
390
348
|
return render
|
|
391
349
|
|
|
392
|
-
def _make_response(
|
|
393
|
-
self,
|
|
394
|
-
template: str,
|
|
395
|
-
*,
|
|
396
|
-
jinja_context: dict[str, Any],
|
|
397
|
-
request: Request,
|
|
398
|
-
) -> str | Response:
|
|
399
|
-
"""
|
|
400
|
-
Creates the HTML response using the given Jinja template name and context.
|
|
401
|
-
|
|
402
|
-
Arguments:
|
|
403
|
-
template: The Jinja2 template selector to use.
|
|
404
|
-
jinja_context: The Jinj2 rendering context.
|
|
405
|
-
prefix: Optional template name prefix.
|
|
406
|
-
request: The current request.
|
|
407
|
-
"""
|
|
408
|
-
# The reason for returning string from this method is to let `hx()` or `page()` create
|
|
409
|
-
# the HTML response - that way they can copy response headers and do other convenience
|
|
410
|
-
# conversions.
|
|
411
|
-
# The drawback is that users lose some of the baked-in debug utilities of TemplateResponse.
|
|
412
|
-
# This can be worked around by using a rendering context factory that includes the route's
|
|
413
|
-
# dependencies in the Jinja context. Then this method can be overridden to take the Response
|
|
414
|
-
# object from the context and copy the header from it into TemplateResponse.
|
|
415
|
-
result = self.templates.TemplateResponse(
|
|
416
|
-
name=template,
|
|
417
|
-
context=jinja_context,
|
|
418
|
-
request=request,
|
|
419
|
-
)
|
|
420
|
-
return bytes(result.body).decode(result.charset)
|
|
421
|
-
|
|
422
350
|
def _resolve_template_name(
|
|
423
351
|
self,
|
|
424
352
|
template: ComponentSelector[str],
|
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
from collections.abc import Callable, Coroutine
|
|
2
2
|
from typing import Any, ParamSpec, Protocol, TypeAlias, TypeVar, runtime_checkable
|
|
3
3
|
|
|
4
|
-
from fastapi import Request
|
|
4
|
+
from fastapi import Request
|
|
5
5
|
|
|
6
6
|
P = ParamSpec("P")
|
|
7
7
|
T = TypeVar("T")
|
|
8
8
|
Tco = TypeVar("Tco", covariant=True)
|
|
9
9
|
Tcontra = TypeVar("Tcontra", contravariant=True)
|
|
10
10
|
|
|
11
|
-
MaybeAsyncFunc = Callable[P, T] | Callable[P, Coroutine[Any, Any, T]]
|
|
11
|
+
MaybeAsyncFunc: TypeAlias = Callable[P, T] | Callable[P, Coroutine[Any, Any, T]]
|
|
12
12
|
|
|
13
13
|
|
|
14
|
-
class
|
|
15
|
-
"""Sync
|
|
14
|
+
class SyncRenderFunction(Protocol[Tcontra]):
|
|
15
|
+
"""Sync render function definition."""
|
|
16
16
|
|
|
17
|
-
def __call__(self, result: Tcontra, *, context: dict[str, Any], request: Request) -> str
|
|
17
|
+
def __call__(self, result: Tcontra, *, context: dict[str, Any], request: Request) -> str:
|
|
18
18
|
"""
|
|
19
19
|
Arguments:
|
|
20
20
|
result: The result of the route the renderer is used on.
|
|
@@ -22,17 +22,15 @@ class SyncHTMLRenderer(Protocol[Tcontra]):
|
|
|
22
22
|
request: The request being served.
|
|
23
23
|
|
|
24
24
|
Returns:
|
|
25
|
-
|
|
25
|
+
The rendered string.
|
|
26
26
|
"""
|
|
27
27
|
...
|
|
28
28
|
|
|
29
29
|
|
|
30
|
-
class
|
|
31
|
-
"""Async
|
|
30
|
+
class AsyncRenderFunction(Protocol[Tcontra]):
|
|
31
|
+
"""Async render function definition."""
|
|
32
32
|
|
|
33
|
-
async def __call__(
|
|
34
|
-
self, result: Tcontra, *, context: dict[str, Any], request: Request
|
|
35
|
-
) -> str | Response:
|
|
33
|
+
async def __call__(self, result: Tcontra, *, context: dict[str, Any], request: Request) -> str:
|
|
36
34
|
"""
|
|
37
35
|
Arguments:
|
|
38
36
|
result: The result of the route the renderer is used on.
|
|
@@ -40,34 +38,13 @@ class AsyncHTMLRenderer(Protocol[Tcontra]):
|
|
|
40
38
|
request: The request being served.
|
|
41
39
|
|
|
42
40
|
Returns:
|
|
43
|
-
|
|
41
|
+
The rendered string.
|
|
44
42
|
"""
|
|
45
43
|
...
|
|
46
44
|
|
|
47
45
|
|
|
48
|
-
|
|
49
|
-
"""Sync or async
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
class JinjaContextFactory(Protocol):
|
|
53
|
-
"""
|
|
54
|
-
Protocol definition for methods that convert a FastAPI route's result and route context
|
|
55
|
-
(i.e. the route's arguments) into a Jinja context (`dict`).
|
|
56
|
-
"""
|
|
57
|
-
|
|
58
|
-
def __call__(self, *, route_result: Any, route_context: dict[str, Any]) -> dict[str, Any]:
|
|
59
|
-
"""
|
|
60
|
-
Arguments:
|
|
61
|
-
route_result: The result of the route.
|
|
62
|
-
route_context: Every keyword argument the route received.
|
|
63
|
-
|
|
64
|
-
Returns:
|
|
65
|
-
The Jinja context dictionary.
|
|
66
|
-
|
|
67
|
-
Raises:
|
|
68
|
-
ValueError: If converting the arguments to a Jinja context fails.
|
|
69
|
-
"""
|
|
70
|
-
...
|
|
46
|
+
RenderFunction: TypeAlias = SyncRenderFunction[Tcontra] | AsyncRenderFunction[Tcontra]
|
|
47
|
+
"""Sync or async render function type."""
|
|
71
48
|
|
|
72
49
|
|
|
73
50
|
@runtime_checkable
|
|
@@ -106,4 +83,4 @@ class RequestComponentSelector(Protocol[Tco]):
|
|
|
106
83
|
|
|
107
84
|
|
|
108
85
|
ComponentSelector: TypeAlias = T | RequestComponentSelector[T]
|
|
109
|
-
"""Type alias for
|
|
86
|
+
"""Type alias for all types of component selectors."""
|
|
@@ -27,7 +27,7 @@ def append_to_signature(func: Callable[P, T], *params: inspect.Parameter) -> Cal
|
|
|
27
27
|
Returns:
|
|
28
28
|
The received function with an extended `__signature__`.
|
|
29
29
|
"""
|
|
30
|
-
signature = inspect.signature(func)
|
|
30
|
+
signature = inspect.signature(func, eval_str=True)
|
|
31
31
|
func.__signature__ = signature.replace(parameters=(*signature.parameters.values(), *params)) # type: ignore[attr-defined]
|
|
32
32
|
return func
|
|
33
33
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "fasthx"
|
|
3
|
-
version = "
|
|
3
|
+
version = "3.0.0"
|
|
4
4
|
description = "FastAPI server-side rendering with built-in HTMX support."
|
|
5
5
|
authors = ["Peter Volf <do.volfp@gmail.com>"]
|
|
6
6
|
readme = "README.md"
|
|
@@ -9,7 +9,7 @@ license = "MIT"
|
|
|
9
9
|
[tool.poetry.dependencies]
|
|
10
10
|
python = "^3.10"
|
|
11
11
|
fastapi = ">=0.100.0"
|
|
12
|
-
htmy = { version = ">=0.
|
|
12
|
+
htmy = { extras = ["lxml"], version = ">=0.8.1", optional = true }
|
|
13
13
|
jinja2 = { version = "^3.0.0", optional = true }
|
|
14
14
|
|
|
15
15
|
[tool.poetry.extras]
|
|
@@ -17,18 +17,18 @@ htmy = ["htmy"]
|
|
|
17
17
|
jinja = ["jinja2"]
|
|
18
18
|
|
|
19
19
|
[tool.poetry.group.dev.dependencies]
|
|
20
|
-
htmy = ">=0.
|
|
20
|
+
htmy = { extras = ["lxml"], version = ">=0.8.1"}
|
|
21
21
|
httpx = "^0.26.0"
|
|
22
22
|
jinja2 = "^3.0.0"
|
|
23
|
-
mkdocs-material = "^9.
|
|
24
|
-
mkdocstrings = {
|
|
25
|
-
mypy = "^1.
|
|
26
|
-
poethepoet = "^0.
|
|
27
|
-
pytest = "^8.
|
|
28
|
-
pytest-random-order = "^1.
|
|
29
|
-
ruff = "^0.
|
|
23
|
+
mkdocs-material = "^9.6.15"
|
|
24
|
+
mkdocstrings = {extras = ["python"], version = "^0.30.0"}
|
|
25
|
+
mypy = "^1.17.0"
|
|
26
|
+
poethepoet = "^0.36.0"
|
|
27
|
+
pytest = "^8.4.1"
|
|
28
|
+
pytest-random-order = "^1.2.0"
|
|
29
|
+
ruff = "^0.12.4"
|
|
30
30
|
typing-extensions = ">=4.5.0"
|
|
31
|
-
uvicorn = "^0.
|
|
31
|
+
uvicorn = "^0.35.0"
|
|
32
32
|
|
|
33
33
|
[build-system]
|
|
34
34
|
requires = ["poetry-core"]
|
|
@@ -38,6 +38,13 @@ build-backend = "poetry.core.masonry.api"
|
|
|
38
38
|
strict = true
|
|
39
39
|
show_error_codes = true
|
|
40
40
|
|
|
41
|
+
[tool.pyright]
|
|
42
|
+
venvPath = "."
|
|
43
|
+
venv = ".venv"
|
|
44
|
+
|
|
45
|
+
[tool.pytest.ini_options]
|
|
46
|
+
addopts = "--random-order"
|
|
47
|
+
|
|
41
48
|
[tool.ruff]
|
|
42
49
|
line-length = 108
|
|
43
50
|
exclude = [
|
|
@@ -63,12 +70,17 @@ lint.select = [
|
|
|
63
70
|
"tests/**/*" = ["S101"] # S101: use of assert detected
|
|
64
71
|
|
|
65
72
|
[tool.poe.tasks]
|
|
66
|
-
|
|
67
|
-
format = "ruff format ."
|
|
73
|
+
format = "ruff format --check ."
|
|
74
|
+
format-fix = "ruff format ."
|
|
75
|
+
|
|
68
76
|
lint = "ruff check ."
|
|
69
77
|
lint-fix = "ruff . --fix"
|
|
78
|
+
|
|
70
79
|
mypy = "mypy ."
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
80
|
+
|
|
81
|
+
check.sequence = ["format", "lint", "mypy"]
|
|
82
|
+
check.ignore_fail = "return_non_zero"
|
|
83
|
+
|
|
84
|
+
test = "python -m pytest tests"
|
|
85
|
+
|
|
74
86
|
serve-docs = "mkdocs serve"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|