fastlifeweb 0.9.7__py3-none-any.whl → 0.11.0__py3-none-any.whl
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.
- fastlife/__init__.py +2 -2
- fastlife/config/__init__.py +13 -0
- fastlife/{configurator → config}/configurator.py +64 -41
- fastlife/{configurator → config}/registry.py +2 -10
- fastlife/{configurator → config}/settings.py +7 -3
- fastlife/middlewares/__init__.py +7 -0
- fastlife/middlewares/base.py +24 -0
- fastlife/middlewares/reverse_proxy/__init__.py +16 -0
- fastlife/middlewares/reverse_proxy/x_forwarded.py +1 -14
- fastlife/middlewares/session/__init__.py +16 -0
- fastlife/middlewares/session/middleware.py +6 -1
- fastlife/middlewares/session/serializer.py +21 -0
- fastlife/request/__init__.py +5 -0
- fastlife/request/{model_result.py → form.py} +21 -9
- fastlife/request/form_data.py +28 -3
- fastlife/request/request.py +18 -0
- fastlife/routing/__init__.py +7 -0
- fastlife/routing/route.py +45 -0
- fastlife/routing/router.py +12 -4
- fastlife/security/__init__.py +1 -0
- fastlife/security/csrf.py +29 -11
- fastlife/security/policy.py +6 -2
- fastlife/shared_utils/__init__.py +1 -0
- fastlife/shared_utils/infer.py +7 -0
- fastlife/shared_utils/resolver.py +10 -2
- fastlife/templates/A.jinja +33 -9
- fastlife/templates/Button.jinja +55 -32
- fastlife/templates/Checkbox.jinja +20 -6
- fastlife/templates/CsrfToken.jinja +4 -0
- fastlife/templates/Details.jinja +31 -3
- fastlife/templates/Form.jinja +45 -7
- fastlife/templates/H1.jinja +14 -1
- fastlife/templates/H2.jinja +14 -1
- fastlife/templates/H3.jinja +14 -1
- fastlife/templates/H4.jinja +14 -1
- fastlife/templates/H5.jinja +14 -1
- fastlife/templates/H6.jinja +14 -1
- fastlife/templates/Hidden.jinja +3 -3
- fastlife/templates/Input.jinja +21 -8
- fastlife/templates/Label.jinja +18 -2
- fastlife/templates/Option.jinja +14 -2
- fastlife/templates/P.jinja +14 -2
- fastlife/templates/Radio.jinja +34 -12
- fastlife/templates/Select.jinja +15 -4
- fastlife/templates/Summary.jinja +13 -2
- fastlife/templates/Table.jinja +12 -1
- fastlife/templates/Tbody.jinja +11 -1
- fastlife/templates/Td.jinja +12 -1
- fastlife/templates/Textarea.jinja +18 -0
- fastlife/templates/Tfoot.jinja +11 -1
- fastlife/templates/Th.jinja +12 -1
- fastlife/templates/Thead.jinja +11 -1
- fastlife/templates/Tr.jinja +11 -1
- fastlife/templates/pydantic_form/Boolean.jinja +3 -2
- fastlife/templates/pydantic_form/Checklist.jinja +3 -5
- fastlife/templates/pydantic_form/Dropdown.jinja +3 -2
- fastlife/templates/pydantic_form/Error.jinja +4 -3
- fastlife/templates/pydantic_form/Hidden.jinja +2 -1
- fastlife/templates/pydantic_form/Hint.jinja +2 -1
- fastlife/templates/pydantic_form/Model.jinja +16 -3
- fastlife/templates/pydantic_form/Sequence.jinja +15 -6
- fastlife/templates/pydantic_form/Text.jinja +2 -2
- fastlife/templates/pydantic_form/Textarea.jinja +32 -0
- fastlife/templates/pydantic_form/Union.jinja +7 -1
- fastlife/templates/pydantic_form/Widget.jinja +6 -3
- fastlife/templating/binding.py +18 -4
- fastlife/templating/renderer/__init__.py +3 -1
- fastlife/templating/renderer/abstract.py +21 -8
- fastlife/templating/renderer/constants.py +82 -0
- fastlife/templating/renderer/jinjax.py +269 -6
- fastlife/templating/renderer/widgets/base.py +43 -7
- fastlife/templating/renderer/widgets/boolean.py +21 -0
- fastlife/templating/renderer/widgets/checklist.py +23 -0
- fastlife/templating/renderer/widgets/dropdown.py +22 -2
- fastlife/templating/renderer/widgets/factory.py +100 -29
- fastlife/templating/renderer/widgets/hidden.py +16 -0
- fastlife/templating/renderer/widgets/model.py +7 -1
- fastlife/templating/renderer/widgets/sequence.py +8 -6
- fastlife/templating/renderer/widgets/text.py +80 -4
- fastlife/templating/renderer/widgets/union.py +25 -2
- fastlife/testing/testclient.py +3 -3
- fastlife/views/pydantic_form.py +2 -2
- {fastlifeweb-0.9.7.dist-info → fastlifeweb-0.11.0.dist-info}/METADATA +4 -9
- {fastlifeweb-0.9.7.dist-info → fastlifeweb-0.11.0.dist-info}/RECORD +86 -84
- fastlife/configurator/__init__.py +0 -4
- fastlife/configurator/base.py +0 -9
- fastlife/configurator/route_handler.py +0 -29
- fastlife/templates/__init__.py +0 -0
- {fastlifeweb-0.9.7.dist-info → fastlifeweb-0.11.0.dist-info}/LICENSE +0 -0
- {fastlifeweb-0.9.7.dist-info → fastlifeweb-0.11.0.dist-info}/WHEEL +0 -0
@@ -1,3 +1,14 @@
|
|
1
|
+
"""
|
2
|
+
Template Constants injects as global variables in templates.
|
3
|
+
|
4
|
+
Constants are configurable using the setting :attrs:`jinjax_global_catalog_class`,
|
5
|
+
in order to customize templates.
|
6
|
+
|
7
|
+
Those constants are heavy used to inject CSS classes in primary html element
|
8
|
+
that are bound to Jinja component.
|
9
|
+
|
10
|
+
"""
|
11
|
+
|
1
12
|
from pydantic import BaseModel
|
2
13
|
|
3
14
|
|
@@ -6,6 +17,8 @@ def space_join(*segments: str) -> str:
|
|
6
17
|
|
7
18
|
|
8
19
|
class Constants(BaseModel):
|
20
|
+
"""Templates constants."""
|
21
|
+
|
9
22
|
A_CLASS: str = space_join(
|
10
23
|
"text-primary-600",
|
11
24
|
"hover:text-primary-500",
|
@@ -13,6 +26,7 @@ class Constants(BaseModel):
|
|
13
26
|
"dark:text-primary-300",
|
14
27
|
"dark:hover:text-primary-400",
|
15
28
|
)
|
29
|
+
"""Default css class for {jinjax:component}`A`."""
|
16
30
|
|
17
31
|
BUTTON_CLASS: str = space_join(
|
18
32
|
"bg-primary-600",
|
@@ -32,8 +46,10 @@ class Constants(BaseModel):
|
|
32
46
|
"dark:focus:ring-primary-800",
|
33
47
|
"dark:hover:bg-primary-700",
|
34
48
|
)
|
49
|
+
"""Default css class for {jinjax:component}`Button`."""
|
35
50
|
|
36
51
|
DETAILS_CLASS: str = "border border-neutral-100 p-4 rounded-m"
|
52
|
+
"""Default css class for {jinjax:component}`Details`."""
|
37
53
|
|
38
54
|
SECONDARY_BUTTON_CLASS: str = space_join(
|
39
55
|
"bg-neutral-300",
|
@@ -52,6 +68,15 @@ class Constants(BaseModel):
|
|
52
68
|
"dark:focus:ring-neutral-100",
|
53
69
|
"dark:hover:bg-neutral-300",
|
54
70
|
)
|
71
|
+
"""
|
72
|
+
css class for {jinjax:component}`Button`.
|
73
|
+
|
74
|
+
usage:
|
75
|
+
|
76
|
+
```html
|
77
|
+
<Button :class="SECONDARY_BUTTON_CLASS">secondary</Button>
|
78
|
+
```
|
79
|
+
"""
|
55
80
|
|
56
81
|
ICON_BUTTON_CLASS: str = space_join(
|
57
82
|
"bg-white",
|
@@ -70,6 +95,18 @@ class Constants(BaseModel):
|
|
70
95
|
"dark:focus:ring-primary-500",
|
71
96
|
"dark:hover:text-primary-300",
|
72
97
|
)
|
98
|
+
"""
|
99
|
+
css class for {jinjax:component}`Button`.
|
100
|
+
|
101
|
+
usage:
|
102
|
+
|
103
|
+
```html
|
104
|
+
<Button :class="ICON_BUTTON_CLASS">
|
105
|
+
<icons.PencilSquare class="w-6 h-6" title="Copy" />
|
106
|
+
</Button>
|
107
|
+
|
108
|
+
```
|
109
|
+
"""
|
73
110
|
|
74
111
|
CHECKBOX_CLASS: str = space_join(
|
75
112
|
"bg-neutral-100",
|
@@ -85,7 +122,11 @@ class Constants(BaseModel):
|
|
85
122
|
"focus:ring-2",
|
86
123
|
"focus:ring-primary-500",
|
87
124
|
)
|
125
|
+
"""Default css class for {jinjax:component}`Checkbox`."""
|
126
|
+
|
88
127
|
FORM_CLASS: str = "space-y-4 md:space-y-6"
|
128
|
+
"""Default css class for {jinjax:component}`Form`."""
|
129
|
+
|
89
130
|
H1_CLASS: str = space_join(
|
90
131
|
"block",
|
91
132
|
"font-bold",
|
@@ -98,6 +139,8 @@ class Constants(BaseModel):
|
|
98
139
|
"dark:text-white",
|
99
140
|
"md:text-4xl",
|
100
141
|
)
|
142
|
+
"""Default css class for {jinjax:component}`H1`."""
|
143
|
+
|
101
144
|
H2_CLASS: str = space_join(
|
102
145
|
"block",
|
103
146
|
"font-bold",
|
@@ -110,6 +153,8 @@ class Constants(BaseModel):
|
|
110
153
|
"dark:text-white",
|
111
154
|
"md:text-4xl",
|
112
155
|
)
|
156
|
+
"""Default css class for {jinjax:component}`H2`."""
|
157
|
+
|
113
158
|
H3_CLASS: str = space_join(
|
114
159
|
"block",
|
115
160
|
"font-bold",
|
@@ -122,6 +167,8 @@ class Constants(BaseModel):
|
|
122
167
|
"dark:text-white",
|
123
168
|
"md:text-3xl",
|
124
169
|
)
|
170
|
+
"""Default css class for {jinjax:component}`H3`."""
|
171
|
+
|
125
172
|
H3_SUMMARY_CLASS: str = space_join(
|
126
173
|
"block",
|
127
174
|
"font-bold",
|
@@ -133,6 +180,9 @@ class Constants(BaseModel):
|
|
133
180
|
"dark:text-white",
|
134
181
|
"md:text-3xl",
|
135
182
|
)
|
183
|
+
"""
|
184
|
+
Default css class for {jinjax:component}`H3` inside {jinjax:component}`Summary`.
|
185
|
+
"""
|
136
186
|
|
137
187
|
H4_CLASS: str = space_join(
|
138
188
|
"block",
|
@@ -146,6 +196,8 @@ class Constants(BaseModel):
|
|
146
196
|
"dark:text-white",
|
147
197
|
"md:text-2xl",
|
148
198
|
)
|
199
|
+
"""Default css class for {jinjax:component}`H4`."""
|
200
|
+
|
149
201
|
H5_CLASS: str = space_join(
|
150
202
|
"block",
|
151
203
|
"font-bold",
|
@@ -158,6 +210,8 @@ class Constants(BaseModel):
|
|
158
210
|
"dark:text-white",
|
159
211
|
"md:text-xl",
|
160
212
|
)
|
213
|
+
"""Default css class for {jinjax:component}`H5`."""
|
214
|
+
|
161
215
|
H6_CLASS: str = space_join(
|
162
216
|
"block",
|
163
217
|
"font-bold",
|
@@ -170,6 +224,8 @@ class Constants(BaseModel):
|
|
170
224
|
"dark:text-white",
|
171
225
|
"md:text-l",
|
172
226
|
)
|
227
|
+
"""Default css class for {jinjax:component}`H6`."""
|
228
|
+
|
173
229
|
INPUT_CLASS: str = space_join(
|
174
230
|
"bg-neutral-50",
|
175
231
|
"block",
|
@@ -189,6 +245,8 @@ class Constants(BaseModel):
|
|
189
245
|
"focus:border-primary-500",
|
190
246
|
"focus:ring-primary-500",
|
191
247
|
)
|
248
|
+
"""Default css class for {jinjax:component}`Input`."""
|
249
|
+
|
192
250
|
LABEL_CLASS: str = space_join(
|
193
251
|
"block",
|
194
252
|
"font-bold",
|
@@ -197,12 +255,18 @@ class Constants(BaseModel):
|
|
197
255
|
"text-neutral-900",
|
198
256
|
"dark:text-white",
|
199
257
|
)
|
258
|
+
"""Default css class for {jinjax:component}`Label`."""
|
259
|
+
|
200
260
|
P_CLASS: str = space_join(
|
201
261
|
"text-base",
|
202
262
|
"text-neutral-900",
|
203
263
|
"dark:text-white",
|
204
264
|
)
|
265
|
+
"""Default css class for {jinjax:component}`P`."""
|
266
|
+
|
205
267
|
RADIO_DIV_CLASS: str = "flex items-center mb-4"
|
268
|
+
"""Default css class for {jinjax:component}`Radio` `<div>` container."""
|
269
|
+
|
206
270
|
RADIO_INPUT_CLASS: str = space_join(
|
207
271
|
"bg-neutral-100",
|
208
272
|
"border-neutral-300",
|
@@ -216,9 +280,17 @@ class Constants(BaseModel):
|
|
216
280
|
"dark:focus:ring-primary-600",
|
217
281
|
"dark:ring-offset-neutral-800",
|
218
282
|
)
|
283
|
+
"""
|
284
|
+
Default css class for {jinjax:component}`Radio` `<input type="radio">`.
|
285
|
+
"""
|
286
|
+
|
219
287
|
RADIO_LABEL_CLASS: str = (
|
220
288
|
"ms-2 text-sm font-medium text-neutral-900 dark:text-neutral-300"
|
221
289
|
)
|
290
|
+
"""
|
291
|
+
Default css class for {jinjax:component}`Radio` `<label>` element.
|
292
|
+
"""
|
293
|
+
|
222
294
|
SELECT_CLASS: str = space_join(
|
223
295
|
"bg-neutral-50",
|
224
296
|
"block",
|
@@ -238,6 +310,16 @@ class Constants(BaseModel):
|
|
238
310
|
"dark:placeholder-neutral-400",
|
239
311
|
"dark:text-white",
|
240
312
|
)
|
313
|
+
"""Default css class for {jinjax:component}`Select`."""
|
314
|
+
|
315
|
+
SUMMARY_CLASS: str = "flex items-center items-center font-medium cursor-pointer"
|
316
|
+
"""Default css class for {jinjax:component}`Summary`."""
|
317
|
+
|
241
318
|
TABLE_CLASS: str = "table-auto w-full text-left border-colapse"
|
319
|
+
"""Default css class for {jinjax:component}`Table`."""
|
320
|
+
|
242
321
|
TD_CLASS: str = "px-4 py-2 font-normal border-b dark:border-neutral-500"
|
322
|
+
"""Default css class for {jinjax:component}`Td`."""
|
323
|
+
|
243
324
|
TH_CLASS: str = "px-4 py-2 font-medium border-b dark:border-neutral-500"
|
325
|
+
"""Default css class for {jinjax:component}`Th`."""
|
@@ -1,22 +1,277 @@
|
|
1
|
-
|
1
|
+
"""Template rending based on JinjaX."""
|
2
|
+
|
3
|
+
import ast
|
4
|
+
import logging
|
5
|
+
import re
|
6
|
+
import textwrap
|
7
|
+
from pathlib import Path
|
8
|
+
from typing import (
|
9
|
+
TYPE_CHECKING,
|
10
|
+
Any,
|
11
|
+
Iterator,
|
12
|
+
Mapping,
|
13
|
+
MutableMapping,
|
14
|
+
Optional,
|
15
|
+
Sequence,
|
16
|
+
Type,
|
17
|
+
cast,
|
18
|
+
)
|
2
19
|
|
3
20
|
from fastapi import Request
|
21
|
+
from jinja2 import Template
|
22
|
+
from jinja2.exceptions import TemplateSyntaxError
|
23
|
+
from jinjax import InvalidArgument
|
4
24
|
from jinjax.catalog import Catalog
|
25
|
+
from jinjax.component import RX_ARGS_START, RX_META_HEADER, Component
|
26
|
+
from jinjax.exceptions import DuplicateDefDeclaration
|
5
27
|
from markupsafe import Markup
|
6
28
|
from pydantic.fields import FieldInfo
|
7
29
|
|
8
|
-
from fastlife.request.
|
30
|
+
from fastlife.request.form import FormModel
|
9
31
|
from fastlife.templating.renderer.widgets.factory import WidgetFactory
|
10
32
|
|
11
33
|
if TYPE_CHECKING:
|
12
|
-
from fastlife.
|
34
|
+
from fastlife.config.settings import Settings # coverage: ignore
|
13
35
|
|
14
36
|
from fastlife.shared_utils.resolver import resolve, resolve_path
|
15
37
|
|
16
38
|
from .abstract import AbstractTemplateRenderer, AbstractTemplateRendererFactory
|
17
39
|
|
40
|
+
log = logging.getLogger(__name__)
|
41
|
+
|
42
|
+
RX_DOC_START = re.compile(r"{#-?\s*doc\s+")
|
43
|
+
RX_CONTENT = re.compile(r"\{\{-?\s*content\s*-?\}\}", re.DOTALL)
|
44
|
+
RX_COMMENT_REPLACE = re.compile(r"{#[^#]+#}")
|
45
|
+
|
46
|
+
|
47
|
+
def has_content(source: str) -> bool:
|
48
|
+
nocomment = RX_COMMENT_REPLACE.sub("", source)
|
49
|
+
return len(RX_CONTENT.findall(nocomment)) > 0
|
50
|
+
|
51
|
+
|
52
|
+
def generate_docstring(
|
53
|
+
func_def: ast.FunctionDef, component_name: str, add_content: bool
|
54
|
+
) -> str:
|
55
|
+
"""Generate a docstring for the component."""
|
56
|
+
# Extract function name and docstring
|
57
|
+
docstring = (ast.get_docstring(func_def, clean=True) or "").strip()
|
58
|
+
if docstring:
|
59
|
+
docstring = textwrap.dedent(docstring)
|
60
|
+
docstring_lines = [l for l in docstring.split("\n")]
|
61
|
+
# Add a newline for separation after the function docstring
|
62
|
+
docstring_lines.append("")
|
63
|
+
else:
|
64
|
+
docstring_lines = []
|
65
|
+
|
66
|
+
component_params: list[str] = []
|
67
|
+
|
68
|
+
# Function for processing an argument and adding its docstring lines
|
69
|
+
def process_arg(arg: ast.arg, default_value: Any = None) -> None:
|
70
|
+
arg_name = arg.arg
|
71
|
+
param_desc = ""
|
72
|
+
# Extract the type annotation (if any)
|
73
|
+
if (
|
74
|
+
isinstance(arg.annotation, ast.Subscript)
|
75
|
+
and isinstance(arg.annotation.value, ast.Name)
|
76
|
+
and arg.annotation.value.id == "Annotated"
|
77
|
+
):
|
78
|
+
# For Annotated types, we expect the first argument to be the type and
|
79
|
+
# the second to be the description
|
80
|
+
type_annotation = arg.annotation.slice.elts[0] # type: ignore
|
81
|
+
param_type = ast.unparse(type_annotation)
|
82
|
+
|
83
|
+
if len(arg.annotation.slice.elts) > 1 and isinstance( # type: ignore
|
84
|
+
arg.annotation.slice.elts[1], # type: ignore
|
85
|
+
ast.Constant, # type: ignore
|
86
|
+
):
|
87
|
+
param_desc = arg.annotation.slice.elts[1].value # type: ignore
|
88
|
+
else:
|
89
|
+
# Otherwise, just use the type if available
|
90
|
+
param_type = ast.unparse(arg.annotation) if arg.annotation else "Any"
|
91
|
+
|
92
|
+
# Build the parameter docstring line
|
93
|
+
docstring_lines.append(f":param {arg_name.rstrip('_')}: {param_desc}".strip())
|
94
|
+
|
95
|
+
# Build the string representation of the parameter
|
96
|
+
param_str = f"{arg_name}: {param_type}"
|
97
|
+
if default_value is not None:
|
98
|
+
param_str += f" = {ast.unparse(default_value)}" # type: ignore
|
99
|
+
|
100
|
+
component_params.append(param_str)
|
101
|
+
|
102
|
+
# Process keyword-only arguments
|
103
|
+
kwonlyargs = func_def.args.kwonlyargs
|
104
|
+
kw_defaults = func_def.args.kw_defaults
|
105
|
+
|
106
|
+
for arg, default in zip(kwonlyargs, kw_defaults):
|
107
|
+
process_arg(arg, default)
|
108
|
+
|
109
|
+
if add_content:
|
110
|
+
component_params.append("content: Any")
|
111
|
+
docstring_lines.append(":param content: child node.")
|
112
|
+
|
113
|
+
return (
|
114
|
+
f"{component_name}({', '.join(component_params)})"
|
115
|
+
+ "\n"
|
116
|
+
+ "\n "
|
117
|
+
+ ("\n ".join(docstring_lines).strip()).replace("\n \n", "\n\n")
|
118
|
+
+ "\n"
|
119
|
+
)
|
120
|
+
|
121
|
+
|
122
|
+
class InspectableComponent(Component):
|
123
|
+
__slots__ = (
|
124
|
+
"name",
|
125
|
+
"prefix",
|
126
|
+
"url_prefix",
|
127
|
+
"required",
|
128
|
+
"optional",
|
129
|
+
"css",
|
130
|
+
"js",
|
131
|
+
"path",
|
132
|
+
"mtime",
|
133
|
+
"tmpl",
|
134
|
+
"source",
|
135
|
+
)
|
136
|
+
|
137
|
+
def __init__(
|
138
|
+
self,
|
139
|
+
*,
|
140
|
+
name: str,
|
141
|
+
prefix: str = "",
|
142
|
+
url_prefix: str = "",
|
143
|
+
source: str = "",
|
144
|
+
mtime: float = 0,
|
145
|
+
tmpl: "Template | None" = None,
|
146
|
+
path: "Path | None" = None,
|
147
|
+
) -> None:
|
148
|
+
super().__init__(
|
149
|
+
name=name,
|
150
|
+
prefix=prefix,
|
151
|
+
url_prefix=url_prefix,
|
152
|
+
source=source,
|
153
|
+
mtime=mtime,
|
154
|
+
tmpl=tmpl,
|
155
|
+
path=path,
|
156
|
+
)
|
157
|
+
self.source = source
|
158
|
+
|
159
|
+
def as_def(self) -> ast.FunctionDef:
|
160
|
+
signature = "def component(): pass"
|
161
|
+
match = RX_META_HEADER.match(self.source)
|
162
|
+
if match:
|
163
|
+
headers = match.group(0)
|
164
|
+
header = headers.split("#}")[:-1]
|
165
|
+
def_found = False
|
166
|
+
docstring = ""
|
167
|
+
|
168
|
+
expr = None
|
169
|
+
while header:
|
170
|
+
item = header.pop(0).strip(" -\n")
|
171
|
+
|
172
|
+
expr = self.read_metadata_item(item, RX_ARGS_START)
|
173
|
+
if expr:
|
174
|
+
if def_found:
|
175
|
+
raise DuplicateDefDeclaration(self.name)
|
176
|
+
def_found = True
|
177
|
+
continue
|
178
|
+
|
179
|
+
doc = self.read_metadata_item(item, RX_DOC_START)
|
180
|
+
if doc:
|
181
|
+
docstring += f" {doc.strip()}\n"
|
182
|
+
continue
|
183
|
+
|
184
|
+
if expr:
|
185
|
+
signature = f"""\
|
186
|
+
def component(*, {expr}):
|
187
|
+
'''
|
188
|
+
{docstring or ""}
|
189
|
+
'''
|
190
|
+
...
|
191
|
+
"""
|
192
|
+
elif docstring:
|
193
|
+
signature = f"""\
|
194
|
+
def component():
|
195
|
+
'''
|
196
|
+
{docstring}
|
197
|
+
'''
|
198
|
+
...
|
199
|
+
"""
|
200
|
+
|
201
|
+
try:
|
202
|
+
astree = ast.parse(signature)
|
203
|
+
except SyntaxError as err:
|
204
|
+
raise InvalidArgument(err) from err
|
205
|
+
return cast(ast.FunctionDef, astree.body[0])
|
206
|
+
|
207
|
+
def build_docstring(self) -> str:
|
208
|
+
func_def = self.as_def()
|
209
|
+
prefix = f"{self.prefix}." if self.prefix else ""
|
210
|
+
ret = ".. jinjax:component:: " + generate_docstring(
|
211
|
+
func_def, f"{prefix}{self.name}", has_content(self.source)
|
212
|
+
)
|
213
|
+
return ret
|
214
|
+
|
215
|
+
|
216
|
+
class InspectableCatalog(Catalog):
|
217
|
+
"""
|
218
|
+
Override the catalog in order to iterate over components to build the doc.
|
219
|
+
"""
|
220
|
+
|
221
|
+
def iter_components(
|
222
|
+
self,
|
223
|
+
ignores: Sequence[re.Pattern[str]] | None = None,
|
224
|
+
includes: Sequence[re.Pattern[str]] | None = None,
|
225
|
+
) -> Iterator[InspectableComponent]:
|
226
|
+
for prefix, loader in self.prefixes.items():
|
227
|
+
for t in loader.list_templates():
|
228
|
+
name, file_ext = t.split(".", maxsplit=1)
|
229
|
+
name = name.replace("/", ".")
|
230
|
+
path, tmpl_name = self._get_component_path(
|
231
|
+
prefix, name, file_ext=file_ext
|
232
|
+
)
|
233
|
+
|
234
|
+
to_include = True
|
235
|
+
if includes:
|
236
|
+
to_include = False
|
237
|
+
for include in includes:
|
238
|
+
if include.match(name):
|
239
|
+
to_include = True
|
240
|
+
break
|
241
|
+
if to_include and ignores:
|
242
|
+
for ignore in ignores:
|
243
|
+
if ignore.match(name):
|
244
|
+
to_include = False
|
245
|
+
break
|
246
|
+
|
247
|
+
if to_include:
|
248
|
+
component = InspectableComponent(
|
249
|
+
name=name, prefix=prefix, path=path, source=path.read_text()
|
250
|
+
)
|
251
|
+
|
252
|
+
self.jinja_env.loader = loader
|
253
|
+
try:
|
254
|
+
component.tmpl = self.jinja_env.get_template(
|
255
|
+
tmpl_name, globals=self._tmpl_globals
|
256
|
+
)
|
257
|
+
except TemplateSyntaxError as exc:
|
258
|
+
log.error(f"Syntax Error: {exc} on {exc.lineno} :")
|
259
|
+
log.error(path.read_text())
|
260
|
+
continue
|
261
|
+
yield component
|
262
|
+
|
18
263
|
|
19
264
|
def build_searchpath(template_search_path: str) -> Sequence[str]:
|
265
|
+
"""
|
266
|
+
Build the path containing templates.
|
267
|
+
|
268
|
+
Path may be absolute directories or directories relative to a python
|
269
|
+
package. For instance, the `fastlife:templates` is the directory templates
|
270
|
+
inside the fastlife installation dir.
|
271
|
+
|
272
|
+
:param template_search_path: list of path separated by a comma (`,`).
|
273
|
+
:return: List resolved path.
|
274
|
+
"""
|
20
275
|
searchpath: list[str] = []
|
21
276
|
paths = template_search_path.split(",")
|
22
277
|
|
@@ -29,9 +284,11 @@ def build_searchpath(template_search_path: str) -> Sequence[str]:
|
|
29
284
|
|
30
285
|
|
31
286
|
class JinjaxRenderer(AbstractTemplateRenderer):
|
287
|
+
"""Render templates using JinjaX."""
|
288
|
+
|
32
289
|
def __init__(
|
33
290
|
self,
|
34
|
-
catalog:
|
291
|
+
catalog: InspectableCatalog,
|
35
292
|
request: Request,
|
36
293
|
csrf_token_name: str,
|
37
294
|
form_data_model_prefix: str,
|
@@ -45,6 +302,12 @@ class JinjaxRenderer(AbstractTemplateRenderer):
|
|
45
302
|
self.globals: MutableMapping[str, Any] = {}
|
46
303
|
|
47
304
|
def build_globals(self) -> Mapping[str, Any]:
|
305
|
+
"""
|
306
|
+
Build globals variables accessible in any templates.
|
307
|
+
|
308
|
+
* `request` is the {class}`current request <fastlife.request.request.Request>`
|
309
|
+
* `csrf_token` is used to build for {jinjax:component}`CsrfToken`.
|
310
|
+
"""
|
48
311
|
return {
|
49
312
|
"request": self.request,
|
50
313
|
"csrf_token": {
|
@@ -69,7 +332,7 @@ class JinjaxRenderer(AbstractTemplateRenderer):
|
|
69
332
|
)
|
70
333
|
|
71
334
|
def pydantic_form(
|
72
|
-
self, model:
|
335
|
+
self, model: FormModel[Any], *, token: Optional[str] = None
|
73
336
|
) -> Markup:
|
74
337
|
return WidgetFactory(self, token).get_markup(model)
|
75
338
|
|
@@ -112,7 +375,7 @@ class JinjaxTemplateRenderer(AbstractTemplateRendererFactory):
|
|
112
375
|
self.csrf_token_name = settings.csrf_token_name
|
113
376
|
globals = resolve(settings.jinjax_global_catalog_class)().model_dump()
|
114
377
|
|
115
|
-
self.catalog =
|
378
|
+
self.catalog = InspectableCatalog(
|
116
379
|
use_cache=settings.jinjax_use_cache,
|
117
380
|
auto_reload=settings.jinjax_auto_reload,
|
118
381
|
globals=globals,
|
@@ -19,16 +19,35 @@ def get_title(typ: Type[Any]) -> str:
|
|
19
19
|
|
20
20
|
|
21
21
|
class Widget(abc.ABC, Generic[T]):
|
22
|
+
"""
|
23
|
+
Base class for widget of pydantic fields.
|
24
|
+
|
25
|
+
:param name: field name.
|
26
|
+
:param value: field value.
|
27
|
+
:param title: title for the widget.
|
28
|
+
:param hint: hint for human.
|
29
|
+
:param aria_label: html input aria-label value.
|
30
|
+
:param value: current value.
|
31
|
+
:param error: error of the value if any.
|
32
|
+
:param children_types: childrens types list.
|
33
|
+
:param removable: display a button to remove the widget for optional fields.
|
34
|
+
:param token: token used to get unique id on the form.
|
35
|
+
"""
|
36
|
+
|
22
37
|
name: str
|
23
|
-
"variable name, nested variables have dots"
|
38
|
+
"variable name, nested variables have dots."
|
39
|
+
value: T | None
|
40
|
+
"""Value of the field."""
|
24
41
|
title: str
|
25
|
-
"Human title for the widget"
|
42
|
+
"Human title for the widget."
|
43
|
+
hint: str
|
44
|
+
"A help message for the the widget."
|
26
45
|
aria_label: str
|
27
|
-
"Non visible text alternative"
|
46
|
+
"Non visible text alternative."
|
28
47
|
token: str
|
29
|
-
"unique token to ensure id are unique in the DOM"
|
48
|
+
"unique token to ensure id are unique in the DOM."
|
30
49
|
removable: bool
|
31
|
-
"Indicate that the widget is removable from the dom"
|
50
|
+
"Indicate that the widget is removable from the dom."
|
32
51
|
|
33
52
|
def __init__(
|
34
53
|
self,
|
@@ -37,6 +56,7 @@ class Widget(abc.ABC, Generic[T]):
|
|
37
56
|
value: T | None = None,
|
38
57
|
error: str | None = None,
|
39
58
|
title: str | None = None,
|
59
|
+
hint: str | None = None,
|
40
60
|
token: str | None = None,
|
41
61
|
aria_label: str | None = None,
|
42
62
|
removable: bool = False,
|
@@ -45,6 +65,7 @@ class Widget(abc.ABC, Generic[T]):
|
|
45
65
|
self.value = value
|
46
66
|
self.error = error
|
47
67
|
self.title = title or name.split(".")[-1]
|
68
|
+
self.hint = hint or ""
|
48
69
|
self.aria_label = aria_label or ""
|
49
70
|
self.token = token or secrets.token_urlsafe(4).replace("_", "-")
|
50
71
|
self.removable = removable
|
@@ -52,10 +73,10 @@ class Widget(abc.ABC, Generic[T]):
|
|
52
73
|
|
53
74
|
@abc.abstractmethod
|
54
75
|
def get_template(self) -> str:
|
55
|
-
|
76
|
+
"""Get the widget component template."""
|
56
77
|
|
57
78
|
def to_html(self, renderer: AbstractTemplateRenderer) -> Markup:
|
58
|
-
"""Return the html version"""
|
79
|
+
"""Return the html version."""
|
59
80
|
return Markup(renderer.render_template(self.get_template(), widget=self))
|
60
81
|
|
61
82
|
|
@@ -67,6 +88,17 @@ def _get_fullname(typ: Type[Any]) -> str:
|
|
67
88
|
|
68
89
|
|
69
90
|
class TypeWrapper:
|
91
|
+
"""
|
92
|
+
Wrap children types for union type.
|
93
|
+
|
94
|
+
:param typ: Wrapped type.
|
95
|
+
:param route_prefix: route prefix used for ajax query to build type.
|
96
|
+
:param name: name of the field wrapped.
|
97
|
+
:param token: unique token to render unique id.
|
98
|
+
:param title: title to display.
|
99
|
+
|
100
|
+
"""
|
101
|
+
|
70
102
|
def __init__(
|
71
103
|
self,
|
72
104
|
typ: Type[Any],
|
@@ -83,19 +115,23 @@ class TypeWrapper:
|
|
83
115
|
|
84
116
|
@property
|
85
117
|
def fullname(self) -> str:
|
118
|
+
"""Full name for the type."""
|
86
119
|
return _get_fullname(self.typ)
|
87
120
|
|
88
121
|
@property
|
89
122
|
def id(self) -> str:
|
123
|
+
"""Unique id to inject in the DOM."""
|
90
124
|
name = self.name.replace("_", "-").replace(".", "-").replace(":", "-")
|
91
125
|
typ = self.typ.__name__.replace("_", "-")
|
92
126
|
return f"{name}-{typ}-{self.token}"
|
93
127
|
|
94
128
|
@property
|
95
129
|
def params(self) -> Mapping[str, str]:
|
130
|
+
"""Params for the widget to render."""
|
96
131
|
return {"name": self.name, "token": self.token, "title": self.title}
|
97
132
|
|
98
133
|
@property
|
99
134
|
def url(self) -> str:
|
135
|
+
"""Url to fetch the widget."""
|
100
136
|
ret = f"{self.route_prefix}/pydantic-form/widgets/{self.fullname}"
|
101
137
|
return ret
|
@@ -1,12 +1,31 @@
|
|
1
|
+
"""
|
2
|
+
Widget for field of type bool.
|
3
|
+
"""
|
4
|
+
|
1
5
|
from .base import Widget
|
2
6
|
|
3
7
|
|
4
8
|
class BooleanWidget(Widget[bool]):
|
9
|
+
"""
|
10
|
+
Widget for field of type bool.
|
11
|
+
|
12
|
+
:param name: field name.
|
13
|
+
:param title: title for the widget.
|
14
|
+
:param hint: hint for human.
|
15
|
+
:param aria_label: html input aria-label value.
|
16
|
+
:param value: current value.
|
17
|
+
:param error: error of the value if any.
|
18
|
+
:param removable: display a button to remove the widget for optional fields.
|
19
|
+
:param token: token used to get unique id on the form.
|
20
|
+
"""
|
21
|
+
|
5
22
|
def __init__(
|
6
23
|
self,
|
7
24
|
name: str,
|
8
25
|
*,
|
9
26
|
title: str | None,
|
27
|
+
hint: str | None = None,
|
28
|
+
aria_label: str | None = None,
|
10
29
|
value: bool = False,
|
11
30
|
error: str | None = None,
|
12
31
|
removable: bool = False,
|
@@ -15,6 +34,8 @@ class BooleanWidget(Widget[bool]):
|
|
15
34
|
super().__init__(
|
16
35
|
name,
|
17
36
|
title=title,
|
37
|
+
hint=hint,
|
38
|
+
aria_label=aria_label,
|
18
39
|
value=value,
|
19
40
|
error=error,
|
20
41
|
removable=removable,
|