fastlifeweb 0.5.1__tar.gz → 0.6.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.
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/PKG-INFO +2 -2
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/pyproject.toml +7 -6
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/__init__.py +3 -2
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/request/form_data.py +7 -22
- fastlifeweb-0.6.0/src/fastlife/request/model_result.py +91 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/shared_utils/infer.py +1 -1
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templates/pydantic_form/Checklist.jinja +1 -1
- fastlifeweb-0.6.0/src/fastlife/templates/pydantic_form/Error.jinja +4 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templates/pydantic_form/Text.jinja +1 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templating/renderer/abstract.py +16 -2
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templating/renderer/jinjax.py +25 -4
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templating/renderer/widgets/base.py +7 -5
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templating/renderer/widgets/checklist.py +4 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templating/renderer/widgets/factory.py +57 -16
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templating/renderer/widgets/text.py +3 -1
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/testing/testclient.py +102 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/views/pydantic_form.py +6 -2
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/LICENSE +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/README.md +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/configurator/__init__.py +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/configurator/base.py +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/configurator/configurator.py +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/configurator/registry.py +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/configurator/settings.py +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/py.typed +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/request/__init__.py +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/security/__init__.py +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/security/csrf.py +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/security/policy.py +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/session/__init__.py +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/session/middleware.py +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/session/serializer.py +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/shared_utils/__init__.py +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/shared_utils/resolver.py +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templates/A.jinja +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templates/Button.jinja +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templates/Checkbox.jinja +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templates/CsrfToken.jinja +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templates/Form.jinja +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templates/H1.jinja +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templates/H2.jinja +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templates/H3.jinja +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templates/H4.jinja +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templates/H5.jinja +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templates/H6.jinja +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templates/Hidden.jinja +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templates/Input.jinja +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templates/Label.jinja +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templates/Option.jinja +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templates/P.jinja +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templates/Radio.jinja +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templates/Select.jinja +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templates/__init__.py +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templates/pydantic_form/Boolean.jinja +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templates/pydantic_form/Dropdown.jinja +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templates/pydantic_form/Hidden.jinja +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templates/pydantic_form/Hint.jinja +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templates/pydantic_form/Model.jinja +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templates/pydantic_form/Sequence.jinja +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templates/pydantic_form/Union.jinja +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templates/pydantic_form/Widget.jinja +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templating/__init__.py +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templating/binding.py +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templating/renderer/__init__.py +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templating/renderer/widgets/__init__.py +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templating/renderer/widgets/boolean.py +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templating/renderer/widgets/dropdown.py +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templating/renderer/widgets/hidden.py +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templating/renderer/widgets/model.py +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templating/renderer/widgets/sequence.py +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templating/renderer/widgets/union.py +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/testing/__init__.py +0 -0
- {fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/views/__init__.py +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: fastlifeweb
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.6.0
|
4
4
|
Summary: High-level web framework
|
5
5
|
Home-page: https://github.com/mardiros/fastlife
|
6
6
|
License: BSD-derived
|
@@ -24,7 +24,7 @@ Requires-Dist: itsdangerous (>=2.1.2,<3.0.0)
|
|
24
24
|
Requires-Dist: jinjax (>=0.34,<0.35)
|
25
25
|
Requires-Dist: markupsafe (>=2.1.3,<3.0.0)
|
26
26
|
Requires-Dist: multidict (>=6.0.5,<7.0.0)
|
27
|
-
Requires-Dist: pydantic (
|
27
|
+
Requires-Dist: pydantic (==2.5.1)
|
28
28
|
Requires-Dist: pydantic-settings (>=2.0.3,<3.0.0)
|
29
29
|
Requires-Dist: python-multipart (>=0.0.6,<0.0.7)
|
30
30
|
Requires-Dist: venusian (>=3.0.0,<4.0.0)
|
@@ -15,21 +15,21 @@ classifiers = [
|
|
15
15
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
16
16
|
"Topic :: Internet :: WWW/HTTP",
|
17
17
|
]
|
18
|
-
version = "0.
|
18
|
+
version = "0.6.0"
|
19
19
|
|
20
20
|
[tool.poetry.dependencies]
|
21
21
|
python = "^3.11"
|
22
22
|
beautifulsoup4 = {version = "^4.12.2", extras = ["testing"]}
|
23
|
+
behave = "^1.2.6"
|
23
24
|
fastapi = "^0.110.0"
|
25
|
+
itsdangerous = "^2.1.2"
|
24
26
|
jinjax = "^0.34"
|
25
|
-
|
27
|
+
markupsafe = "^2.1.3"
|
28
|
+
multidict = "^6.0.5"
|
29
|
+
pydantic = "2.5.1"
|
26
30
|
pydantic-settings = "^2.0.3"
|
27
31
|
python-multipart = "^0.0.6"
|
28
32
|
venusian = "^3.0.0"
|
29
|
-
markupsafe = "^2.1.3"
|
30
|
-
behave = "^1.2.6"
|
31
|
-
itsdangerous = "^2.1.2"
|
32
|
-
multidict = "^6.0.5"
|
33
33
|
|
34
34
|
[tool.poetry.group.dev.dependencies]
|
35
35
|
beautifulsoup4 = "^4.12.2"
|
@@ -67,6 +67,7 @@ filterwarnings = [
|
|
67
67
|
'ignore:.*Deprecated call to `pkg_resources.declare_namespace:DeprecationWarning', # venusian
|
68
68
|
'ignore:.*find_module\(\) is deprecated.*:DeprecationWarning',
|
69
69
|
'ignore:.*FileFinder.find_loader\(\) is deprecated.*:DeprecationWarning',
|
70
|
+
'ignore:.*Pydantic serializer warnings.*:UserWarning',
|
70
71
|
]
|
71
72
|
testpaths = ["tests"]
|
72
73
|
|
@@ -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
|
]
|
@@ -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)
|
{fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templates/pydantic_form/Checklist.jinja
RENAMED
@@ -11,7 +11,7 @@ widget,
|
|
11
11
|
<div>
|
12
12
|
{% for value in widget.value %}
|
13
13
|
<div class="flex items-center mb-4">
|
14
|
-
<Checkbox name={
|
14
|
+
<Checkbox name={value.field_name} type="checkbox" id={value.id} value={value.value} checked={value.checked} />
|
15
15
|
<Label for={value.id} class="ms-2 text-base text-neutral-900 dark:text-white">{{value.label}}</Label>
|
16
16
|
</div>
|
17
17
|
{% endfor %}
|
@@ -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,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("_", "-")
|
{fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templating/renderer/widgets/checklist.py
RENAMED
@@ -17,6 +17,10 @@ class Checkable(BaseModel):
|
|
17
17
|
id = f"{self.name}-{self.value}-{self.token}"
|
18
18
|
return id.replace(".", "-").replace("_", "-")
|
19
19
|
|
20
|
+
@property
|
21
|
+
def field_name(self) -> str:
|
22
|
+
return f"{self.name}[]"
|
23
|
+
|
20
24
|
|
21
25
|
class ChecklistWidget(Widget[Sequence[Checkable]]):
|
22
26
|
def __init__(
|
@@ -2,6 +2,7 @@ import secrets
|
|
2
2
|
from collections.abc import MutableSequence, Sequence
|
3
3
|
from decimal import Decimal
|
4
4
|
from enum import Enum
|
5
|
+
from inspect import isclass
|
5
6
|
from types import NoneType
|
6
7
|
from typing import Any, Literal, Mapping, Optional, Type, cast, get_origin
|
7
8
|
from uuid import UUID
|
@@ -10,6 +11,7 @@ from markupsafe import Markup
|
|
10
11
|
from pydantic import BaseModel, EmailStr, SecretStr, ValidationError
|
11
12
|
from pydantic.fields import FieldInfo
|
12
13
|
|
14
|
+
from fastlife.request.model_result import ModelResult
|
13
15
|
from fastlife.shared_utils.infer import is_complex_type, is_union
|
14
16
|
from fastlife.templating.renderer.abstract import AbstractTemplateRenderer
|
15
17
|
from fastlife.templating.renderer.widgets.boolean import BooleanWidget
|
@@ -31,21 +33,25 @@ class WidgetFactory:
|
|
31
33
|
|
32
34
|
def get_markup(
|
33
35
|
self,
|
34
|
-
|
35
|
-
form_data: Mapping[str, Any],
|
36
|
+
model: ModelResult[Any],
|
36
37
|
*,
|
37
|
-
prefix: str,
|
38
38
|
removable: bool,
|
39
39
|
field: FieldInfo | None = None,
|
40
40
|
) -> Markup:
|
41
41
|
return self.get_widget(
|
42
|
-
|
42
|
+
model.model.__class__,
|
43
|
+
model.form_data,
|
44
|
+
model.errors,
|
45
|
+
prefix=model.prefix,
|
46
|
+
removable=removable,
|
47
|
+
field=field,
|
43
48
|
).to_html(self.renderer)
|
44
49
|
|
45
50
|
def get_widget(
|
46
51
|
self,
|
47
52
|
base: Type[Any],
|
48
53
|
form_data: Mapping[str, Any],
|
54
|
+
form_errors: Mapping[str, Any],
|
49
55
|
*,
|
50
56
|
prefix: str,
|
51
57
|
removable: bool,
|
@@ -54,6 +60,7 @@ class WidgetFactory:
|
|
54
60
|
return self.build(
|
55
61
|
base,
|
56
62
|
value=form_data.get(prefix, {}),
|
63
|
+
form_errors=form_errors,
|
57
64
|
name=prefix,
|
58
65
|
removable=removable,
|
59
66
|
field=field,
|
@@ -66,11 +73,12 @@ class WidgetFactory:
|
|
66
73
|
name: str = "",
|
67
74
|
value: Any,
|
68
75
|
removable: bool,
|
76
|
+
form_errors: Mapping[str, Any],
|
69
77
|
field: FieldInfo | None = None,
|
70
78
|
) -> Widget[Any]:
|
71
79
|
if field and field.metadata:
|
72
80
|
for widget in field.metadata:
|
73
|
-
if issubclass(widget, Widget):
|
81
|
+
if isclass(widget) and issubclass(widget, Widget):
|
74
82
|
return cast(
|
75
83
|
Widget[Any],
|
76
84
|
widget(
|
@@ -86,38 +94,52 @@ class WidgetFactory:
|
|
86
94
|
type_origin = get_origin(typ)
|
87
95
|
if type_origin:
|
88
96
|
if is_union(typ):
|
89
|
-
return self.build_union(name, typ, field, value, removable)
|
97
|
+
return self.build_union(name, typ, field, value, form_errors, removable)
|
90
98
|
|
91
99
|
if (
|
92
100
|
type_origin is Sequence
|
93
101
|
or type_origin is MutableSequence
|
94
102
|
or type_origin is list
|
95
103
|
):
|
96
|
-
return self.build_sequence(
|
104
|
+
return self.build_sequence(
|
105
|
+
name, typ, field, value, form_errors, removable
|
106
|
+
)
|
97
107
|
|
98
108
|
if type_origin is Literal:
|
99
|
-
return self.build_literal(
|
109
|
+
return self.build_literal(
|
110
|
+
name, typ, field, value, form_errors, removable
|
111
|
+
)
|
100
112
|
|
101
113
|
if type_origin is set:
|
102
|
-
return self.build_set(name, typ, field, value, removable)
|
114
|
+
return self.build_set(name, typ, field, value, form_errors, removable)
|
103
115
|
|
104
116
|
if issubclass(typ, Enum): # if it raises here, the type_origin is unknown
|
105
|
-
return self.build_enum(name, typ, field, value, removable)
|
117
|
+
return self.build_enum(name, typ, field, value, form_errors, removable)
|
106
118
|
|
107
119
|
if issubclass(typ, BaseModel): # if it raises here, the type_origin is unknown
|
108
|
-
return self.build_model(
|
120
|
+
return self.build_model(
|
121
|
+
name, typ, field, value or {}, form_errors, removable
|
122
|
+
)
|
109
123
|
|
110
124
|
if issubclass(typ, bool):
|
111
|
-
return self.build_boolean(
|
125
|
+
return self.build_boolean(
|
126
|
+
name, typ, field, value or False, form_errors, removable
|
127
|
+
)
|
112
128
|
|
113
129
|
if issubclass(typ, EmailStr): # type: ignore
|
114
|
-
return self.build_emailtype(
|
130
|
+
return self.build_emailtype(
|
131
|
+
name, typ, field, value or "", form_errors, removable
|
132
|
+
)
|
115
133
|
|
116
134
|
if issubclass(typ, SecretStr):
|
117
|
-
return self.build_secretstr(
|
135
|
+
return self.build_secretstr(
|
136
|
+
name, typ, field, value or "", form_errors, removable
|
137
|
+
)
|
118
138
|
|
119
139
|
if issubclass(typ, (int, str, float, Decimal, UUID)):
|
120
|
-
return self.build_simpletype(
|
140
|
+
return self.build_simpletype(
|
141
|
+
name, typ, field, value or "", form_errors, removable
|
142
|
+
)
|
121
143
|
|
122
144
|
raise NotImplementedError(f"{typ} not implemented") # coverage: ignore
|
123
145
|
|
@@ -127,6 +149,7 @@ class WidgetFactory:
|
|
127
149
|
typ: Type[BaseModel],
|
128
150
|
field: Optional[FieldInfo],
|
129
151
|
value: Mapping[str, Any],
|
152
|
+
form_errors: Mapping[str, Any],
|
130
153
|
removable: bool,
|
131
154
|
) -> Widget[Any]:
|
132
155
|
ret: dict[str, Any] = {}
|
@@ -143,6 +166,7 @@ class WidgetFactory:
|
|
143
166
|
name=child_key,
|
144
167
|
field=field,
|
145
168
|
value=value.get(key),
|
169
|
+
form_errors=form_errors,
|
146
170
|
removable=False,
|
147
171
|
)
|
148
172
|
return ModelWidget(
|
@@ -159,6 +183,7 @@ class WidgetFactory:
|
|
159
183
|
field_type: Type[Any],
|
160
184
|
field: Optional[FieldInfo],
|
161
185
|
value: Any,
|
186
|
+
form_errors: Mapping[str, Any],
|
162
187
|
removable: bool,
|
163
188
|
) -> Widget[Any]:
|
164
189
|
types: list[Type[Any]] = []
|
@@ -176,7 +201,12 @@ class WidgetFactory:
|
|
176
201
|
and not is_complex_type(types[0])
|
177
202
|
):
|
178
203
|
return self.build(
|
179
|
-
types[0],
|
204
|
+
types[0],
|
205
|
+
name=field_name,
|
206
|
+
field=field,
|
207
|
+
value=value,
|
208
|
+
form_errors=form_errors,
|
209
|
+
removable=False,
|
180
210
|
)
|
181
211
|
child = None
|
182
212
|
if value:
|
@@ -191,6 +221,7 @@ class WidgetFactory:
|
|
191
221
|
name=field_name,
|
192
222
|
field=field,
|
193
223
|
value=value,
|
224
|
+
form_errors=form_errors,
|
194
225
|
removable=False,
|
195
226
|
)
|
196
227
|
|
@@ -212,6 +243,7 @@ class WidgetFactory:
|
|
212
243
|
field_type: Type[Any],
|
213
244
|
field: Optional[FieldInfo],
|
214
245
|
value: Optional[Sequence[Any]],
|
246
|
+
form_errors: Mapping[str, Any],
|
215
247
|
removable: bool,
|
216
248
|
) -> Widget[Any]:
|
217
249
|
typ = field_type.__args__[0] # type: ignore
|
@@ -222,6 +254,7 @@ class WidgetFactory:
|
|
222
254
|
name=f"{field_name}.{idx}",
|
223
255
|
value=v,
|
224
256
|
field=field,
|
257
|
+
form_errors=form_errors,
|
225
258
|
removable=True,
|
226
259
|
)
|
227
260
|
for idx, v in enumerate(value)
|
@@ -242,6 +275,7 @@ class WidgetFactory:
|
|
242
275
|
field_type: Type[Any],
|
243
276
|
field: Optional[FieldInfo],
|
244
277
|
value: Optional[Sequence[Any]],
|
278
|
+
form_errors: Mapping[str, Any],
|
245
279
|
removable: bool,
|
246
280
|
) -> Widget[Any]:
|
247
281
|
choice_wrapper = field_type.__args__[0]
|
@@ -291,6 +325,7 @@ class WidgetFactory:
|
|
291
325
|
field_type: Type[Any],
|
292
326
|
field: FieldInfo | None,
|
293
327
|
value: bool,
|
328
|
+
form_errors: Mapping[str, Any],
|
294
329
|
removable: bool,
|
295
330
|
) -> Widget[Any]:
|
296
331
|
return BooleanWidget(
|
@@ -307,6 +342,7 @@ class WidgetFactory:
|
|
307
342
|
field_type: Type[Any],
|
308
343
|
field: FieldInfo | None,
|
309
344
|
value: str | int | float,
|
345
|
+
form_errors: Mapping[str, Any],
|
310
346
|
removable: bool,
|
311
347
|
) -> Widget[Any]:
|
312
348
|
return TextWidget(
|
@@ -326,6 +362,7 @@ class WidgetFactory:
|
|
326
362
|
field_type: Type[Any],
|
327
363
|
field: FieldInfo | None,
|
328
364
|
value: SecretStr | str,
|
365
|
+
form_errors: Mapping[str, Any],
|
329
366
|
removable: bool,
|
330
367
|
) -> Widget[Any]:
|
331
368
|
return TextWidget(
|
@@ -345,6 +382,7 @@ class WidgetFactory:
|
|
345
382
|
field_type: Type[Any], # a literal actually
|
346
383
|
field: FieldInfo | None,
|
347
384
|
value: str | int | float,
|
385
|
+
form_errors: Mapping[str, Any],
|
348
386
|
removable: bool,
|
349
387
|
) -> Widget[Any]:
|
350
388
|
choices: list[str] = field_type.__args__ # type: ignore
|
@@ -369,6 +407,7 @@ class WidgetFactory:
|
|
369
407
|
field_type: Type[Any], # an enum subclass
|
370
408
|
field: FieldInfo | None,
|
371
409
|
value: str | int | float,
|
410
|
+
form_errors: Mapping[str, Any],
|
372
411
|
removable: bool,
|
373
412
|
) -> Widget[Any]:
|
374
413
|
options = [(item.name, item.value) for item in field_type] # type: ignore
|
@@ -387,6 +426,7 @@ class WidgetFactory:
|
|
387
426
|
field_type: Type[Any],
|
388
427
|
field: FieldInfo | None,
|
389
428
|
value: str | int | float,
|
429
|
+
form_errors: Mapping[str, Any],
|
390
430
|
removable: bool,
|
391
431
|
) -> Widget[Any]:
|
392
432
|
return TextWidget(
|
@@ -398,4 +438,5 @@ class WidgetFactory:
|
|
398
438
|
title=field.title if field else "",
|
399
439
|
token=self.token,
|
400
440
|
value=str(value),
|
441
|
+
error=form_errors.get(field_name),
|
401
442
|
)
|
@@ -11,11 +11,12 @@ class TextWidget(Widget[str]):
|
|
11
11
|
title: Optional[str],
|
12
12
|
aria_label: Optional[str] = None,
|
13
13
|
placeholder: Optional[str] = None,
|
14
|
+
error: str | None = None,
|
14
15
|
removable: bool = False,
|
15
16
|
value: str = "",
|
16
17
|
token: Optional[str] = None,
|
17
18
|
hint: Optional[str] = None,
|
18
|
-
input_type: str = "text"
|
19
|
+
input_type: str = "text",
|
19
20
|
) -> None:
|
20
21
|
super().__init__(
|
21
22
|
name,
|
@@ -23,6 +24,7 @@ class TextWidget(Widget[str]):
|
|
23
24
|
title=title,
|
24
25
|
aria_label=aria_label,
|
25
26
|
token=token,
|
27
|
+
error=error,
|
26
28
|
removable=removable,
|
27
29
|
)
|
28
30
|
self.placeholder = placeholder or ""
|
@@ -1,3 +1,5 @@
|
|
1
|
+
"""Testing your application."""
|
2
|
+
|
1
3
|
import re
|
2
4
|
import time
|
3
5
|
from collections.abc import MutableMapping
|
@@ -20,41 +22,67 @@ Cookies = httpx._models.Cookies # type: ignore
|
|
20
22
|
|
21
23
|
|
22
24
|
class Element:
|
25
|
+
"""Access to a dom element."""
|
26
|
+
|
23
27
|
def __init__(self, client: "WebTestClient", tag: bs4.Tag):
|
24
28
|
self._client = client
|
25
29
|
self._tag = tag
|
26
30
|
|
27
31
|
def click(self) -> "WebResponse":
|
32
|
+
"""Simulate a client to a a link. No javascript exectuted here."""
|
28
33
|
return self._client.get(self._tag.attrs["href"])
|
29
34
|
|
30
35
|
@property
|
31
36
|
def node_name(self) -> str:
|
37
|
+
"""Get the node name of the dom element."""
|
32
38
|
return self._tag.name
|
33
39
|
|
34
40
|
@property
|
35
41
|
def attrs(self) -> dict[str, str]:
|
42
|
+
"""Attributes of the element."""
|
36
43
|
return self._tag.attrs
|
37
44
|
|
38
45
|
@property
|
39
46
|
def text(self) -> str:
|
47
|
+
"""
|
48
|
+
Return the text of the element, with text of childs element.
|
49
|
+
|
50
|
+
Note that the text is stripped for convenience but inner text may contains
|
51
|
+
many spaces not manipulated here.
|
52
|
+
"""
|
40
53
|
return self._tag.text.strip()
|
41
54
|
|
42
55
|
@property
|
43
56
|
def h1(self) -> "Element":
|
57
|
+
"""
|
58
|
+
Return the h1 child element.
|
59
|
+
|
60
|
+
Should be used on the html body element directly.
|
61
|
+
"""
|
44
62
|
nodes = self.by_node_name("h1")
|
45
63
|
assert len(nodes) == 1, f"Should have 1 <h1>, got {len(nodes)} in {self}"
|
46
64
|
return nodes[0]
|
47
65
|
|
48
66
|
@property
|
49
67
|
def h2(self) -> Sequence["Element"]:
|
68
|
+
"""
|
69
|
+
Return the h2 elements.
|
70
|
+
"""
|
50
71
|
return self.by_node_name("h2")
|
51
72
|
|
52
73
|
@property
|
53
74
|
def form(self) -> "Element | None":
|
75
|
+
"""Get the form element of the web page."""
|
54
76
|
return Element(self._client, self._tag.form) if self._tag.form else None
|
55
77
|
|
56
78
|
@property
|
57
79
|
def hx_target(self) -> Optional[str]:
|
80
|
+
"""
|
81
|
+
Return the hx-target of the element.
|
82
|
+
|
83
|
+
It may be set on a parent. It also resolve special case "this" and return the id
|
84
|
+
of the element.
|
85
|
+
"""
|
58
86
|
el: bs4.Tag | None = self._tag
|
59
87
|
while el:
|
60
88
|
if "hx-target" in el.attrs:
|
@@ -66,12 +94,14 @@ class Element:
|
|
66
94
|
return None
|
67
95
|
|
68
96
|
def by_text(self, text: str, *, node_name: str | None = None) -> "Element | None":
|
97
|
+
"""Find the first element that match the text."""
|
69
98
|
nodes = self.iter_all_by_text(text, node_name=node_name)
|
70
99
|
return next(nodes, None)
|
71
100
|
|
72
101
|
def iter_all_by_text(
|
73
102
|
self, text: str, *, node_name: str | None = None
|
74
103
|
) -> "Iterator[Element]":
|
104
|
+
"""Return an iterator of all elements that match the text."""
|
75
105
|
nodes = self._tag.find_all(string=re.compile(rf"\s*{text}\s*"))
|
76
106
|
for node in nodes:
|
77
107
|
if isinstance(node, bs4.NavigableString):
|
@@ -89,10 +119,12 @@ class Element:
|
|
89
119
|
def get_all_by_text(
|
90
120
|
self, text: str, *, node_name: str | None = None
|
91
121
|
) -> "Sequence[Element]":
|
122
|
+
"""Return the list of all elements that match the text."""
|
92
123
|
nodes = self.iter_all_by_text(text, node_name=node_name)
|
93
124
|
return list(nodes)
|
94
125
|
|
95
126
|
def by_label_text(self, text: str) -> "Element | None":
|
127
|
+
"""Return the element which is the target of the label having the given text."""
|
96
128
|
label = self.by_text(text, node_name="label")
|
97
129
|
assert label is not None
|
98
130
|
assert label.attrs.get("for") is not None
|
@@ -103,6 +135,11 @@ class Element:
|
|
103
135
|
def by_node_name(
|
104
136
|
self, node_name: str, *, attrs: dict[str, str] | None = None
|
105
137
|
) -> list["Element"]:
|
138
|
+
"""
|
139
|
+
Return the list of elements with the given node_name.
|
140
|
+
|
141
|
+
An optional set of attributes may given and must match if passed.
|
142
|
+
"""
|
106
143
|
return [
|
107
144
|
Element(self._client, e) for e in self._tag.find_all(node_name, attrs or {})
|
108
145
|
]
|
@@ -115,6 +152,15 @@ class Element:
|
|
115
152
|
|
116
153
|
|
117
154
|
class WebForm:
|
155
|
+
"""
|
156
|
+
Handle form.
|
157
|
+
|
158
|
+
Form are filled out and submit with methods and try to avoid invalid
|
159
|
+
usage, such as selecting an option that don't exists is not possible here.
|
160
|
+
Again, no javascript is executed here, but htmx attribute `hx-post` and `hx-target`
|
161
|
+
are read while submiting to simulate it.
|
162
|
+
"""
|
163
|
+
|
118
164
|
def __init__(self, client: "WebTestClient", origin: str, form: Element):
|
119
165
|
self._client = client
|
120
166
|
self._form = form
|
@@ -154,6 +200,14 @@ class WebForm:
|
|
154
200
|
# field textearea...
|
155
201
|
|
156
202
|
def set(self, fieldname: str, value: str) -> Any:
|
203
|
+
"""
|
204
|
+
Set a value to an input field.
|
205
|
+
|
206
|
+
It works for checkbox and radio as well.
|
207
|
+
Checkbox may contains many values.
|
208
|
+
Options of select can't be set with this method, the select method must
|
209
|
+
be used instead.
|
210
|
+
"""
|
157
211
|
if fieldname not in self._formfields:
|
158
212
|
raise ValueError(f'"{fieldname}" does not exists')
|
159
213
|
if self._formfields[fieldname].node_name == "select":
|
@@ -175,6 +229,7 @@ class WebForm:
|
|
175
229
|
self._formdata[fieldname] = value
|
176
230
|
|
177
231
|
def unset(self, fieldname: str, value: str) -> Any:
|
232
|
+
"""Unset an element. Only works with checkbox."""
|
178
233
|
if fieldname not in self._formfields:
|
179
234
|
raise ValueError(f'"{fieldname}" does not exists')
|
180
235
|
if self._formfields[fieldname].node_name != "input":
|
@@ -189,6 +244,9 @@ class WebForm:
|
|
189
244
|
self._formdata[fieldname] = val
|
190
245
|
|
191
246
|
def select(self, fieldname: str, value: str) -> Any:
|
247
|
+
"""
|
248
|
+
Select an option, if multiple, value is added, otherwise, value is replaced.
|
249
|
+
"""
|
192
250
|
if fieldname not in self._formfields:
|
193
251
|
raise ValueError(f'"{fieldname}" does not exists')
|
194
252
|
field = self._formfields[fieldname]
|
@@ -206,6 +264,9 @@ class WebForm:
|
|
206
264
|
raise ValueError(f'No option {value} in <select name="{fieldname}">')
|
207
265
|
|
208
266
|
def unselect(self, fieldname: str, value: str) -> Any:
|
267
|
+
"""
|
268
|
+
Unselect an option if multiple, otherwise an exception is raised.
|
269
|
+
"""
|
209
270
|
if fieldname not in self._formfields:
|
210
271
|
raise ValueError(f'"{fieldname}" does not exists')
|
211
272
|
field = self._formfields[fieldname]
|
@@ -231,6 +292,20 @@ class WebForm:
|
|
231
292
|
raise ValueError(f'No option {value} in <select name="{fieldname}">')
|
232
293
|
|
233
294
|
def button(self, text: str, position: int = 0) -> "WebForm":
|
295
|
+
"""
|
296
|
+
Simmulate a click on a button using the text of the button,
|
297
|
+
|
298
|
+
and eventually a position. The button return the form and the submit()
|
299
|
+
should be called directly.
|
300
|
+
|
301
|
+
This is used in order to inject the value of the button in the form, usually
|
302
|
+
done while many actions are available on a form.
|
303
|
+
|
304
|
+
::
|
305
|
+
|
306
|
+
form.button("Go").submit()
|
307
|
+
|
308
|
+
"""
|
234
309
|
buttons = self._form.get_all_by_text(text, node_name="button")
|
235
310
|
if position >= len(buttons):
|
236
311
|
pos = ""
|
@@ -243,6 +318,9 @@ class WebForm:
|
|
243
318
|
return self
|
244
319
|
|
245
320
|
def submit(self, follow_redirects: bool = True) -> "WebResponse":
|
321
|
+
"""
|
322
|
+
Submit the form as it has been previously filled out.
|
323
|
+
"""
|
246
324
|
headers: dict[str, str] = {}
|
247
325
|
target = (
|
248
326
|
self._form.attrs.get("hx-post")
|
@@ -261,10 +339,13 @@ class WebForm:
|
|
261
339
|
)
|
262
340
|
|
263
341
|
def __contains__(self, key: str) -> bool:
|
342
|
+
"""Test if a field exists in the form."""
|
264
343
|
return key in self._formdata
|
265
344
|
|
266
345
|
|
267
346
|
class WebResponse:
|
347
|
+
"""Represent an http response made by the WebTestClient browser."""
|
348
|
+
|
268
349
|
def __init__(self, client: "WebTestClient", origin: str, response: httpx.Response):
|
269
350
|
self._client = client
|
270
351
|
self._response = response
|
@@ -274,38 +355,46 @@ class WebResponse:
|
|
274
355
|
|
275
356
|
@property
|
276
357
|
def status_code(self) -> int:
|
358
|
+
"""Http status code."""
|
277
359
|
return self._response.status_code
|
278
360
|
|
279
361
|
@property
|
280
362
|
def is_redirect(self) -> bool:
|
363
|
+
"""True for any kind of http redirect status."""
|
281
364
|
return 300 <= self._response.status_code < 400
|
282
365
|
|
283
366
|
@property
|
284
367
|
def content_type(self) -> str:
|
368
|
+
"""Get the content type of the response, from the header."""
|
285
369
|
return self._response.headers.get("content-type", "").split(";").pop(0)
|
286
370
|
|
287
371
|
@property
|
288
372
|
def headers(self) -> httpx.Headers:
|
373
|
+
"""All http headers of the response."""
|
289
374
|
return self._response.headers
|
290
375
|
|
291
376
|
@property
|
292
377
|
def text(self) -> str:
|
378
|
+
"""Http response body."""
|
293
379
|
return self._response.text
|
294
380
|
|
295
381
|
@property
|
296
382
|
def html(self) -> Element:
|
383
|
+
"""Http response body as an Element."""
|
297
384
|
if self._html is None:
|
298
385
|
self._html = bs4.BeautifulSoup(self._response.text, "html.parser")
|
299
386
|
return Element(self._client, self._html)
|
300
387
|
|
301
388
|
@property
|
302
389
|
def html_body(self) -> Element:
|
390
|
+
"""The body element of the html response."""
|
303
391
|
body = self.html.by_node_name("body")
|
304
392
|
assert len(body) == 1
|
305
393
|
return body[0]
|
306
394
|
|
307
395
|
@property
|
308
396
|
def form(self) -> WebForm:
|
397
|
+
"""The form element of the html response."""
|
309
398
|
if self._form is None:
|
310
399
|
form = self.html.form
|
311
400
|
assert form is not None
|
@@ -313,18 +402,23 @@ class WebResponse:
|
|
313
402
|
return self._form
|
314
403
|
|
315
404
|
def by_text(self, text: str, *, node_name: str | None = None) -> Element | None:
|
405
|
+
"""Search a dom element by its text."""
|
316
406
|
return self.html.by_text(text, node_name=node_name)
|
317
407
|
|
318
408
|
def by_label_text(self, text: str) -> Element | None:
|
409
|
+
"""Search a dom element by its associated label text."""
|
319
410
|
return self.html.by_label_text(text)
|
320
411
|
|
321
412
|
def by_node_name(
|
322
413
|
self, node_name: str, *, attrs: dict[str, str] | None = None
|
323
414
|
) -> list[Element]:
|
415
|
+
"""List dom element having the given node name, and eventually attributes."""
|
324
416
|
return self.html.by_node_name(node_name, attrs=attrs)
|
325
417
|
|
326
418
|
|
327
419
|
class Session(dict[str, Any]):
|
420
|
+
"""Manipulate the session of the WebTestClient browser."""
|
421
|
+
|
328
422
|
def __init__(self, client: "WebTestClient"):
|
329
423
|
self.client = client
|
330
424
|
self.srlz = client.session_serializer
|
@@ -343,6 +437,7 @@ class Session(dict[str, Any]):
|
|
343
437
|
super().__init__(data)
|
344
438
|
|
345
439
|
def __setitem__(self, __key: Any, __value: Any) -> None:
|
440
|
+
"""Initialize a value in the session of the client in order to test."""
|
346
441
|
super().__setitem__(__key, __value)
|
347
442
|
settings = self.settings
|
348
443
|
data = self.serialize()
|
@@ -380,6 +475,8 @@ class Session(dict[str, Any]):
|
|
380
475
|
|
381
476
|
|
382
477
|
class WebTestClient:
|
478
|
+
"""The fake browser used for testing purpose."""
|
479
|
+
|
383
480
|
def __init__(
|
384
481
|
self,
|
385
482
|
app: ASGIApp,
|
@@ -404,10 +501,12 @@ class WebTestClient:
|
|
404
501
|
|
405
502
|
@property
|
406
503
|
def cookies(self) -> Cookies:
|
504
|
+
"""HTTP Cookies"""
|
407
505
|
return self.testclient.cookies
|
408
506
|
|
409
507
|
@property
|
410
508
|
def session(self) -> MutableMapping[str, Any]:
|
509
|
+
"""Session shared between the server and the client."""
|
411
510
|
return Session(self)
|
412
511
|
|
413
512
|
def request(
|
@@ -419,6 +518,7 @@ class WebTestClient:
|
|
419
518
|
headers: Mapping[str, str] | None = None,
|
420
519
|
max_redirects: int = 0,
|
421
520
|
) -> WebResponse:
|
521
|
+
"""Perform http requests."""
|
422
522
|
rawresp = self.testclient.request(
|
423
523
|
method=method,
|
424
524
|
url=url,
|
@@ -455,6 +555,7 @@ class WebTestClient:
|
|
455
555
|
return resp
|
456
556
|
|
457
557
|
def get(self, url: str, follow_redirects: bool = True) -> WebResponse:
|
558
|
+
"""Perform http GET request."""
|
458
559
|
return self.request(
|
459
560
|
"GET",
|
460
561
|
url,
|
@@ -469,6 +570,7 @@ class WebTestClient:
|
|
469
570
|
headers: Mapping[str, Any] | None = None,
|
470
571
|
follow_redirects: bool = True,
|
471
572
|
) -> WebResponse:
|
573
|
+
"""Perform http POST request in "application/x-www-form-urlencoded" format."""
|
472
574
|
if headers is None:
|
473
575
|
headers = {}
|
474
576
|
return self.request(
|
@@ -21,8 +21,12 @@ async def show_widget(
|
|
21
21
|
field = None
|
22
22
|
if title:
|
23
23
|
field = FieldInfo(title=title)
|
24
|
-
data = reg.renderer(request).
|
25
|
-
model_cls,
|
24
|
+
data = reg.renderer(request).pydantic_form_field(
|
25
|
+
model=model_cls,
|
26
|
+
name=name,
|
27
|
+
token=token,
|
28
|
+
removable=removable,
|
29
|
+
field=field,
|
26
30
|
)
|
27
31
|
return Response(data, headers={"Content-Type": "text/html"})
|
28
32
|
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
{fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templating/renderer/widgets/__init__.py
RENAMED
File without changes
|
File without changes
|
{fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templating/renderer/widgets/dropdown.py
RENAMED
File without changes
|
File without changes
|
File without changes
|
{fastlifeweb-0.5.1 → fastlifeweb-0.6.0}/src/fastlife/templating/renderer/widgets/sequence.py
RENAMED
File without changes
|
File without changes
|
File without changes
|
File without changes
|