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.
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2024 Peter Volf
3
+ Copyright (c) 2024-2025 Peter Volf
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -1,8 +1,9 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: fasthx
3
- Version: 2.3.2
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.5.0) ; extra == "htmy"
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
- Interally, 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.
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 `HTMLRenderer` protocol.
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 dependecy to see that its return value is available in the render function.
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
- Interally, 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.
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 `HTMLRenderer` protocol.
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 dependecy to see that its return value is available in the render function.
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 HTMLRenderer as HTMLRenderer
12
- from .typing import JinjaContextFactory as JinjaContextFactory
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 HTMLRenderer, MaybeAsyncFunc, P, T
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: HTMLRenderer[T],
15
+ render: RenderFunction[T],
16
16
  *,
17
17
  no_data: bool = False,
18
- render_error: HTMLRenderer[Exception] | None = None,
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
- HTMLResponse(
64
- rendered,
65
- # The default status code of the FastAPI Response dependency is None
66
- # (not allowed by the typing but required for FastAPI).
67
- status_code=getattr(response, "status_code", 200) or 200,
68
- headers=getattr(response, "headers", None),
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: HTMLRenderer[T],
84
+ render: RenderFunction[T],
89
85
  *,
90
- render_error: HTMLRenderer[Exception] | None = None,
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
- ) -> T | Response:
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
- return (
124
- HTMLResponse(
125
- rendered,
126
- # The default status code of the FastAPI Response dependency is None
127
- # (not allowed by the typing but required for FastAPI).
128
- status_code=getattr(response, "status_code", 200) or 200,
129
- headers=getattr(response, "headers", None),
130
- background=getattr(response, "background", None),
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, HTMLRenderer, MaybeAsyncFunc, P, RequestComponentSelector, T
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 takes selects the rendered component
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 the there's no `Request` in the context.
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.htmy`.
115
+ - The default context of `self.renderer`.
116
116
  """
117
117
 
118
- htmy: h.Renderer = field(default_factory=h.Renderer)
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.htmy`.
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: The component selector to use.
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(component_selector),
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: The component selector to use.
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(component_selector),
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.htmy.render(component, self._make_render_context(request, {}))
204
+ return await self.renderer.render(component, self._make_render_context(request, {}))
199
205
 
200
- def _make_render_function(self, component_selector: HTMYComponentSelector[T]) -> HTMLRenderer[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.htmy.render(component(result), self._make_render_context(request, context))
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
- ) -> HTMLRenderer[Exception]:
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.htmy.render(component(result), self._make_render_context(request, context))
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, Coroutine
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
- ComponentSelector,
12
- HTMLRenderer,
13
- JinjaContextFactory,
14
- MaybeAsyncFunc,
15
- P,
16
- RequestComponentSelector,
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 whih the `route_context` should be accessible in templates.
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
- @dataclass(frozen=True, slots=True)
204
- class TemplateHeader:
218
+ class ComponentHeader(_ComponentHeader[str]):
205
219
  """
206
- Template selector that takes the Jinja template name from a request header.
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
- header: str
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
- ) -> HTMLRenderer[Any]:
323
+ ) -> RenderFunction[Any]:
367
324
  """
368
- Creates an `HTMLRenderer` with the given configuration.
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 | Response:
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
- return self._make_response(
385
- template_name,
386
- jinja_context=make_context(route_result=result, route_context=context),
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, Response
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 SyncHTMLRenderer(Protocol[Tcontra]):
15
- """Sync HTML renderer definition."""
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 | Response:
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
- HTML string (it will be automatically converted to `HTMLResponse`) or a `Response` object.
25
+ The rendered string.
26
26
  """
27
27
  ...
28
28
 
29
29
 
30
- class AsyncHTMLRenderer(Protocol[Tcontra]):
31
- """Async HTML renderer definition."""
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
- HTML string (it will be automatically converted to `HTMLResponse`) or a `Response` object.
41
+ The rendered string.
44
42
  """
45
43
  ...
46
44
 
47
45
 
48
- HTMLRenderer = SyncHTMLRenderer[Tcontra] | AsyncHTMLRenderer[Tcontra]
49
- """Sync or async HTML renderer type."""
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 known component selectors."""
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 = "2.3.2"
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.5.0", optional = true }
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.5.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.5.32"
24
- mkdocstrings = { extras = ["python"], version = "^0.25.2" }
25
- mypy = "^1.15.0"
26
- poethepoet = "^0.30.0"
27
- pytest = "^8.3.3"
28
- pytest-random-order = "^1.1.1"
29
- ruff = "^0.11.2"
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.32.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
- check-format = "ruff format --check ."
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
- test = "python -m pytest tests --random-order"
72
- static-checks.sequence = ["lint", "check-format", "mypy"]
73
- static-checks.ignore_fail = "return_non_zero"
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