fastlifeweb 0.2.0__py3-none-any.whl → 0.2.2__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.
@@ -21,7 +21,7 @@ from typing import (
21
21
 
22
22
  import venusian # type: ignore
23
23
  from fastapi import Depends, FastAPI, Response
24
- from fastapi.datastructures import Default
24
+ from fastapi.params import Depends as DependsType
25
25
  from fastapi.staticfiles import StaticFiles
26
26
 
27
27
  from fastlife.configurator.base import AbstractMiddleware
@@ -69,20 +69,21 @@ class Configurator:
69
69
 
70
70
  def add_route(
71
71
  self,
72
+ name: str,
72
73
  path: str,
73
74
  endpoint: Callable[..., Coroutine[Any, Any, Response]],
74
75
  *,
75
- response_model: Any = Default(None),
76
- status_code: Optional[int] = None,
77
- tags: Optional[List[Union[str, Enum]]] = None,
78
- # dependencies: Optional[Sequence[Depends]] = None, # type: ignore
76
+ permission: str | None = None,
77
+ status_code: int | None = None,
78
+ tags: List[Union[str, Enum]] | None = None,
79
79
  summary: Optional[str] = None,
80
80
  description: Optional[str] = None,
81
- # response_description: str = "Successful Response",
81
+ response_description: str = "Successful Response",
82
82
  # responses: Optional[Dict[Union[int, str], Dict[str, Any]]] = None,
83
83
  deprecated: Optional[bool] = None,
84
84
  methods: Optional[List[str]] = None,
85
- operation_id: Optional[str] = None,
85
+ # operation_id: Optional[str] = None,
86
+ # response_model: Any = Default(None),
86
87
  # response_model_include: Optional[IncEx] = None,
87
88
  # response_model_exclude: Optional[IncEx] = None,
88
89
  # response_model_by_alias: bool = True,
@@ -93,26 +94,29 @@ class Configurator:
93
94
  # response_class: Union[Type[Response], DefaultPlaceholder] = Default(
94
95
  # HTMLResponse
95
96
  # ),
96
- name: Optional[str] = None,
97
97
  # openapi_extra: Optional[Dict[str, Any]] = None,
98
98
  # generate_unique_id_function: Callable[[APIRoute], str] = Default(
99
99
  # generate_unique_id
100
100
  # ),
101
101
  ) -> "Configurator":
102
+ dependencies: List[DependsType] = []
103
+ if permission:
104
+ dependencies.append(Depends(self.registry.check_permission(permission)))
105
+
102
106
  self._app.add_api_route(
103
107
  path,
104
108
  endpoint,
105
- response_model=response_model,
109
+ # response_model=response_model,
106
110
  status_code=status_code,
107
111
  tags=tags,
108
- # dependencies=dependencies,
112
+ dependencies=dependencies,
109
113
  summary=summary,
110
114
  description=description,
111
- # response_description=response_description,
115
+ response_description=response_description,
112
116
  # responses=responses,
113
117
  deprecated=deprecated,
114
118
  methods=methods,
115
- operation_id=operation_id,
119
+ # operation_id=operation_id,
116
120
  # response_model_include=response_model_include,
117
121
  # response_model_exclude=response_model_exclude,
118
122
  # response_model_by_alias=response_model_by_alias,
@@ -134,6 +138,12 @@ class Configurator:
134
138
  self._app.mount(route_path, StaticFiles(directory=directory), name=name)
135
139
  return self
136
140
 
141
+ def add_exception_handler(
142
+ self, status_code_or_exc: int | Type[Exception], handler: Any
143
+ ) -> "Configurator":
144
+ self._app.add_exception_handler(status_code_or_exc, handler)
145
+ return self
146
+
137
147
 
138
148
  def configure(
139
149
  wrapped: Callable[[Configurator], None]
@@ -2,6 +2,7 @@ from typing import Annotated
2
2
 
3
3
  from fastapi import Depends
4
4
 
5
+ from fastlife.security.policy import CheckPermission
5
6
  from fastlife.shared_utils.resolver import resolve
6
7
  from fastlife.templating.renderer import AbstractTemplateRenderer
7
8
 
@@ -11,11 +12,15 @@ from .settings import Settings
11
12
  class AppRegistry:
12
13
  settings: Settings
13
14
  renderer: AbstractTemplateRenderer
15
+ check_permission: CheckPermission
14
16
 
15
17
  def __init__(self, settings: Settings) -> None:
18
+ # Abtract class resolved for dependency injection
16
19
  TemplateRenderer = resolve(settings.template_renderer_class)
20
+
17
21
  self.settings = settings
18
22
  self.renderer = TemplateRenderer(settings)
23
+ self.check_permission = resolve(settings.check_permission)
19
24
 
20
25
 
21
26
  DEFAULT_REGISTRY: AppRegistry = None # type: ignore
@@ -19,6 +19,7 @@ class Settings(BaseSettings):
19
19
 
20
20
  session_secret_key: str = Field(default="")
21
21
  session_cookie_name: str = Field(default="flsess")
22
+ session_cookie_domain: str = Field(default="")
22
23
  session_cookie_path: str = Field(default="/")
23
24
  session_duration: timedelta = Field(default=timedelta(days=14))
24
25
  session_cookie_same_site: Literal["lax", "strict", "none"] = Field(default="lax")
@@ -28,3 +29,5 @@ class Settings(BaseSettings):
28
29
  )
29
30
 
30
31
  domain_name: str = Field(default="", title="domain name where the app is served")
32
+
33
+ check_permission: str = Field(default="fastlife.security.policy:check_permission")
@@ -0,0 +1,18 @@
1
+ from typing import Any, Callable, Coroutine
2
+
3
+ CheckPermissionHook = Callable[..., Coroutine[Any, Any, None]] | Callable[..., None]
4
+ CheckPermission = Callable[[str], CheckPermissionHook]
5
+
6
+
7
+ def check_permission(permission_name: str) -> CheckPermissionHook:
8
+ """
9
+ A closure that check that a user as the given username.
10
+
11
+ This method has to be overriden using the setting check_permission
12
+ to implement it.
13
+ """
14
+
15
+ def depencency_injection() -> None:
16
+ ...
17
+
18
+ return depencency_injection
@@ -17,6 +17,6 @@ def includeme(config: Configurator) -> None:
17
17
  cookie_path=settings.session_cookie_path,
18
18
  cookie_same_site=settings.session_cookie_same_site,
19
19
  cookie_secure=settings.session_cookie_secure,
20
- cookie_domain=settings.domain_name,
20
+ cookie_domain=settings.session_cookie_domain,
21
21
  serializer=session_serializer,
22
22
  )
@@ -41,16 +41,18 @@ class SessionMiddleware(AbstractMiddleware):
41
41
  return
42
42
 
43
43
  connection = HTTPConnection(scope)
44
- reset_session = False
45
-
46
- if self.cookie_name in connection.cookies:
44
+ existing_session = self.cookie_name in connection.cookies
45
+ if existing_session:
47
46
  data = connection.cookies[self.cookie_name].encode("utf-8")
48
- scope["session"], reset_session = self.serializer.deserialize(data)
47
+ scope["session"], broken_session = self.serializer.deserialize(data)
48
+ if broken_session:
49
+ existing_session = False
49
50
  else:
50
51
  scope["session"] = {}
51
52
 
52
53
  async def send_wrapper(message: Message) -> None:
53
54
  if message["type"] == "http.response.start":
55
+ headers = None
54
56
  if scope["session"]:
55
57
  # We have session data to persist.
56
58
  data = self.serializer.serialize(scope["session"]).decode("utf-8")
@@ -60,7 +62,7 @@ class SessionMiddleware(AbstractMiddleware):
60
62
  f"Max-Age={self.max_age}; {self.security_flags}"
61
63
  )
62
64
  headers.append("set-cookie", header_value)
63
- elif reset_session:
65
+ elif existing_session:
64
66
  # The session has been cleared.
65
67
  headers = MutableHeaders(scope=message)
66
68
  expires = "expires=Thu, 01 Jan 1970 00:00:00 GMT; "
@@ -34,6 +34,11 @@ class SignedSessionSerializer(AbsractSessionSerializer):
34
34
  def deserialize(self, data: bytes) -> Tuple[Mapping[str, Any], bool]:
35
35
  try:
36
36
  data = self.signer.unsign(data, max_age=self.max_age)
37
+ # We can't deserialize something wrong since the serialize
38
+ # is signing the content.
39
+ # If the signature key is compromise and we have invalid payload,
40
+ # raising exceptions here is fine, it's dangerous afterall.
41
+ session = json.loads(b64decode(data))
37
42
  except itsdangerous.BadSignature:
38
43
  return {}, True
39
- return json.loads(b64decode(data)), False
44
+ return session, False
@@ -1,4 +1,5 @@
1
1
  import re
2
+ import time
2
3
  from collections.abc import MutableMapping
3
4
  from typing import Any, Literal, Mapping
4
5
  from urllib.parse import urlencode
@@ -13,13 +14,60 @@ from fastlife.session.serializer import AbsractSessionSerializer
13
14
  from fastlife.shared_utils.resolver import resolve
14
15
 
15
16
 
17
+ class Element:
18
+ def __init__(self, client: "WebTestClient", tag: bs4.Tag):
19
+ self._client = client
20
+ self._tag = tag
21
+
22
+ def click(self) -> "WebResponse":
23
+ return self._client.get(self._tag.attrs["href"])
24
+
25
+ @property
26
+ def attrs(self) -> dict[str, str]:
27
+ return self._tag.attrs
28
+
29
+ @property
30
+ def form(self) -> "Element | None":
31
+ return Element(self._client, self._tag.form) if self._tag.form else None
32
+
33
+ def by_text(self, text: str, *, node_name: str | None = None) -> "Element | None":
34
+ nodes = self._tag.find_all(string=re.compile(rf"\s*{text}\s*"))
35
+ for node in nodes:
36
+ if isinstance(node, bs4.NavigableString):
37
+ node = node.parent
38
+
39
+ if node_name:
40
+ while node is not None:
41
+ if node.name == node_name:
42
+ return Element(self._client, node)
43
+ node = node.parent
44
+ elif node:
45
+ return Element(self._client, node)
46
+ return None
47
+
48
+ def by_label_text(self, text: str) -> "Element | None":
49
+ label = self.by_text(text, node_name="label")
50
+ assert label is not None
51
+ assert label.attrs.get("for") is not None
52
+ resp = self._tag.find(id=label.attrs["for"])
53
+ assert not isinstance(resp, bs4.NavigableString)
54
+ return Element(self._client, resp) if resp else None
55
+
56
+ def by_node_name(
57
+ self, node_name: str, *, attrs: dict[str, str] | None = None
58
+ ) -> list["Element"]:
59
+ return [
60
+ Element(self._client, e) for e in self._tag.find_all(node_name, attrs or {})
61
+ ]
62
+
63
+
16
64
  class WebForm:
17
- def __init__(self, client: "WebTestClient", origin: str, form: bs4.Tag):
65
+ def __init__(self, client: "WebTestClient", origin: str, form: Element):
18
66
  self._client = client
19
67
  self._form = form
20
68
  self._origin = origin
21
69
  self._formdata: dict[str, str] = {}
22
- inputs = self._form.find_all("input")
70
+ inputs = self._form.by_node_name("input")
23
71
  for input in inputs:
24
72
  if input.attrs.get("type") == "checkbox" and "checked" not in input.attrs:
25
73
  continue
@@ -32,7 +80,7 @@ class WebForm:
32
80
  self._formdata[fieldname] = value
33
81
 
34
82
  def button(self, text: str) -> "WebForm":
35
- assert self._form.find("button", string=re.compile(f".*{text}.*")) is not None
83
+ assert self._form.by_text(text, node_name="button") is not None
36
84
  return self
37
85
 
38
86
  def submit(self, follow_redirects: bool = True) -> "WebResponse":
@@ -45,6 +93,9 @@ class WebForm:
45
93
  target, data=self._formdata, follow_redirects=follow_redirects
46
94
  )
47
95
 
96
+ def __contains__(self, key: str) -> bool:
97
+ return key in self._formdata
98
+
48
99
 
49
100
  class WebResponse:
50
101
  def __init__(self, client: "WebTestClient", origin: str, response: httpx.Response):
@@ -75,39 +126,16 @@ class WebResponse:
75
126
  return self._response.text
76
127
 
77
128
  @property
78
- def html(self) -> bs4.Tag:
129
+ def html(self) -> Element:
79
130
  if self._html is None:
80
131
  self._html = bs4.BeautifulSoup(self._response.text, "html.parser")
81
- return self._html
132
+ return Element(self._client, self._html)
82
133
 
83
134
  @property
84
- def html_body(self) -> bs4.Tag:
85
- body = self.html.find("body")
86
- assert body is not None
87
- assert isinstance(body, bs4.Tag)
88
- return body
89
-
90
- def by_text(self, text: str, *, node_name: str | None = None) -> bs4.Tag | None:
91
- nodes = self.html.find_all(string=re.compile(rf"\s*{text}\s*"))
92
- for node in nodes:
93
- if isinstance(node, bs4.NavigableString):
94
- node = node.parent
95
-
96
- if node_name:
97
- while node is not None:
98
- if node.name == node_name:
99
- return node
100
- node = node.parent
101
-
102
- return None
103
-
104
- def by_label_text(self, text: str) -> bs4.Tag | None:
105
- label = self.by_text(text, node_name="label")
106
- assert label is not None
107
- assert label.attrs.get("for") is not None
108
- resp = self.html.find(id=label.attrs["for"])
109
- assert not isinstance(resp, bs4.NavigableString)
110
- return resp
135
+ def html_body(self) -> Element:
136
+ body = self.html.by_node_name("body")
137
+ assert len(body) == 1
138
+ return body[0]
111
139
 
112
140
  @property
113
141
  def form(self) -> WebForm:
@@ -117,10 +145,16 @@ class WebResponse:
117
145
  self._form = WebForm(self._client, self._origin, form)
118
146
  return self._form
119
147
 
148
+ def by_text(self, text: str, *, node_name: str | None = None) -> Element | None:
149
+ return self.html.by_text(text, node_name=node_name)
150
+
151
+ def by_label_text(self, text: str) -> Element | None:
152
+ return self.html.by_label_text(text)
153
+
120
154
  def by_node_name(
121
155
  self, node_name: str, *, attrs: dict[str, str] | None = None
122
- ) -> list[bs4.Tag]:
123
- return self.html.find_all(node_name, attrs or {})
156
+ ) -> list[Element]:
157
+ return self.html.by_node_name(node_name, attrs=attrs)
124
158
 
125
159
 
126
160
  CookieTypes = httpx._types.CookieTypes # type: ignore
@@ -141,28 +175,51 @@ class Session(dict[str, Any]):
141
175
  assert settings is not None
142
176
  self.settings = settings
143
177
  data: Mapping[str, Any]
144
- if settings.session_cookie_name in self.client.cookies:
145
- data, exists = self.srlz.deserialize(
178
+ self.has_session = settings.session_cookie_name in self.client.cookies
179
+ if self.has_session:
180
+ data, error = self.srlz.deserialize(
146
181
  self.client.cookies[settings.session_cookie_name].encode("utf-8")
147
182
  )
183
+ if error:
184
+ self.has_session = False
148
185
  else:
149
- data, exists = {}, False
150
- self.new_session = not exists
186
+ data = {}
151
187
  super().__init__(data)
152
188
 
153
189
  def __setitem__(self, __key: Any, __value: Any) -> None:
154
190
  super().__setitem__(__key, __value)
155
191
  settings = self.settings
156
192
  data = self.serialize()
157
- if self.new_session:
158
- self.client.cookies.set(
159
- settings.session_cookie_name,
160
- data,
161
- settings.domain_name,
162
- settings.session_cookie_path,
193
+ from http.cookiejar import Cookie
194
+
195
+ self.client.cookies.jar.set_cookie(
196
+ Cookie(
197
+ version=0,
198
+ name=settings.session_cookie_name,
199
+ value=data,
200
+ port=None,
201
+ port_specified=False,
202
+ domain=f".{settings.session_cookie_domain}",
203
+ domain_specified=True,
204
+ domain_initial_dot=True,
205
+ path="/",
206
+ path_specified=True,
207
+ secure=False,
208
+ expires=int(time.time() + settings.session_duration.total_seconds()),
209
+ discard=False,
210
+ comment=None,
211
+ comment_url=None,
212
+ rest={"HttpOnly": None, "SameSite": "lax"}, # type: ignore
213
+ rfc2109=False,
163
214
  )
164
- else:
165
- self.client.cookies[settings.session_cookie_name] = data
215
+ )
216
+ # this does not work
217
+ # self.client.cookies.set(
218
+ # settings.session_cookie_name,
219
+ # data,
220
+ # settings.session_cookie_domain,
221
+ # settings.session_cookie_path,
222
+ # )
166
223
 
167
224
  def serialize(self) -> str:
168
225
  return self.srlz.serialize(self).decode("utf-8")
@@ -177,7 +234,12 @@ class WebTestClient:
177
234
  cookies: CookieTypes | None = None,
178
235
  ) -> None:
179
236
  self.app = app
180
- self.testclient = TestClient(app, cookies=cookies or {})
237
+ if settings is None:
238
+ settings = Settings()
239
+ settings.domain_name = settings.domain_name or "testserver.local"
240
+ self.testclient = TestClient(
241
+ app, base_url=f"http://{settings.domain_name}", cookies=cookies or {}
242
+ )
181
243
  self.settings = settings
182
244
  self.session_serializer: AbsractSessionSerializer | None = None
183
245
  if settings:
@@ -212,7 +274,7 @@ class WebTestClient:
212
274
  url=url,
213
275
  headers=headers,
214
276
  content=content,
215
- follow_redirects=False, # don't follow for cookie processing
277
+ follow_redirects=False,
216
278
  )
217
279
  resp = WebResponse(
218
280
  self,
@@ -23,5 +23,8 @@ async def show_widget(
23
23
  def includeme(config: Configurator) -> None:
24
24
  route_prefix = config.registry.settings.fastlife_route_prefix
25
25
  config.add_route(
26
- f"{route_prefix}/pydantic-form/widgets/{{typ}}", show_widget, methods=["GET"]
26
+ "fl-pydantic-form-widget",
27
+ f"{route_prefix}/pydantic-form/widgets/{{typ}}",
28
+ show_widget,
29
+ methods=["GET"],
27
30
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fastlifeweb
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: High-level web framework
5
5
  License: BSD-derived
6
6
  Author: Guillaume Gauvrit
@@ -30,6 +30,11 @@ Description-Content-Type: text/markdown
30
30
 
31
31
  # Fastlife
32
32
 
33
+ [![CI](https://github.com/mardiros/fastlife/actions/workflows/main.yml/badge.svg)](https://github.com/mardiros/fastlife/actions/workflows/main.yml)
34
+
35
+ [![codecov](https://codecov.io/gh/mardiros/fastlife/graph/badge.svg?token=DTpi73d7mf)](https://codecov.io/gh/mardiros/fastlife)
36
+
37
+
33
38
  A high-level Python web framework based on FastAPI, Jinja2 and Pydandic and htmx.
34
39
 
35
40
  The intention is to prototype fast application.
@@ -1,17 +1,18 @@
1
1
  fastlife/__init__.py,sha256=__RsTYXTkhcxwHRvT1xWQ5XfygdwBtotbrff71lu-kk,190
2
2
  fastlife/configurator/__init__.py,sha256=2EPjM1o5iHJIViPwgJjaPQS3pMhE-9dik_mm53eX2DY,91
3
3
  fastlife/configurator/base.py,sha256=2ahvTudLmD99YQjnIeGN5JDPCSl3k-mauu7bsSEB5RE,216
4
- fastlife/configurator/configurator.py,sha256=3LMeHFhQxCVFDFgAJLSVv8BJJqc5-J-gsnWARJoM-2I,5155
5
- fastlife/configurator/registry.py,sha256=YRewgqw6FoKHa3vjliwm2_XshydJKPJL_IAnvldfp3o,1115
6
- fastlife/configurator/settings.py,sha256=lNgp_jBfzgXGxoXaW7iey5VNdf9D6AGhimYPNwiLjF0,1255
4
+ fastlife/configurator/configurator.py,sha256=SfzHlS6ou2JrUhzm0Q--G8R_XGo4nARQO-Mwzy-Mfn4,5474
5
+ fastlife/configurator/registry.py,sha256=fSwlfeqrVzLJmaR2-bAu41rJP8uRMZd-8vZnfsfN2xg,1332
6
+ fastlife/configurator/settings.py,sha256=ui40ldu3f6v9ARDVijhNcD2AxF1fWFbfOkkZ7z_2gH0,1394
7
7
  fastlife/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
8
  fastlife/request/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
9
  fastlife/request/form_data.py,sha256=7gV2hLeFyhPRrNYL_UlRAqM-oqxSu2-VvxKHMv5F900,3849
10
10
  fastlife/security/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
11
  fastlife/security/csrf.py,sha256=-2XVfkSpmU1HJB2-_tZiOmqs9KLtib0tb_l1QbIFj34,1005
12
- fastlife/session/__init__.py,sha256=0BIArO93ylDlHhVUNyAv7UfVuh9nO9YRUh0YvNDc-IM,828
13
- fastlife/session/middleware.py,sha256=kCPQY8nODX1kXPDJzhgyZF4IIT4soG8WS7EqSYn2vEk,2930
14
- fastlife/session/serializer.py,sha256=3VwDafQ_dXttlf3Dj2WULhV0PVDBy0EvetVYpdcThM8,1169
12
+ fastlife/security/policy.py,sha256=5jV5nypy5O8XPFFRJ_bzG8Ltk5xcPWclkz23qiG1_I8,509
13
+ fastlife/session/__init__.py,sha256=OnzRCYRzc1lw9JB0UdKi-aRLPNT2n8mM8kwY1P4w7uU,838
14
+ fastlife/session/middleware.py,sha256=tz5ANgTKXaIqXGaqNM9czfSRdkfwRChODmKAqWGzP60,3050
15
+ fastlife/session/serializer.py,sha256=qpVnHQjYTxw3aOnoEOKIjOFJg2z45KjiX5sipWk2gws,1458
15
16
  fastlife/shared_utils/infer.py,sha256=_hmGzu84VlZAkdw_owkW8eHknULqH3MLDBlXj7LkEsc,466
16
17
  fastlife/shared_utils/resolver.py,sha256=cZYcaV27sIC5vLc_xo-yj0S3nTimeY5KRZTanHY6e_Y,1295
17
18
  fastlife/templates/base.jinja2,sha256=JOHL2bexmNfaRwStdOnd_oLW1YhKnhQbNQfVItMVBkM,112
@@ -40,10 +41,10 @@ fastlife/templating/renderer/widgets/sequence.py,sha256=b2e7YOU4BCASJ46peYtSaaoq
40
41
  fastlife/templating/renderer/widgets/text.py,sha256=6sQ9tlmWVn8-bogSbb8m2gAL-1Lrkb026W5ekez4Jlc,789
41
42
  fastlife/templating/renderer/widgets/union.py,sha256=pT_Mcrb-_ZTZV3ZPkyQYdEW2AE3PglojXdYaMfrgZ0k,1645
42
43
  fastlife/testing/__init__.py,sha256=KgTlRI0g8z7HRpL7mD5QgI__LT9Y4QDSzKMlxJG3wNk,67
43
- fastlife/testing/testclient.py,sha256=p1VwviwQT4xstJSWejtuBvHDwmeqNCjwLvWLtkkCl-I,7979
44
+ fastlife/testing/testclient.py,sha256=GBSL9xECrLf4jkFiZCT2h0NajFNBSVuFY4zm5MyolBw,10095
44
45
  fastlife/views/__init__.py,sha256=nn4B_8YTbTmhGPvSd20yyKK_9Dh1Pfh_Iq7z6iK8-CE,154
45
- fastlife/views/pydantic_form.py,sha256=4Et5oJ1dFiQlUY1msJA85aHZG5ngZPPdzaZ77W1Py8I,835
46
- fastlifeweb-0.2.0.dist-info/LICENSE,sha256=F75xSseSKMwqzFj8rswYU6NWS3VoWOc_gY3fJYf9_LI,1504
47
- fastlifeweb-0.2.0.dist-info/METADATA,sha256=qQ5ySuy9Lfhd06rarLgVNDz-O5CuWQiq3vndAanG7ZE,1471
48
- fastlifeweb-0.2.0.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
49
- fastlifeweb-0.2.0.dist-info/RECORD,,
46
+ fastlife/views/pydantic_form.py,sha256=5fBmw94wYLKuEN-YwqTnCLDDCq-TEzeJNmzWdsB2I3M,887
47
+ fastlifeweb-0.2.2.dist-info/LICENSE,sha256=F75xSseSKMwqzFj8rswYU6NWS3VoWOc_gY3fJYf9_LI,1504
48
+ fastlifeweb-0.2.2.dist-info/METADATA,sha256=AU7ZtKIfvQIFIaqSJhU5D4Fk5jzo4M-L2Kf5VGpTJBc,1750
49
+ fastlifeweb-0.2.2.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
50
+ fastlifeweb-0.2.2.dist-info/RECORD,,