fastlifeweb 0.5.0__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.
Files changed (73) hide show
  1. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/PKG-INFO +3 -3
  2. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/pyproject.toml +8 -7
  3. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/__init__.py +3 -2
  4. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/request/form_data.py +7 -22
  5. fastlifeweb-0.6.0/src/fastlife/request/model_result.py +91 -0
  6. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/shared_utils/infer.py +1 -1
  7. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templates/pydantic_form/Checklist.jinja +1 -1
  8. fastlifeweb-0.6.0/src/fastlife/templates/pydantic_form/Error.jinja +4 -0
  9. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templates/pydantic_form/Text.jinja +1 -0
  10. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templating/renderer/abstract.py +16 -2
  11. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templating/renderer/jinjax.py +25 -4
  12. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templating/renderer/widgets/base.py +7 -5
  13. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templating/renderer/widgets/checklist.py +4 -0
  14. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templating/renderer/widgets/factory.py +57 -16
  15. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templating/renderer/widgets/text.py +3 -1
  16. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/testing/testclient.py +102 -0
  17. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/views/pydantic_form.py +6 -2
  18. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/LICENSE +0 -0
  19. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/README.md +0 -0
  20. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/configurator/__init__.py +0 -0
  21. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/configurator/base.py +0 -0
  22. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/configurator/configurator.py +0 -0
  23. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/configurator/registry.py +0 -0
  24. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/configurator/settings.py +0 -0
  25. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/py.typed +0 -0
  26. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/request/__init__.py +0 -0
  27. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/security/__init__.py +0 -0
  28. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/security/csrf.py +0 -0
  29. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/security/policy.py +0 -0
  30. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/session/__init__.py +0 -0
  31. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/session/middleware.py +0 -0
  32. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/session/serializer.py +0 -0
  33. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/shared_utils/__init__.py +0 -0
  34. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/shared_utils/resolver.py +0 -0
  35. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templates/A.jinja +0 -0
  36. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templates/Button.jinja +0 -0
  37. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templates/Checkbox.jinja +0 -0
  38. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templates/CsrfToken.jinja +0 -0
  39. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templates/Form.jinja +0 -0
  40. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templates/H1.jinja +0 -0
  41. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templates/H2.jinja +0 -0
  42. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templates/H3.jinja +0 -0
  43. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templates/H4.jinja +0 -0
  44. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templates/H5.jinja +0 -0
  45. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templates/H6.jinja +0 -0
  46. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templates/Hidden.jinja +0 -0
  47. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templates/Input.jinja +0 -0
  48. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templates/Label.jinja +0 -0
  49. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templates/Option.jinja +0 -0
  50. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templates/P.jinja +0 -0
  51. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templates/Radio.jinja +0 -0
  52. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templates/Select.jinja +0 -0
  53. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templates/__init__.py +0 -0
  54. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templates/pydantic_form/Boolean.jinja +0 -0
  55. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templates/pydantic_form/Dropdown.jinja +0 -0
  56. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templates/pydantic_form/Hidden.jinja +0 -0
  57. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templates/pydantic_form/Hint.jinja +0 -0
  58. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templates/pydantic_form/Model.jinja +0 -0
  59. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templates/pydantic_form/Sequence.jinja +0 -0
  60. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templates/pydantic_form/Union.jinja +0 -0
  61. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templates/pydantic_form/Widget.jinja +0 -0
  62. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templating/__init__.py +0 -0
  63. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templating/binding.py +0 -0
  64. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templating/renderer/__init__.py +0 -0
  65. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templating/renderer/widgets/__init__.py +0 -0
  66. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templating/renderer/widgets/boolean.py +0 -0
  67. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templating/renderer/widgets/dropdown.py +0 -0
  68. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templating/renderer/widgets/hidden.py +0 -0
  69. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templating/renderer/widgets/model.py +0 -0
  70. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templating/renderer/widgets/sequence.py +0 -0
  71. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/templating/renderer/widgets/union.py +0 -0
  72. {fastlifeweb-0.5.0 → fastlifeweb-0.6.0}/src/fastlife/testing/__init__.py +0 -0
  73. {fastlifeweb-0.5.0 → 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.5.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
@@ -21,10 +21,10 @@ Requires-Dist: beautifulsoup4[testing] (>=4.12.2,<5.0.0)
21
21
  Requires-Dist: behave (>=1.2.6,<2.0.0)
22
22
  Requires-Dist: fastapi (>=0.110.0,<0.111.0)
23
23
  Requires-Dist: itsdangerous (>=2.1.2,<3.0.0)
24
- Requires-Dist: jinjax (>=0.32,<0.33)
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 (>=2.3.0,<3.0.0)
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.5.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"
24
- jinjax = "^0.32"
25
- pydantic = "^2.3.0"
25
+ itsdangerous = "^2.1.2"
26
+ jinjax = "^0.34"
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
- from .request.form_data import model
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 in form_data_decode_list:
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)
@@ -5,7 +5,7 @@ from pydantic import BaseModel
5
5
 
6
6
 
7
7
  def is_complex_type(typ: Type[Any]) -> bool:
8
- return get_origin(typ) or issubclass(typ, BaseModel)
8
+ return bool(get_origin(typ) or issubclass(typ, BaseModel))
9
9
 
10
10
 
11
11
  def is_union(typ: Type[Any]) -> bool:
@@ -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={widget.name} type="checkbox" id={value.id} value={value.value} checked={value.checked} />
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 %}
@@ -0,0 +1,4 @@
1
+ {# def text #}
2
+ {% if text %}
3
+ <span class="mt-2 text-sm text-danger-500 dark:text-danger-400">{{text}}</span>
4
+ {% endif %}
@@ -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: Type[Any],
34
- form_data: Mapping[str, Any] | None = None,
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: Type[Any],
73
- form_data: Optional[Mapping[str, Any]] = None,
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, Optional, Type, TypeVar
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: Optional[T] = None,
38
- title: Optional[str] = None,
39
- token: Optional[str] = None,
40
- aria_label: Optional[str] = None,
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("_", "-")
@@ -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
- base: Type[Any],
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
- base, form_data, prefix=prefix, removable=removable, field=field
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(name, typ, field, value, removable)
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(name, typ, field, value, removable)
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(name, typ, field, value or {}, removable)
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(name, typ, field, value or False, removable)
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(name, typ, field, value or "", removable)
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(name, typ, field, value or "", removable)
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(name, typ, field, value or "", removable)
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], name=field_name, field=field, value=value, removable=False
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).pydantic_form(
25
- model_cls, None, name, token, removable, field
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