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.
Files changed (29) hide show
  1. fastlife/__init__.py +3 -2
  2. fastlife/request/form_data.py +7 -22
  3. fastlife/request/model_result.py +91 -0
  4. fastlife/shared_utils/infer.py +1 -1
  5. fastlife/templates/pydantic_form/Boolean.jinja +7 -2
  6. fastlife/templates/pydantic_form/Checklist.jinja +3 -1
  7. fastlife/templates/pydantic_form/Dropdown.jinja +1 -0
  8. fastlife/templates/pydantic_form/Error.jinja +4 -0
  9. fastlife/templates/pydantic_form/Model.jinja +1 -0
  10. fastlife/templates/pydantic_form/Sequence.jinja +1 -0
  11. fastlife/templates/pydantic_form/Text.jinja +1 -0
  12. fastlife/templates/pydantic_form/Union.jinja +4 -4
  13. fastlife/templating/renderer/abstract.py +16 -2
  14. fastlife/templating/renderer/jinjax.py +25 -4
  15. fastlife/templating/renderer/widgets/base.py +7 -5
  16. fastlife/templating/renderer/widgets/boolean.py +7 -1
  17. fastlife/templating/renderer/widgets/checklist.py +13 -2
  18. fastlife/templating/renderer/widgets/dropdown.py +7 -1
  19. fastlife/templating/renderer/widgets/factory.py +69 -16
  20. fastlife/templating/renderer/widgets/model.py +7 -1
  21. fastlife/templating/renderer/widgets/sequence.py +7 -1
  22. fastlife/templating/renderer/widgets/text.py +3 -1
  23. fastlife/templating/renderer/widgets/union.py +7 -1
  24. fastlife/testing/testclient.py +102 -0
  25. fastlife/views/pydantic_form.py +6 -2
  26. {fastlifeweb-0.5.1.dist-info → fastlifeweb-0.6.1.dist-info}/METADATA +2 -2
  27. {fastlifeweb-0.5.1.dist-info → fastlifeweb-0.6.1.dist-info}/RECORD +29 -27
  28. {fastlifeweb-0.5.1.dist-info → fastlifeweb-0.6.1.dist-info}/LICENSE +0 -0
  29. {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
- 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:
@@ -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
- <Label for={widget.id}>{{widget.title}}</Label>
5
- <Checkbox name={widget.name} type="checkbox" id={widget.id} checked={widget.value} />
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={widget.name} type="checkbox" id={value.id} value={value.value} checked={value.checked} />
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>
@@ -10,6 +10,7 @@
10
10
  </Option>
11
11
  {%- endfor -%}
12
12
  </Select>
13
+ <pydantic_form.Error text={widget.error} />
13
14
  <pydantic_form.Hint text={widget.hint} />
14
15
  </div>
15
16
  </pydantic_form.Widget>
@@ -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 %}
@@ -5,6 +5,7 @@
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>
10
11
  {% for child in children_widget %}
@@ -4,6 +4,7 @@
4
4
  <details id="{{widget.id}}" open>
5
5
  <summary class="justify-between items-center font-medium cursor-pointer">
6
6
  <H3>{{widget.title}}</H3>
7
+ <pydantic_form.Error text={widget.error} />
7
8
  </summary>
8
9
  <div>
9
10
  {% set fnGetName = "get" + widget.id.replace("-", "_") %}
@@ -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} hx-vals={parent_type.params|tojson}
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: 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("_", "-")
@@ -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, title=title, value=value, removable=removable, token=token
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, value=value, token=token, title=title, removable=removable
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, value=value, title=title, token=token, removable=removable
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: