fastlifeweb 0.20.1__py3-none-any.whl → 0.22.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. CHANGELOG.md +18 -1
  2. fastlife/__init__.py +45 -13
  3. fastlife/adapters/__init__.py +1 -1
  4. fastlife/adapters/fastapi/__init__.py +9 -0
  5. fastlife/adapters/fastapi/form.py +26 -0
  6. fastlife/{request → adapters/fastapi}/form_data.py +1 -1
  7. fastlife/{request → adapters/fastapi}/localizer.py +4 -2
  8. fastlife/adapters/fastapi/request.py +33 -0
  9. fastlife/{routing → adapters/fastapi/routing}/route.py +3 -3
  10. fastlife/{routing → adapters/fastapi/routing}/router.py +1 -1
  11. fastlife/adapters/itsdangerous/__init__.py +3 -0
  12. fastlife/adapters/itsdangerous/session.py +50 -0
  13. fastlife/adapters/jinjax/jinjax_ext/inspectable_component.py +7 -7
  14. fastlife/adapters/jinjax/jinjax_ext/jinjax_doc.py +1 -1
  15. fastlife/adapters/jinjax/renderer.py +9 -57
  16. fastlife/adapters/jinjax/widget_factory/bool_builder.py +2 -2
  17. fastlife/adapters/jinjax/widget_factory/emailstr_builder.py +5 -4
  18. fastlife/adapters/jinjax/widget_factory/enum_builder.py +2 -2
  19. fastlife/adapters/jinjax/widget_factory/factory.py +32 -23
  20. fastlife/adapters/jinjax/widget_factory/literal_builder.py +7 -6
  21. fastlife/adapters/jinjax/widget_factory/model_builder.py +3 -3
  22. fastlife/adapters/jinjax/widget_factory/secretstr_builder.py +2 -2
  23. fastlife/adapters/jinjax/widget_factory/sequence_builder.py +3 -3
  24. fastlife/adapters/jinjax/widget_factory/set_builder.py +2 -2
  25. fastlife/adapters/jinjax/widget_factory/simpletype_builder.py +7 -8
  26. fastlife/adapters/jinjax/widget_factory/union_builder.py +3 -3
  27. fastlife/adapters/jinjax/widgets/base.py +36 -36
  28. fastlife/adapters/jinjax/widgets/boolean.py +13 -34
  29. fastlife/adapters/jinjax/widgets/checklist.py +36 -42
  30. fastlife/adapters/jinjax/widgets/dropdown.py +32 -38
  31. fastlife/adapters/jinjax/widgets/hidden.py +7 -15
  32. fastlife/adapters/jinjax/widgets/model.py +36 -43
  33. fastlife/adapters/jinjax/widgets/sequence.py +63 -42
  34. fastlife/adapters/jinjax/widgets/text.py +39 -78
  35. fastlife/adapters/jinjax/widgets/union.py +51 -58
  36. fastlife/components/CsrfToken.jinja +1 -1
  37. fastlife/components/Form.jinja +1 -1
  38. fastlife/components/pydantic_form/FatalError.jinja +8 -0
  39. fastlife/components/pydantic_form/Widget.jinja +4 -3
  40. fastlife/config/__init__.py +3 -6
  41. fastlife/config/configurator.py +80 -32
  42. fastlife/config/exceptions.py +0 -2
  43. fastlife/config/resources.py +1 -2
  44. fastlife/config/views.py +2 -4
  45. fastlife/domain/__init__.py +1 -0
  46. fastlife/domain/model/__init__.py +1 -0
  47. fastlife/domain/model/asgi.py +3 -0
  48. fastlife/domain/model/csrf.py +19 -0
  49. fastlife/{request → domain/model}/form.py +13 -22
  50. fastlife/{request → domain/model}/request.py +26 -30
  51. fastlife/domain/model/security_policy.py +105 -0
  52. fastlife/{templates/inline.py → domain/model/template.py} +8 -0
  53. fastlife/domain/model/types.py +17 -0
  54. fastlife/middlewares/base.py +1 -1
  55. fastlife/middlewares/reverse_proxy/x_forwarded.py +1 -2
  56. fastlife/middlewares/session/__init__.py +2 -2
  57. fastlife/middlewares/session/middleware.py +4 -3
  58. fastlife/middlewares/session/serializer.py +0 -44
  59. fastlife/{services/policy.py → service/check_permission.py} +1 -1
  60. fastlife/{security → service}/csrf.py +5 -15
  61. fastlife/{services → service}/locale_negociator.py +5 -8
  62. fastlife/{config → service}/registry.py +13 -7
  63. fastlife/service/security_policy.py +100 -0
  64. fastlife/{services → service}/templates.py +10 -48
  65. fastlife/{services → service}/translations.py +15 -0
  66. fastlife/{config/settings.py → settings.py} +6 -12
  67. fastlife/shared_utils/infer.py +24 -1
  68. fastlife/{templates/constants.py → template_globals.py} +2 -2
  69. fastlife/testing/testclient.py +2 -2
  70. fastlife/views/__init__.py +1 -0
  71. fastlife/views/pydantic_form.py +6 -0
  72. {fastlifeweb-0.20.1.dist-info → fastlifeweb-0.22.0.dist-info}/METADATA +1 -1
  73. {fastlifeweb-0.20.1.dist-info → fastlifeweb-0.22.0.dist-info}/RECORD +79 -80
  74. tailwind.config.js +1 -1
  75. fastlife/components/pydantic_form/Boolean.jinja +0 -13
  76. fastlife/components/pydantic_form/Checklist.jinja +0 -21
  77. fastlife/components/pydantic_form/Dropdown.jinja +0 -18
  78. fastlife/components/pydantic_form/Hidden.jinja +0 -3
  79. fastlife/components/pydantic_form/Model.jinja +0 -30
  80. fastlife/components/pydantic_form/Sequence.jinja +0 -47
  81. fastlife/components/pydantic_form/Text.jinja +0 -11
  82. fastlife/components/pydantic_form/Textarea.jinja +0 -38
  83. fastlife/components/pydantic_form/Union.jinja +0 -34
  84. fastlife/request/__init__.py +0 -5
  85. fastlife/security/__init__.py +0 -1
  86. fastlife/security/policy.py +0 -188
  87. fastlife/templates/__init__.py +0 -12
  88. fastlife/templates/binding.py +0 -52
  89. /fastlife/{routing → adapters/fastapi/routing}/__init__.py +0 -0
  90. /fastlife/{services → service}/__init__.py +0 -0
  91. {fastlifeweb-0.20.1.dist-info → fastlifeweb-0.22.0.dist-info}/WHEEL +0 -0
  92. {fastlifeweb-0.20.1.dist-info → fastlifeweb-0.22.0.dist-info}/entry_points.txt +0 -0
  93. {fastlifeweb-0.20.1.dist-info → fastlifeweb-0.22.0.dist-info}/licenses/LICENSE +0 -0
CHANGELOG.md CHANGED
@@ -1,3 +1,20 @@
1
+ ## 0.22.0 - Released on 2024-11-23
2
+ * Add a way to add fatal errors on form in order to display an error block.
3
+ * The localizer can be called gettext in the depency in order to simple translation.
4
+ * Expose the 99% of the usefull API in the main package.
5
+ * Refactor all internal class to get a more hexagonal approach in order to reduce
6
+ circular dependencies.
7
+
8
+ ## 0.21.0 - Released on 2024-11-15
9
+ * Make the InlineTemplate the only way to render views template.
10
+ * Breaking change: template args is not supported in Configutor.add_route.
11
+ * Breaking change: template args is not supported in @view_config.
12
+ * Breaking change: template and Template dedendencies have been removed.
13
+ * Add new method in the configurator to register global vars for template:
14
+ {meth}`fastlife.config.configurator.GenericConfigurator.add_renderer_global`.
15
+ * Add npgettext i18n helper method support.
16
+ * Remove babel from dependency list (only a dev dependency).
17
+
1
18
  ## 0.20.1 - Released on 2024-11-09
2
19
  * Add a new class GenericRegistry in order to properly type custom Configurator / Registry / Settings
3
20
  * Using InlineTemplate, we can pass arbitrary types for pydantic form
@@ -41,7 +58,7 @@
41
58
  * Hotfix components to create tables
42
59
 
43
60
  ## 0.15.0 - Released on 2024-09-29
44
- * Add an {class}`fastlife.security.policy.AbstractSecurityPolicy` class
61
+ * Add an {class}`fastlife.service.security_policy.AbstractSecurityPolicy` class
45
62
  * New method {meth}`fastlife.config.configurator.GenericConfigurator.set_security_policy`
46
63
  * Breaking change, the check_permission has been removed from the settings.
47
64
  to configure the permission policy, a security policy has to be implemented.
fastlife/__init__.py CHANGED
@@ -3,40 +3,72 @@ from importlib import metadata
3
3
  __version__ = metadata.version("fastlifeweb")
4
4
 
5
5
  from fastapi import Response
6
+ from fastapi.responses import RedirectResponse
6
7
 
8
+ from .adapters.fastapi.form import form_model
9
+ from .adapters.fastapi.localizer import Localizer
10
+ from .adapters.fastapi.request import AnyRequest, Registry, Request, get_request
7
11
  from .config import (
8
12
  Configurator,
9
- DefaultRegistry,
10
13
  GenericConfigurator,
11
- GenericRegistry,
12
- Settings,
13
14
  configure,
15
+ exception_handler,
14
16
  resource,
15
17
  resource_view,
16
18
  view_config,
17
19
  )
18
- from .request import GenericRequest, Registry, Request, get_request
20
+ from .domain.model.form import FormModel
21
+ from .domain.model.request import GenericRequest
22
+ from .domain.model.security_policy import (
23
+ Allowed,
24
+ Denied,
25
+ Forbidden,
26
+ HasPermission,
27
+ Unauthenticated,
28
+ Unauthorized,
29
+ )
30
+ from .domain.model.template import JinjaXTemplate
19
31
 
20
32
  # from .request.form_data import model
21
- from .services.templates import TemplateParams
33
+ from .service.registry import DefaultRegistry, GenericRegistry
34
+ from .service.security_policy import AbstractSecurityPolicy, InsecurePolicy
35
+ from .settings import Settings
22
36
 
23
37
  __all__ = [
24
38
  # Config
25
- "configure",
26
39
  "GenericConfigurator",
27
- "Configurator",
28
- "DefaultRegistry",
29
40
  "GenericRegistry",
30
- "TemplateParams",
41
+ "Registry",
31
42
  "Settings",
43
+ "configure",
32
44
  "view_config",
45
+ "exception_handler",
33
46
  "resource",
34
47
  "resource_view",
35
- # Model
36
- # "model",
37
- "Request",
48
+ "Configurator",
49
+ "DefaultRegistry",
50
+ # Form
51
+ "FormModel",
52
+ "form_model",
53
+ # Request
38
54
  "GenericRequest",
55
+ "AnyRequest",
56
+ "Request",
39
57
  "get_request",
40
- "Registry",
58
+ # Response
41
59
  "Response",
60
+ "RedirectResponse",
61
+ # Security
62
+ "AbstractSecurityPolicy",
63
+ "HasPermission",
64
+ "Unauthenticated",
65
+ "Allowed",
66
+ "Denied",
67
+ "Unauthorized",
68
+ "Forbidden",
69
+ "InsecurePolicy",
70
+ # Template
71
+ "JinjaXTemplate",
72
+ # i18n
73
+ "Localizer",
42
74
  ]
@@ -1,3 +1,3 @@
1
1
  """
2
- Adapters are implementations of abstract classed defined in {mod}`fastlife.services`.
2
+ Adapters are implementations of abstract classed defined in {mod}`fastlife.service`.
3
3
  """
@@ -0,0 +1,9 @@
1
+ """
2
+ Adapters based on top of FastAPI.
3
+
4
+ At the momdent, the code is highly coupled with FastAPI,
5
+ some part of the code import fastapi directly for legacy reason,
6
+ but it shouldn't.
7
+
8
+ All FastAPI dependency should be contained in this module.
9
+ """
@@ -0,0 +1,26 @@
1
+ """HTTP Form serialization."""
2
+
3
+ from collections.abc import Callable, Mapping
4
+ from typing import Any
5
+
6
+ from fastapi import Depends
7
+
8
+ from fastlife.adapters.fastapi.form_data import MappingFormData
9
+ from fastlife.adapters.fastapi.request import Registry
10
+ from fastlife.domain.model.form import FormModel, T
11
+
12
+
13
+ def form_model(
14
+ cls: type[T], name: str | None = None
15
+ ) -> Callable[[Mapping[str, Any]], FormModel[T]]:
16
+ """
17
+ Build a model, a class of type T based on Pydandic Base Model from a form payload.
18
+ """
19
+
20
+ def to_model(data: MappingFormData, registry: Registry) -> FormModel[T]:
21
+ prefix = name or registry.settings.form_data_model_prefix
22
+ if not data:
23
+ return FormModel[T].default(prefix, cls)
24
+ return FormModel[T].from_payload(prefix, cls, data)
25
+
26
+ return Depends(to_model)
@@ -10,7 +10,7 @@ from typing import (
10
10
 
11
11
  from fastapi import Depends
12
12
 
13
- from fastlife import Request
13
+ from fastlife.adapters.fastapi.request import Request
14
14
 
15
15
 
16
16
  def unflatten_struct(
@@ -2,12 +2,14 @@ from typing import Annotated
2
2
 
3
3
  from fastapi import Depends
4
4
 
5
- from fastlife.request.request import Request
6
- from fastlife.services.translations import Localizer as RequestLocalizer
5
+ from fastlife.adapters.fastapi.request import Request
6
+ from fastlife.service.translations import Localizer as RequestLocalizer
7
7
 
8
8
 
9
9
  def get_localizer(request: Request) -> RequestLocalizer:
10
+ """Return the localizer for the given request."""
10
11
  return request.registry.localizer(request)
11
12
 
12
13
 
13
14
  Localizer = Annotated[RequestLocalizer, Depends(get_localizer)]
15
+ """Define a localizer"""
@@ -0,0 +1,33 @@
1
+ """HTTP Request representation in a python object."""
2
+
3
+ from typing import Annotated, Any
4
+
5
+ from fastapi import Request as FastAPIRequest
6
+ from fastapi.params import Depends
7
+
8
+ from fastlife.domain.model.request import GenericRequest
9
+ from fastlife.service.registry import DefaultRegistry
10
+
11
+
12
+ def get_request(request: FastAPIRequest) -> GenericRequest[Any]:
13
+ """Return the Fastlife Request object."""
14
+ return request # type: ignore
15
+
16
+
17
+ Request = Annotated[GenericRequest[DefaultRegistry], Depends(get_request)]
18
+ """A request that is associated to the default registry."""
19
+ # FastAPI handle its Request objects using a lenient_issubclass,
20
+ # basically a issubclass(Request), doe to the Generic[T], it does not work.
21
+
22
+
23
+ AnyRequest = Annotated[GenericRequest[Any], Depends(get_request)]
24
+ """A request version that is associated to the any registry."""
25
+
26
+
27
+ def get_registry(request: Request) -> DefaultRegistry:
28
+ """Return the Fastlife Registry object."""
29
+ return request.registry
30
+
31
+
32
+ Registry = Annotated[DefaultRegistry, Depends(get_registry)]
33
+ """FastAPI dependency to access to the global registry."""
@@ -7,10 +7,10 @@ from fastapi.routing import APIRoute
7
7
  from starlette.requests import Request as StarletteRequest
8
8
  from starlette.responses import Response
9
9
 
10
- from fastlife.request.request import Request
10
+ from fastlife.domain.model.request import GenericRequest
11
11
 
12
12
  if TYPE_CHECKING:
13
- from fastlife.config.registry import DefaultRegistry # coverage: ignore
13
+ from fastlife.service.registry import DefaultRegistry # coverage: ignore
14
14
 
15
15
 
16
16
  class Route(APIRoute):
@@ -41,7 +41,7 @@ class Route(APIRoute):
41
41
  orig_route_handler = super().get_route_handler()
42
42
 
43
43
  async def route_handler(request: StarletteRequest) -> Response:
44
- req = Request(self._registry, request)
44
+ req = GenericRequest(self._registry, request)
45
45
  return await orig_route_handler(req)
46
46
 
47
47
  return route_handler
@@ -9,7 +9,7 @@ from typing import Any
9
9
 
10
10
  from fastapi import APIRouter
11
11
 
12
- from fastlife.routing.route import Route
12
+ from fastlife.adapters.fastapi.routing.route import Route
13
13
 
14
14
 
15
15
  class Router(APIRouter):
@@ -0,0 +1,3 @@
1
+ from .session import SignedSessionSerializer
2
+
3
+ __all__ = ["SignedSessionSerializer"]
@@ -0,0 +1,50 @@
1
+ """Serialize session."""
2
+
3
+ import json
4
+ from base64 import b64decode, b64encode
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+ import itsdangerous
9
+
10
+ from fastlife.middlewares.session.serializer import AbsractSessionSerializer
11
+
12
+
13
+ class SignedSessionSerializer(AbsractSessionSerializer):
14
+ """
15
+ The default fastlife session serializer.
16
+
17
+ It's based on the itsdangerous package to sign the session with a secret key.
18
+
19
+ :param secret_key: a secret used to sign the session payload.
20
+
21
+ :param max_age: session lifetime in seconds.
22
+ """
23
+
24
+ def __init__(self, secret_key: str, max_age: int) -> None:
25
+ self.signer = itsdangerous.TimestampSigner(secret_key)
26
+ self.max_age = max_age
27
+
28
+ def serialize(self, data: Mapping[str, Any]) -> bytes:
29
+ """Serialize and sign the session."""
30
+ dump = json.dumps(data).encode("utf-8")
31
+ encoded = b64encode(dump)
32
+ signed = self.signer.sign(encoded)
33
+ return signed
34
+
35
+ def deserialize(self, data: bytes) -> tuple[Mapping[str, Any], bool]:
36
+ """Deserialize the session.
37
+
38
+ If the signature is incorect, the session restart from the begining.
39
+ No exception raised.
40
+ """
41
+ try:
42
+ data = self.signer.unsign(data, max_age=self.max_age)
43
+ # We can't deserialize something wrong since the serialize
44
+ # is signing the content.
45
+ # If the signature key is compromise and we have invalid payload,
46
+ # raising exceptions here is fine, it's dangerous afterall.
47
+ session = json.loads(b64decode(data))
48
+ except itsdangerous.BadSignature:
49
+ return {}, True
50
+ return session, False
@@ -42,17 +42,17 @@ class InspectableComponent(Component):
42
42
  """
43
43
 
44
44
  __slots__ = (
45
- "name",
46
- "prefix",
47
- "url_prefix",
48
- "required",
49
- "optional",
50
45
  "css",
51
46
  "js",
52
- "path",
53
47
  "mtime",
54
- "tmpl",
48
+ "name",
49
+ "optional",
50
+ "path",
51
+ "prefix",
52
+ "required",
55
53
  "source",
54
+ "tmpl",
55
+ "url_prefix",
56
56
  )
57
57
 
58
58
  def __init__(
@@ -14,7 +14,7 @@ from sphinx.roles import XRefRole
14
14
  from sphinx.util import relative_uri # type: ignore
15
15
 
16
16
  from fastlife.adapters.jinjax.renderer import JinjaxEngine
17
- from fastlife.config.settings import Settings
17
+ from fastlife.settings import Settings
18
18
 
19
19
 
20
20
  def create_ref_node(arg_type: str) -> nodes.Node:
@@ -4,7 +4,7 @@ Template rending based on JinjaX.
4
4
 
5
5
  import logging
6
6
  import textwrap
7
- from collections.abc import Mapping, MutableMapping, Sequence
7
+ from collections.abc import Sequence
8
8
  from typing import (
9
9
  TYPE_CHECKING,
10
10
  Any,
@@ -14,15 +14,15 @@ from markupsafe import Markup
14
14
  from pydantic.fields import FieldInfo
15
15
 
16
16
  from fastlife import Request
17
+ from fastlife.adapters.fastapi.form import FormModel
18
+ from fastlife.adapters.fastapi.localizer import get_localizer
17
19
  from fastlife.adapters.jinjax.widget_factory.factory import WidgetFactory
18
- from fastlife.request.form import FormModel
19
- from fastlife.request.localizer import get_localizer
20
- from fastlife.templates.inline import InlineTemplate
20
+ from fastlife.domain.model.template import InlineTemplate
21
21
 
22
22
  if TYPE_CHECKING:
23
- from fastlife.config.settings import Settings # coverage: ignore
23
+ from fastlife.settings import Settings # coverage: ignore
24
24
 
25
- from fastlife.services.templates import (
25
+ from fastlife.service.templates import (
26
26
  AbstractTemplateRenderer,
27
27
  AbstractTemplateRendererFactory,
28
28
  )
@@ -66,58 +66,10 @@ class JinjaxRenderer(AbstractTemplateRenderer):
66
66
  super().__init__(request)
67
67
  self.catalog = catalog
68
68
  self.settings = request.registry.settings
69
- self.globals: MutableMapping[str, Any] = {}
70
69
  self.translations = get_localizer(request)
70
+ self.globals["pydantic_form"] = self.pydantic_form
71
71
 
72
- def build_globals(self) -> Mapping[str, Any]:
73
- """
74
- Build globals variables accessible in any templates.
75
-
76
- * `request` is the {class}`current request <fastlife.request.request.Request>`
77
- * `csrf_token` is used to build for {jinjax:component}`CsrfToken`.
78
- """
79
- settings = self.settings
80
- ret = {
81
- "request": self.request,
82
- "csrf_token": {
83
- "name": settings.csrf_token_name,
84
- "value": self.request.scope.get(settings.csrf_token_name, ""),
85
- },
86
- "pydantic_form": self.pydantic_form,
87
- "localizer": self.translations,
88
- **self.globals,
89
- }
90
- return ret
91
-
92
- def render_template(
93
- self,
94
- template: str,
95
- *,
96
- globals: Mapping[str, Any] | None = None,
97
- **params: Any,
98
- ) -> str:
99
- """
100
- Render the JinjaX component with the given parameter.
101
-
102
- :param template: the template to render
103
- :param globals: parameters that will be used by the JinjaX component and all its
104
- child components without "props drilling".
105
- :param params: parameters used to render the template.
106
- """
107
-
108
- template = template[: -len(self.settings.jinjax_file_ext) - 1]
109
- if globals:
110
- self.globals.update(globals)
111
-
112
- self.catalog.jinja_env.install_gettext_translations( # type: ignore
113
- self.translations, newstyle=True
114
- )
115
-
116
- return self.catalog.render( # type: ignore
117
- template, __globals=self.build_globals(), **params
118
- )
119
-
120
- def render_inline(self, template: InlineTemplate) -> str:
72
+ def render_template(self, template: InlineTemplate) -> str:
121
73
  """
122
74
  Render the JinjaX component with the given parameter.
123
75
 
@@ -134,7 +86,7 @@ class JinjaxRenderer(AbstractTemplateRenderer):
134
86
  return self.catalog.render(
135
87
  template.__class__.__qualname__,
136
88
  __source=src,
137
- __globals=self.build_globals(),
89
+ __globals=self.globals,
138
90
  **params,
139
91
  )
140
92
 
@@ -28,9 +28,9 @@ class BoolBuilder(BaseWidgetBuilder[bool]):
28
28
  ) -> BooleanWidget:
29
29
  """Build the widget."""
30
30
  return BooleanWidget(
31
- field_name,
31
+ name=field_name,
32
32
  removable=removable,
33
- title=field.title if field else "",
33
+ title=field.title or "" if field else "",
34
34
  hint=field.description if field else None,
35
35
  aria_label=(
36
36
  field.json_schema_extra.get("aria_label") # type:ignore
@@ -8,9 +8,10 @@ from pydantic.networks import EmailStr
8
8
 
9
9
  from fastlife.adapters.jinjax.widget_factory.base import BaseWidgetBuilder
10
10
  from fastlife.adapters.jinjax.widgets.text import TextWidget
11
+ from fastlife.domain.model.types import Builtins
11
12
 
12
13
 
13
- class EmailStrBuilder(BaseWidgetBuilder[EmailStr]):
14
+ class EmailStrBuilder(BaseWidgetBuilder[Builtins]):
14
15
  """Builder for Pydantic EmailStr."""
15
16
 
16
17
  def accept(self, typ: type[Any], origin: type[Any] | None) -> bool:
@@ -23,17 +24,17 @@ class EmailStrBuilder(BaseWidgetBuilder[EmailStr]):
23
24
  field_name: str,
24
25
  field_type: type[Any],
25
26
  field: FieldInfo | None,
26
- value: EmailStr | None,
27
+ value: Builtins | None,
27
28
  form_errors: Mapping[str, Any],
28
29
  removable: bool,
29
30
  ) -> TextWidget:
30
31
  """Build the widget."""
31
32
  return TextWidget(
32
- field_name,
33
+ name=field_name,
33
34
  input_type="email",
34
35
  placeholder=str(field.examples[0]) if field and field.examples else None,
35
36
  removable=removable,
36
- title=field.title if field else "",
37
+ title=field.title or "" if field else "",
37
38
  hint=field.description if field else None,
38
39
  aria_label=(
39
40
  field.json_schema_extra.get("aria_label") # type:ignore
@@ -31,10 +31,10 @@ class EnumBuilder(BaseWidgetBuilder[Enum]):
31
31
  """Build the widget."""
32
32
  options = [(item.name, item.value) for item in field_type] # type: ignore
33
33
  return DropDownWidget(
34
- field_name,
34
+ name=field_name,
35
35
  options=options, # type: ignore
36
36
  removable=removable,
37
- title=field.title if field else "",
37
+ title=field.title or "" if field else "",
38
38
  hint=field.description if field else None,
39
39
  aria_label=(
40
40
  field.json_schema_extra.get("aria_label") # type:ignore
@@ -4,15 +4,17 @@ Create markup for pydantic forms.
4
4
 
5
5
  import secrets
6
6
  from collections.abc import Mapping
7
- from inspect import isclass
8
- from typing import Any, cast, get_origin
7
+ from typing import TYPE_CHECKING, Any, get_origin
9
8
 
10
9
  from markupsafe import Markup
11
10
  from pydantic.fields import FieldInfo
12
11
 
13
- from fastlife.adapters.jinjax.widgets.base import Widget
14
- from fastlife.request.form import FormModel
15
- from fastlife.services.templates import AbstractTemplateRenderer
12
+ from fastlife.adapters.jinjax.widgets.base import CustomWidget, Widget
13
+ from fastlife.domain.model.form import FormModel
14
+ from fastlife.domain.model.template import JinjaXTemplate
15
+
16
+ if TYPE_CHECKING:
17
+ from fastlife.service.templates import AbstractTemplateRenderer
16
18
 
17
19
  from .base import BaseWidgetBuilder
18
20
  from .bool_builder import BoolBuilder
@@ -27,6 +29,11 @@ from .simpletype_builder import SimpleTypeBuilder
27
29
  from .union_builder import UnionBuilder
28
30
 
29
31
 
32
+ class FatalError(JinjaXTemplate):
33
+ template = """<pydantic_form.FatalError :message="message" />"""
34
+ message: str
35
+
36
+
30
37
  class WidgetFactory:
31
38
  """
32
39
  Form builder for pydantic model.
@@ -35,7 +42,7 @@ class WidgetFactory:
35
42
  :param token: reuse a token.
36
43
  """
37
44
 
38
- def __init__(self, renderer: AbstractTemplateRenderer, token: str | None = None):
45
+ def __init__(self, renderer: "AbstractTemplateRenderer", token: str | None = None):
39
46
  self.renderer = renderer
40
47
  self.token = token or secrets.token_urlsafe(4).replace("_", "-")
41
48
  self.builders: list[BaseWidgetBuilder[Any]] = [
@@ -70,7 +77,10 @@ class WidgetFactory:
70
77
  :param removable: Include a button to remove the model in the markup.
71
78
  :param field: only build the markup of this field is not None.
72
79
  """
73
- return self.get_widget(
80
+ ret = Markup()
81
+ if model.fatal_error:
82
+ ret += self.renderer.render_template(FatalError(message=model.fatal_error))
83
+ ret += self.get_widget(
74
84
  model.model.__class__,
75
85
  model.form_data,
76
86
  model.errors,
@@ -78,6 +88,7 @@ class WidgetFactory:
78
88
  removable=removable,
79
89
  field=field,
80
90
  ).to_html(self.renderer)
91
+ return ret
81
92
 
82
93
  def get_widget(
83
94
  self,
@@ -131,24 +142,22 @@ class WidgetFactory:
131
142
  """
132
143
  if field and field.metadata:
133
144
  for widget in field.metadata:
134
- if isclass(widget) and issubclass(widget, Widget):
135
- return cast(
136
- Widget[Any],
137
- widget(
138
- name,
139
- value=value,
140
- removable=removable,
141
- title=field.title if field else "",
142
- hint=field.description if field else None,
143
- aria_label=(
144
- field.json_schema_extra.get("aria_label") # type:ignore
145
- if field and field.json_schema_extra
146
- else None
147
- ),
148
- token=self.token,
149
- error=form_errors.get(name),
145
+ if isinstance(widget, CustomWidget):
146
+ ret: Widget[Any] = widget.typ(
147
+ name=name,
148
+ value=value,
149
+ removable=removable,
150
+ title=field.title or "" if field else "",
151
+ hint=field.description if field else None,
152
+ aria_label=(
153
+ field.json_schema_extra.get("aria_label") # type:ignore
154
+ if field and field.json_schema_extra
155
+ else None
150
156
  ),
157
+ token=self.token,
158
+ error=form_errors.get(name),
151
159
  )
160
+ return ret
152
161
 
153
162
  type_origin = get_origin(typ)
154
163
  for builder in self.builders:
@@ -8,9 +8,10 @@ from pydantic.fields import FieldInfo
8
8
  from fastlife.adapters.jinjax.widget_factory.base import BaseWidgetBuilder
9
9
  from fastlife.adapters.jinjax.widgets.dropdown import DropDownWidget
10
10
  from fastlife.adapters.jinjax.widgets.hidden import HiddenWidget
11
+ from fastlife.domain.model.types import AnyLiteral
11
12
 
12
13
 
13
- class LiteralBuilder(BaseWidgetBuilder[str]): # str|int|bool
14
+ class LiteralBuilder(BaseWidgetBuilder[AnyLiteral]):
14
15
  """Builder for Literal."""
15
16
 
16
17
  def accept(self, typ: type[Any], origin: type[Any] | None) -> bool:
@@ -23,7 +24,7 @@ class LiteralBuilder(BaseWidgetBuilder[str]): # str|int|bool
23
24
  field_name: str,
24
25
  field_type: type[Any], # a literal actually
25
26
  field: FieldInfo | None,
26
- value: str | None,
27
+ value: AnyLiteral | None,
27
28
  form_errors: Mapping[str, Any],
28
29
  removable: bool,
29
30
  ) -> HiddenWidget | DropDownWidget:
@@ -31,15 +32,15 @@ class LiteralBuilder(BaseWidgetBuilder[str]): # str|int|bool
31
32
  choices: list[str] = field_type.__args__ # type: ignore
32
33
  if len(choices) == 1:
33
34
  return HiddenWidget(
34
- field_name,
35
+ name=field_name,
35
36
  value=choices[0],
36
37
  token=self.factory.token,
37
38
  )
38
39
  return DropDownWidget(
39
- field_name,
40
- options=choices,
40
+ name=field_name,
41
+ options=choices, # type: ignore
41
42
  removable=removable,
42
- title=field.title if field else "",
43
+ title=field.title or "" if field else "",
43
44
  hint=field.description if field else None,
44
45
  aria_label=(
45
46
  field.json_schema_extra.get("aria_label") # type:ignore
@@ -47,11 +47,11 @@ class ModelBuilder(BaseWidgetBuilder[Mapping[str, Any]]):
47
47
  form_errors=form_errors,
48
48
  removable=False,
49
49
  )
50
- return ModelWidget(
51
- field_name,
50
+ return ModelWidget[Any](
51
+ name=field_name,
52
52
  value=list(ret.values()),
53
53
  removable=removable,
54
- title=field.title if field and field.title else "",
54
+ title=field.title or "" if field and field.title else "",
55
55
  hint=field.description if field else None,
56
56
  aria_label=(
57
57
  field.json_schema_extra.get("aria_label") # type:ignore