fastlifeweb 0.5.1__py3-none-any.whl → 0.6.1__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 +3 -2
- fastlife/request/form_data.py +7 -22
- fastlife/request/model_result.py +91 -0
- fastlife/shared_utils/infer.py +1 -1
- fastlife/templates/pydantic_form/Boolean.jinja +7 -2
- fastlife/templates/pydantic_form/Checklist.jinja +3 -1
- fastlife/templates/pydantic_form/Dropdown.jinja +1 -0
- fastlife/templates/pydantic_form/Error.jinja +4 -0
- fastlife/templates/pydantic_form/Model.jinja +1 -0
- fastlife/templates/pydantic_form/Sequence.jinja +1 -0
- fastlife/templates/pydantic_form/Text.jinja +1 -0
- fastlife/templates/pydantic_form/Union.jinja +4 -4
- fastlife/templating/renderer/abstract.py +16 -2
- fastlife/templating/renderer/jinjax.py +25 -4
- fastlife/templating/renderer/widgets/base.py +7 -5
- fastlife/templating/renderer/widgets/boolean.py +7 -1
- fastlife/templating/renderer/widgets/checklist.py +13 -2
- fastlife/templating/renderer/widgets/dropdown.py +7 -1
- fastlife/templating/renderer/widgets/factory.py +69 -16
- fastlife/templating/renderer/widgets/model.py +7 -1
- fastlife/templating/renderer/widgets/sequence.py +7 -1
- fastlife/templating/renderer/widgets/text.py +3 -1
- fastlife/templating/renderer/widgets/union.py +7 -1
- fastlife/testing/testclient.py +102 -0
- fastlife/views/pydantic_form.py +6 -2
- {fastlifeweb-0.5.1.dist-info → fastlifeweb-0.6.1.dist-info}/METADATA +2 -2
- {fastlifeweb-0.5.1.dist-info → fastlifeweb-0.6.1.dist-info}/RECORD +29 -27
- {fastlifeweb-0.5.1.dist-info → fastlifeweb-0.6.1.dist-info}/LICENSE +0 -0
- {fastlifeweb-0.5.1.dist-info → fastlifeweb-0.6.1.dist-info}/WHEEL +0 -0
fastlife/__init__.py
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
from .configurator import Configurator, configure
|
2
2
|
from .configurator.registry import Registry
|
3
|
-
|
3
|
+
|
4
|
+
# from .request.form_data import model
|
4
5
|
from .templating import Template, template
|
5
6
|
|
6
7
|
__all__ = [
|
@@ -11,5 +12,5 @@ __all__ = [
|
|
11
12
|
"Template",
|
12
13
|
"Registry",
|
13
14
|
# Model
|
14
|
-
"model",
|
15
|
+
# "model",
|
15
16
|
]
|
fastlife/request/form_data.py
CHANGED
@@ -1,18 +1,14 @@
|
|
1
1
|
from typing import (
|
2
2
|
Annotated,
|
3
3
|
Any,
|
4
|
-
Callable,
|
5
4
|
Mapping,
|
6
5
|
MutableMapping,
|
7
6
|
MutableSequence,
|
8
7
|
Optional,
|
9
8
|
Sequence,
|
10
|
-
Type,
|
11
|
-
TypeVar,
|
12
9
|
)
|
13
10
|
|
14
11
|
from fastapi import Depends, Request
|
15
|
-
from pydantic import BaseModel
|
16
12
|
|
17
13
|
from fastlife.configurator.registry import Registry
|
18
14
|
|
@@ -80,7 +76,13 @@ async def unflatten_mapping_form_data(
|
|
80
76
|
form_data = await request.form()
|
81
77
|
form_data_decode_list: MutableMapping[str, Any] = {}
|
82
78
|
for key, val in form_data.multi_items():
|
83
|
-
if key
|
79
|
+
if key.endswith("[]"):
|
80
|
+
key = key[:-2]
|
81
|
+
if key not in form_data_decode_list:
|
82
|
+
form_data_decode_list[key] = [val]
|
83
|
+
else:
|
84
|
+
form_data_decode_list[key].append(val)
|
85
|
+
elif key in form_data_decode_list:
|
84
86
|
if not isinstance(form_data_decode_list, list):
|
85
87
|
form_data_decode_list[key] = [form_data_decode_list[key]]
|
86
88
|
form_data_decode_list[key].append(val)
|
@@ -105,20 +107,3 @@ async def unflatten_sequence_form_data(
|
|
105
107
|
|
106
108
|
MappingFormData = Annotated[Mapping[str, Any], Depends(unflatten_mapping_form_data)]
|
107
109
|
SequenceFormData = Annotated[Sequence[str], Depends(unflatten_sequence_form_data)]
|
108
|
-
|
109
|
-
|
110
|
-
T = TypeVar("T", bound=BaseModel)
|
111
|
-
"""Template type for form serialized model"""
|
112
|
-
|
113
|
-
|
114
|
-
def model(cls: Type[T], name: Optional[str] = None) -> Callable[[Mapping[str, Any]], T]:
|
115
|
-
"""
|
116
|
-
Build a model, a class of type T based on Pydandic Base Model from a form payload.
|
117
|
-
"""
|
118
|
-
|
119
|
-
def to_model(data: MappingFormData, registry: Registry) -> Optional[T]:
|
120
|
-
if data:
|
121
|
-
return cls(**data[name or registry.settings.form_data_model_prefix])
|
122
|
-
return None
|
123
|
-
|
124
|
-
return Depends(to_model)
|
@@ -0,0 +1,91 @@
|
|
1
|
+
from typing import Any, Callable, Generic, Mapping, Type, TypeVar, get_origin
|
2
|
+
|
3
|
+
from fastapi import Depends
|
4
|
+
from pydantic import BaseModel, ValidationError
|
5
|
+
|
6
|
+
from fastlife.configurator.registry import Registry
|
7
|
+
from fastlife.request.form_data import MappingFormData
|
8
|
+
from fastlife.shared_utils.infer import is_union
|
9
|
+
|
10
|
+
T = TypeVar("T", bound=BaseModel)
|
11
|
+
"""Template type for form serialized model"""
|
12
|
+
|
13
|
+
|
14
|
+
class ModelResult(Generic[T]):
|
15
|
+
prefix: str
|
16
|
+
model: T
|
17
|
+
errors: Mapping[str, str]
|
18
|
+
is_valid: bool
|
19
|
+
|
20
|
+
def __init__(
|
21
|
+
self, prefix: str, model: T, errors: Mapping[str, Any], is_valid: bool = False
|
22
|
+
) -> None:
|
23
|
+
self.prefix = prefix
|
24
|
+
self.model = model
|
25
|
+
self.errors = errors
|
26
|
+
self.is_valid = is_valid
|
27
|
+
|
28
|
+
@classmethod
|
29
|
+
def default(cls, prefix: str, pydantic_type: Type[T]) -> "ModelResult[T]":
|
30
|
+
return cls(prefix, pydantic_type.model_construct(), {})
|
31
|
+
|
32
|
+
@property
|
33
|
+
def form_data(self) -> Mapping[str, Any]:
|
34
|
+
return {self.prefix: self.model.model_dump()}
|
35
|
+
|
36
|
+
@classmethod
|
37
|
+
def from_payload(
|
38
|
+
cls, prefix: str, pydantic_type: Type[T], data: Mapping[str, Any]
|
39
|
+
) -> "ModelResult[T]":
|
40
|
+
try:
|
41
|
+
return cls(prefix, pydantic_type(**data.get(prefix, {})), {}, True)
|
42
|
+
except ValidationError as exc:
|
43
|
+
errors: dict[str, str] = {}
|
44
|
+
for error in exc.errors():
|
45
|
+
loc = prefix
|
46
|
+
typ: Any = pydantic_type
|
47
|
+
for part in error["loc"]:
|
48
|
+
if isinstance(part, str):
|
49
|
+
type_origin = get_origin(typ)
|
50
|
+
if type_origin:
|
51
|
+
if is_union(typ):
|
52
|
+
args = typ.__args__
|
53
|
+
for arg in args:
|
54
|
+
if arg.__name__ == part:
|
55
|
+
typ = arg
|
56
|
+
continue
|
57
|
+
|
58
|
+
else:
|
59
|
+
raise NotImplementedError
|
60
|
+
elif issubclass(typ, BaseModel):
|
61
|
+
typ = typ.model_fields[part].annotation
|
62
|
+
loc = f"{loc}.{part}"
|
63
|
+
else:
|
64
|
+
raise NotImplementedError
|
65
|
+
|
66
|
+
else:
|
67
|
+
# it is an integer and it part of the list
|
68
|
+
loc = f"{loc}.{part}"
|
69
|
+
|
70
|
+
if loc in errors:
|
71
|
+
errors[loc] = f"{errors[loc]}, {error['msg']}"
|
72
|
+
else:
|
73
|
+
errors[loc] = error["msg"]
|
74
|
+
model = pydantic_type.model_construct(**data.get(prefix, {}))
|
75
|
+
return cls(prefix, model, errors)
|
76
|
+
|
77
|
+
|
78
|
+
def model(
|
79
|
+
cls: Type[T], name: str | None = None
|
80
|
+
) -> Callable[[Mapping[str, Any]], ModelResult[T]]:
|
81
|
+
"""
|
82
|
+
Build a model, a class of type T based on Pydandic Base Model from a form payload.
|
83
|
+
"""
|
84
|
+
|
85
|
+
def to_model(data: MappingFormData, registry: Registry) -> ModelResult[T]:
|
86
|
+
prefix = name or registry.settings.form_data_model_prefix
|
87
|
+
if not data:
|
88
|
+
return ModelResult[T].default(prefix, cls)
|
89
|
+
return ModelResult[T].from_payload(prefix, cls, data)
|
90
|
+
|
91
|
+
return Depends(to_model)
|
fastlife/shared_utils/infer.py
CHANGED
@@ -1,7 +1,12 @@
|
|
1
1
|
{# def widget #}
|
2
2
|
<pydantic_form.Widget widget={widget} removable={widget.removable}>
|
3
3
|
<div class="pt-4">
|
4
|
-
<
|
5
|
-
|
4
|
+
<div class="flex items-center">
|
5
|
+
<Checkbox name={widget.name} type="checkbox" id={widget.id} checked={widget.value} value="1" />
|
6
|
+
<Label for={widget.id} class="ms-2 text-base text-neutral-900 dark:text-white">
|
7
|
+
{{widget.title|safe}}
|
8
|
+
</Label>
|
9
|
+
</div>
|
10
|
+
<pydantic_form.Error text={widget.error} />
|
6
11
|
</div>
|
7
12
|
</pydantic_form.Widget>
|
@@ -7,12 +7,14 @@ widget,
|
|
7
7
|
<details open>
|
8
8
|
<summary class="justify-between items-center font-medium cursor-pointer">
|
9
9
|
<H3>{{widget.title}}</H3>
|
10
|
+
<pydantic_form.Error text={widget.error} />
|
10
11
|
</summary>
|
11
12
|
<div>
|
12
13
|
{% for value in widget.value %}
|
13
14
|
<div class="flex items-center mb-4">
|
14
|
-
<Checkbox name={
|
15
|
+
<Checkbox name={value.field_name} type="checkbox" id={value.id} value={value.value} checked={value.checked} />
|
15
16
|
<Label for={value.id} class="ms-2 text-base text-neutral-900 dark:text-white">{{value.label}}</Label>
|
17
|
+
<pydantic_form.Error text={value.error} />
|
16
18
|
</div>
|
17
19
|
{% endfor %}
|
18
20
|
</div>
|
@@ -3,6 +3,7 @@
|
|
3
3
|
<pydantic_form.Widget widget={widget} removable={widget.removable}>
|
4
4
|
<div class="pt-4">
|
5
5
|
<Label for={widget.id}>{{widget.title}}</Label>
|
6
|
+
<pydantic_form.Error text={widget.error} />
|
6
7
|
<Input name={widget.name} value={widget.value} type={widget.input_type} id={widget.id}
|
7
8
|
aria-label={widget.aria_label} placeholder={widget.placeholder} />
|
8
9
|
<pydantic_form.Hint text={widget.hint} />
|
@@ -5,20 +5,20 @@
|
|
5
5
|
<details open>
|
6
6
|
<summary class="justify-between items-center font-medium cursor-pointer">
|
7
7
|
<H3>{{widget.title}}</H3>
|
8
|
+
<pydantic_form.Error text={widget.error} />
|
8
9
|
</summary>
|
9
10
|
<div hx-sync="this" id="{{widget.id}}-child">
|
10
11
|
{% if child %}
|
11
12
|
{{ child }}
|
12
13
|
{% else %}
|
13
14
|
{% for typ in types %}
|
14
|
-
<Button type="button" hx-target="closest div" hx-get={typ.url} hx-vals={typ.params|tojson}
|
15
|
-
id={typ.id}
|
15
|
+
<Button type="button" hx-target="closest div" hx-get={typ.url} hx-vals={typ.params|tojson} id={typ.id}
|
16
16
|
onclick={"document.getElementById('" + widget.id +"-remove-btn').hidden=false"}>{{typ.title}}</Button>
|
17
17
|
{% endfor %}
|
18
18
|
{% endif %}
|
19
19
|
</div>
|
20
|
-
<Button type="button" id={widget.id + "-remove-btn" } hx-target={"#" + widget.id}
|
21
|
-
hx-get={parent_type.url} hidden={not child}>
|
20
|
+
<Button type="button" id={widget.id + "-remove-btn" } hx-target={"#" + widget.id}
|
21
|
+
hx-vals={parent_type.params|tojson} hx-get={parent_type.url} hidden={not child}>
|
22
22
|
Remove
|
23
23
|
</Button>
|
24
24
|
</details>
|
@@ -5,6 +5,8 @@ from fastapi import Request
|
|
5
5
|
from markupsafe import Markup
|
6
6
|
from pydantic.fields import FieldInfo
|
7
7
|
|
8
|
+
from fastlife.request.model_result import ModelResult
|
9
|
+
|
8
10
|
|
9
11
|
class AbstractTemplateRenderer(abc.ABC):
|
10
12
|
route_prefix: str
|
@@ -30,8 +32,8 @@ class AbstractTemplateRenderer(abc.ABC):
|
|
30
32
|
@abc.abstractmethod
|
31
33
|
def pydantic_form(
|
32
34
|
self,
|
33
|
-
model:
|
34
|
-
|
35
|
+
model: ModelResult[Any],
|
36
|
+
*,
|
35
37
|
name: str | None = None,
|
36
38
|
token: str | None = None,
|
37
39
|
removable: bool = False,
|
@@ -39,6 +41,18 @@ class AbstractTemplateRenderer(abc.ABC):
|
|
39
41
|
) -> Markup:
|
40
42
|
...
|
41
43
|
|
44
|
+
@abc.abstractmethod
|
45
|
+
def pydantic_form_field(
|
46
|
+
self,
|
47
|
+
model: Type[Any],
|
48
|
+
*,
|
49
|
+
name: Optional[str] = None,
|
50
|
+
token: Optional[str] = None,
|
51
|
+
removable: bool = False,
|
52
|
+
field: FieldInfo | None = None,
|
53
|
+
) -> Markup:
|
54
|
+
...
|
55
|
+
|
42
56
|
|
43
57
|
class AbstractTemplateRendererFactory(abc.ABC):
|
44
58
|
@abc.abstractmethod
|
@@ -5,6 +5,7 @@ from jinjax.catalog import Catalog
|
|
5
5
|
from markupsafe import Markup
|
6
6
|
from pydantic.fields import FieldInfo
|
7
7
|
|
8
|
+
from fastlife.request.model_result import ModelResult
|
8
9
|
from fastlife.templating.renderer.widgets.factory import WidgetFactory
|
9
10
|
|
10
11
|
if TYPE_CHECKING:
|
@@ -69,8 +70,8 @@ class JinjaxRenderer(AbstractTemplateRenderer):
|
|
69
70
|
|
70
71
|
def pydantic_form(
|
71
72
|
self,
|
72
|
-
model:
|
73
|
-
|
73
|
+
model: ModelResult[Any],
|
74
|
+
*,
|
74
75
|
name: Optional[str] = None,
|
75
76
|
token: Optional[str] = None,
|
76
77
|
removable: bool = False,
|
@@ -78,12 +79,32 @@ class JinjaxRenderer(AbstractTemplateRenderer):
|
|
78
79
|
) -> Markup:
|
79
80
|
return WidgetFactory(self, token).get_markup(
|
80
81
|
model,
|
81
|
-
form_data or {},
|
82
|
-
prefix=(name or self.form_data_model_prefix),
|
83
82
|
removable=removable,
|
84
83
|
field=field,
|
85
84
|
)
|
86
85
|
|
86
|
+
def pydantic_form_field(
|
87
|
+
self,
|
88
|
+
model: Type[Any],
|
89
|
+
*,
|
90
|
+
name: Optional[str] = None,
|
91
|
+
token: Optional[str] = None,
|
92
|
+
removable: bool = False,
|
93
|
+
field: FieldInfo | None = None,
|
94
|
+
) -> Markup:
|
95
|
+
return (
|
96
|
+
WidgetFactory(self, token)
|
97
|
+
.get_widget(
|
98
|
+
model,
|
99
|
+
form_data={},
|
100
|
+
form_errors={},
|
101
|
+
prefix=(name or self.form_data_model_prefix),
|
102
|
+
removable=removable,
|
103
|
+
field=field,
|
104
|
+
)
|
105
|
+
.to_html(self)
|
106
|
+
)
|
107
|
+
|
87
108
|
|
88
109
|
class JinjaxTemplateRenderer(AbstractTemplateRendererFactory):
|
89
110
|
route_prefix: str
|
@@ -1,6 +1,6 @@
|
|
1
1
|
import abc
|
2
2
|
import secrets
|
3
|
-
from typing import Any, Generic, Mapping,
|
3
|
+
from typing import Any, Generic, Mapping, Type, TypeVar
|
4
4
|
|
5
5
|
from markupsafe import Markup
|
6
6
|
|
@@ -34,14 +34,16 @@ class Widget(abc.ABC, Generic[T]):
|
|
34
34
|
self,
|
35
35
|
name: str,
|
36
36
|
*,
|
37
|
-
value:
|
38
|
-
|
39
|
-
|
40
|
-
|
37
|
+
value: T | None = None,
|
38
|
+
error: str | None = None,
|
39
|
+
title: str | None = None,
|
40
|
+
token: str | None = None,
|
41
|
+
aria_label: str | None = None,
|
41
42
|
removable: bool = False,
|
42
43
|
):
|
43
44
|
self.name = name
|
44
45
|
self.value = value
|
46
|
+
self.error = error
|
45
47
|
self.title = title or name.split(".")[-1]
|
46
48
|
self.aria_label = aria_label or ""
|
47
49
|
self.token = token or secrets.token_urlsafe(4).replace("_", "-")
|
@@ -8,11 +8,17 @@ class BooleanWidget(Widget[bool]):
|
|
8
8
|
*,
|
9
9
|
title: str | None,
|
10
10
|
value: bool = False,
|
11
|
+
error: str | None = None,
|
11
12
|
removable: bool = False,
|
12
13
|
token: str,
|
13
14
|
) -> None:
|
14
15
|
super().__init__(
|
15
|
-
name,
|
16
|
+
name,
|
17
|
+
title=title,
|
18
|
+
value=value,
|
19
|
+
error=error,
|
20
|
+
removable=removable,
|
21
|
+
token=token,
|
16
22
|
)
|
17
23
|
|
18
24
|
def get_template(self) -> str:
|
@@ -1,6 +1,6 @@
|
|
1
1
|
from typing import Sequence
|
2
2
|
|
3
|
-
from pydantic import BaseModel
|
3
|
+
from pydantic import BaseModel, Field
|
4
4
|
|
5
5
|
from .base import Widget
|
6
6
|
|
@@ -11,12 +11,17 @@ class Checkable(BaseModel):
|
|
11
11
|
value: str
|
12
12
|
token: str
|
13
13
|
checked: bool
|
14
|
+
error: str | None = Field(default=None)
|
14
15
|
|
15
16
|
@property
|
16
17
|
def id(self) -> str:
|
17
18
|
id = f"{self.name}-{self.value}-{self.token}"
|
18
19
|
return id.replace(".", "-").replace("_", "-")
|
19
20
|
|
21
|
+
@property
|
22
|
+
def field_name(self) -> str:
|
23
|
+
return f"{self.name}[]"
|
24
|
+
|
20
25
|
|
21
26
|
class ChecklistWidget(Widget[Sequence[Checkable]]):
|
22
27
|
def __init__(
|
@@ -25,11 +30,17 @@ class ChecklistWidget(Widget[Sequence[Checkable]]):
|
|
25
30
|
*,
|
26
31
|
title: str | None,
|
27
32
|
value: Sequence[Checkable],
|
33
|
+
error: str | None = None,
|
28
34
|
token: str,
|
29
35
|
removable: bool,
|
30
36
|
) -> None:
|
31
37
|
super().__init__(
|
32
|
-
name,
|
38
|
+
name,
|
39
|
+
value=value,
|
40
|
+
error=error,
|
41
|
+
token=token,
|
42
|
+
title=title,
|
43
|
+
removable=removable,
|
33
44
|
)
|
34
45
|
|
35
46
|
def get_template(self) -> str:
|
@@ -10,13 +10,19 @@ class DropDownWidget(Widget[str]):
|
|
10
10
|
*,
|
11
11
|
title: Optional[str],
|
12
12
|
value: Optional[str] = None,
|
13
|
+
error: str | None = None,
|
13
14
|
options: Sequence[Tuple[str, str]] | Sequence[str],
|
14
15
|
removable: bool = False,
|
15
16
|
token: Optional[str] = None,
|
16
17
|
hint: Optional[str] = None,
|
17
18
|
) -> None:
|
18
19
|
super().__init__(
|
19
|
-
name,
|
20
|
+
name,
|
21
|
+
value=value,
|
22
|
+
error=error,
|
23
|
+
title=title,
|
24
|
+
token=token,
|
25
|
+
removable=removable,
|
20
26
|
)
|
21
27
|
self.options: list[dict[str, str]] = []
|
22
28
|
for opt in options:
|