fastlifeweb 0.1.1__tar.gz → 0.1.2__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 (49) hide show
  1. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/PKG-INFO +2 -1
  2. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/pyproject.toml +3 -1
  3. fastlifeweb-0.1.2/src/fastlife/configurator/base.py +9 -0
  4. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/src/fastlife/configurator/configurator.py +19 -1
  5. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/src/fastlife/configurator/settings.py +13 -0
  6. fastlifeweb-0.1.2/src/fastlife/session/__init__.py +21 -0
  7. fastlifeweb-0.1.2/src/fastlife/session/middleware.py +70 -0
  8. fastlifeweb-0.1.2/src/fastlife/session/serializer.py +39 -0
  9. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/src/fastlife/testing/testclient.py +61 -19
  10. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/LICENSE +0 -0
  11. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/README.md +0 -0
  12. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/src/fastlife/__init__.py +0 -0
  13. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/src/fastlife/configurator/__init__.py +0 -0
  14. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/src/fastlife/configurator/registry.py +0 -0
  15. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/src/fastlife/py.typed +0 -0
  16. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/src/fastlife/request/__init__.py +0 -0
  17. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/src/fastlife/request/form_data.py +0 -0
  18. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/src/fastlife/security/__init__.py +0 -0
  19. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/src/fastlife/security/csrf.py +0 -0
  20. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/src/fastlife/shared_utils/infer.py +0 -0
  21. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/src/fastlife/shared_utils/resolver.py +0 -0
  22. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/src/fastlife/templates/base.jinja2 +0 -0
  23. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/src/fastlife/templates/globals.jinja2 +0 -0
  24. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/src/fastlife/templates/pydantic_form/boolean.jinja2 +0 -0
  25. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/src/fastlife/templates/pydantic_form/dropdown.jinja2 +0 -0
  26. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/src/fastlife/templates/pydantic_form/hidden.jinja2 +0 -0
  27. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/src/fastlife/templates/pydantic_form/model.jinja2 +0 -0
  28. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/src/fastlife/templates/pydantic_form/sequence.jinja2 +0 -0
  29. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/src/fastlife/templates/pydantic_form/text.jinja2 +0 -0
  30. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/src/fastlife/templates/pydantic_form/union.jinja2 +0 -0
  31. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/src/fastlife/templates/pydantic_form/widget.jinja2 +0 -0
  32. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/src/fastlife/templating/__init__.py +0 -0
  33. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/src/fastlife/templating/binding.py +0 -0
  34. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/src/fastlife/templating/renderer/__init__.py +0 -0
  35. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/src/fastlife/templating/renderer/abstract.py +0 -0
  36. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/src/fastlife/templating/renderer/jinja2.py +0 -0
  37. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/src/fastlife/templating/renderer/widgets/__init__.py +0 -0
  38. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/src/fastlife/templating/renderer/widgets/base.py +0 -0
  39. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/src/fastlife/templating/renderer/widgets/boolean.py +0 -0
  40. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/src/fastlife/templating/renderer/widgets/dropdown.py +0 -0
  41. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/src/fastlife/templating/renderer/widgets/factory.py +0 -0
  42. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/src/fastlife/templating/renderer/widgets/hidden.py +0 -0
  43. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/src/fastlife/templating/renderer/widgets/model.py +0 -0
  44. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/src/fastlife/templating/renderer/widgets/sequence.py +0 -0
  45. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/src/fastlife/templating/renderer/widgets/text.py +0 -0
  46. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/src/fastlife/templating/renderer/widgets/union.py +0 -0
  47. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/src/fastlife/testing/__init__.py +0 -0
  48. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/src/fastlife/views/__init__.py +0 -0
  49. {fastlifeweb-0.1.1 → fastlifeweb-0.1.2}/src/fastlife/views/pydantic_form.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fastlifeweb
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: High-level web framework
5
5
  License: BSD-derived
6
6
  Author: Guillaume Gauvrit
@@ -19,6 +19,7 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
19
  Requires-Dist: beautifulsoup4[testing] (>=4.12.2,<5.0.0)
20
20
  Requires-Dist: behave (>=1.2.6,<2.0.0)
21
21
  Requires-Dist: fastapi (>=0.108.0,<0.109.0)
22
+ Requires-Dist: itsdangerous (>=2.1.2,<3.0.0)
22
23
  Requires-Dist: jinja2 (>=3.1.2,<4.0.0)
23
24
  Requires-Dist: markupsafe (>=2.1.3,<3.0.0)
24
25
  Requires-Dist: pydantic (>=2.3.0,<3.0.0)
@@ -13,7 +13,7 @@ classifiers = [
13
13
  "Topic :: Software Development :: Libraries :: Python Modules",
14
14
  "Topic :: Internet :: WWW/HTTP",
15
15
  ]
16
- version = "0.1.1"
16
+ version = "0.1.2"
17
17
 
18
18
  [tool.poetry.dependencies]
19
19
  python = "^3.11"
@@ -26,6 +26,7 @@ python-multipart = "^0.0.6"
26
26
  venusian = "^3.0.0"
27
27
  markupsafe = "^2.1.3"
28
28
  behave = "^1.2.6"
29
+ itsdangerous = "^2.1.2"
29
30
 
30
31
  [tool.poetry.group.dev.dependencies]
31
32
  beautifulsoup4 = "^4.12.2"
@@ -69,6 +70,7 @@ testpaths = ["tests"]
69
70
  [tool.coverage.report]
70
71
  exclude_lines = [
71
72
  "# coverage: ignore",
73
+ "\\s+\\.\\.\\.$",
72
74
  ]
73
75
 
74
76
  [build-system]
@@ -0,0 +1,9 @@
1
+ import abc
2
+
3
+ from starlette.types import Receive, Scope, Send
4
+
5
+
6
+ class AbstractMiddleware(abc.ABC):
7
+ @abc.abstractmethod
8
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
9
+ ...
@@ -7,13 +7,24 @@ import logging
7
7
  from enum import Enum
8
8
  from pathlib import Path
9
9
  from types import ModuleType
10
- from typing import TYPE_CHECKING, Any, Callable, Coroutine, List, Optional, Union
10
+ from typing import (
11
+ TYPE_CHECKING,
12
+ Any,
13
+ Callable,
14
+ Coroutine,
15
+ List,
16
+ Optional,
17
+ Self,
18
+ Type,
19
+ Union,
20
+ )
11
21
 
12
22
  import venusian # type: ignore
13
23
  from fastapi import Depends, FastAPI, Response
14
24
  from fastapi.datastructures import Default
15
25
  from fastapi.staticfiles import StaticFiles
16
26
 
27
+ from fastlife.configurator.base import AbstractMiddleware
17
28
  from fastlife.security.csrf import check_csrf
18
29
 
19
30
  from .settings import Settings
@@ -39,6 +50,7 @@ class Configurator:
39
50
  )
40
51
  self.scanner = venusian.Scanner(fastlife=self)
41
52
  self.include("fastlife.views")
53
+ self.include("fastlife.session")
42
54
 
43
55
  def get_app(self) -> FastAPI:
44
56
  return self._app
@@ -49,6 +61,12 @@ class Configurator:
49
61
  self.scanner.scan(module, categories=[VENUSIAN_CATEGORY]) # type: ignore
50
62
  return self
51
63
 
64
+ def add_middleware(
65
+ self, middleware_class: Type[AbstractMiddleware], **options: Any
66
+ ) -> Self:
67
+ self._app.add_middleware(middleware_class, **options)
68
+ return self
69
+
52
70
  def add_route(
53
71
  self,
54
72
  path: str,
@@ -1,3 +1,6 @@
1
+ from datetime import timedelta
2
+ from typing import Literal
3
+
1
4
  from pydantic import Field
2
5
  from pydantic_settings import BaseSettings, SettingsConfigDict
3
6
 
@@ -13,3 +16,13 @@ class Settings(BaseSettings):
13
16
  )
14
17
  form_data_model_prefix: str = Field(default="payload")
15
18
  csrf_token_name: str = Field(default="csrf_token")
19
+
20
+ session_secret_key: str = Field(default="")
21
+ session_cookie_name: str = Field(default="flsess")
22
+ session_cookie_path: str = Field(default="/")
23
+ session_duration: timedelta = Field(default=timedelta(days=14))
24
+ session_cookie_same_site: Literal["lax", "strict", "none"] = Field(default="lax")
25
+ session_cookie_secure: bool = Field(default=False)
26
+ session_serializer: str = Field(
27
+ default="fastlife.session.serializer:SignedSessionSerializer"
28
+ )
@@ -0,0 +1,21 @@
1
+ from fastlife import Configurator, configure
2
+ from fastlife.shared_utils.resolver import resolve
3
+
4
+ from .middleware import SessionMiddleware
5
+
6
+
7
+ @configure
8
+ def includeme(config: Configurator) -> None:
9
+ settings = config.registry.settings
10
+ session_serializer = resolve(settings.session_serializer)
11
+ if settings.session_secret_key:
12
+ config.add_middleware(
13
+ SessionMiddleware,
14
+ cookie_name=settings.session_cookie_name,
15
+ secret_key=settings.session_secret_key,
16
+ duration=settings.session_duration,
17
+ cookie_path=settings.session_cookie_path,
18
+ cookie_same_site=settings.session_cookie_same_site,
19
+ cookie_secure=settings.session_cookie_secure,
20
+ serializer=session_serializer,
21
+ )
@@ -0,0 +1,70 @@
1
+ from datetime import timedelta
2
+ from typing import Literal, Type
3
+
4
+ from starlette.datastructures import MutableHeaders
5
+ from starlette.requests import HTTPConnection
6
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
7
+
8
+ from fastlife.configurator.base import AbstractMiddleware
9
+
10
+ from .serializer import AbsractSessionSerializer, SignedSessionSerializer
11
+
12
+
13
+ class SessionMiddleware(AbstractMiddleware):
14
+ def __init__(
15
+ self,
16
+ app: ASGIApp,
17
+ cookie_name: str,
18
+ secret_key: str,
19
+ duration: timedelta,
20
+ cookie_path: str = "/",
21
+ cookie_same_site: Literal["lax", "strict", "none"] = "lax",
22
+ cookie_secure: bool = False,
23
+ serializer: Type[AbsractSessionSerializer] = SignedSessionSerializer,
24
+ ) -> None:
25
+ self.app = app
26
+ self.serializer = serializer(secret_key, int(duration.total_seconds()))
27
+ self.cookie_name = cookie_name
28
+ self.max_age = int(duration.total_seconds())
29
+ self.path = cookie_path
30
+ self.security_flags = "httponly; samesite=" + cookie_same_site
31
+ if cookie_secure:
32
+ self.security_flags += "; secure"
33
+
34
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
35
+ if scope["type"] not in ("http", "websocket"): # pragma: no cover
36
+ await self.app(scope, receive, send)
37
+ return
38
+
39
+ connection = HTTPConnection(scope)
40
+ reset_session = False
41
+
42
+ if self.cookie_name in connection.cookies:
43
+ data = connection.cookies[self.cookie_name].encode("utf-8")
44
+ scope["session"], reset_session = self.serializer.deserialize(data)
45
+ else:
46
+ scope["session"] = {}
47
+
48
+ async def send_wrapper(message: Message) -> None:
49
+ if message["type"] == "http.response.start":
50
+ if scope["session"]:
51
+ # We have session data to persist.
52
+ data = self.serializer.serialize(scope["session"]).decode("utf-8")
53
+ headers = MutableHeaders(scope=message)
54
+ header_value = (
55
+ f"{self.cookie_name}={data}; path={self.path}; "
56
+ f"Max-Age={self.max_age}; {self.security_flags}"
57
+ )
58
+ headers.append("set-cookie", header_value)
59
+ elif reset_session:
60
+ # The session has been cleared.
61
+ headers = MutableHeaders(scope=message)
62
+ expires = "expires=Thu, 01 Jan 1970 00:00:00 GMT; "
63
+ header_value = (
64
+ f"{self.cookie_name}=; path={self.path}; "
65
+ f"{expires}{self.security_flags}"
66
+ )
67
+ headers.append("set-cookie", header_value)
68
+ await send(message)
69
+
70
+ await self.app(scope, receive, send_wrapper)
@@ -0,0 +1,39 @@
1
+ import abc
2
+ import json
3
+ from base64 import b64decode, b64encode
4
+ from typing import Any, Mapping, Tuple
5
+
6
+ import itsdangerous
7
+
8
+
9
+ class AbsractSessionSerializer(abc.ABC):
10
+ @abc.abstractmethod
11
+ def __init__(self, secret_key: str, max_age: int) -> None:
12
+ ...
13
+
14
+ @abc.abstractmethod
15
+ def serialize(self, data: Mapping[str, Any]) -> bytes:
16
+ ...
17
+
18
+ @abc.abstractmethod
19
+ def deserialize(self, data: bytes) -> Tuple[Mapping[str, Any], bool]:
20
+ ...
21
+
22
+
23
+ class SignedSessionSerializer(AbsractSessionSerializer):
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
+ dump = json.dumps(data).encode("utf-8")
30
+ encoded = b64encode(dump)
31
+ signed = self.signer.sign(encoded)
32
+ return signed
33
+
34
+ def deserialize(self, data: bytes) -> Tuple[Mapping[str, Any], bool]:
35
+ try:
36
+ data = self.signer.unsign(data, max_age=self.max_age)
37
+ except itsdangerous.BadSignature:
38
+ return {}, True
39
+ return json.loads(b64decode(data)), False
@@ -1,5 +1,5 @@
1
1
  import re
2
- from typing import Any, Mapping
2
+ from typing import Any, Literal, Mapping
3
3
  from urllib.parse import urlencode
4
4
 
5
5
  import bs4
@@ -30,13 +30,15 @@ class WebForm:
30
30
  assert self._form.find("button", string=re.compile(f".*{text}.*")) is not None
31
31
  return self
32
32
 
33
- def submit(self) -> "WebResponse":
33
+ def submit(self, follow_redirects: bool = True) -> "WebResponse":
34
34
  target = (
35
35
  self._form.attrs.get("hx-post")
36
36
  or self._form.attrs.get("post")
37
37
  or self._origin
38
38
  )
39
- return self._client.post(target, data=self._formdata)
39
+ return self._client.post(
40
+ target, data=self._formdata, follow_redirects=follow_redirects
41
+ )
40
42
 
41
43
 
42
44
  class WebResponse:
@@ -51,6 +53,10 @@ class WebResponse:
51
53
  def status_code(self) -> int:
52
54
  return self._response.status_code
53
55
 
56
+ @property
57
+ def is_redirect(self) -> int:
58
+ return 300 <= self._response.status_code < 400
59
+
54
60
  @property
55
61
  def content_type(self) -> str:
56
62
  return self._response.headers["content-type"]
@@ -133,25 +139,61 @@ class WebTestClient:
133
139
  def cookies(self, value: Cookies) -> None:
134
140
  self.testclient.cookies = value
135
141
 
136
- def get(self, url: str) -> WebResponse:
137
- resp = self.testclient.get(url, follow_redirects=False)
138
- if "set-cookie" in resp.headers:
139
- for name, cookie in resp.cookies.items():
140
- self.testclient.cookies.set(name, cookie)
141
- return WebResponse(
142
+ def request(
143
+ self,
144
+ method: Literal["GET", "POST"], # I am a browser
145
+ url: str,
146
+ *,
147
+ content: str | None = None,
148
+ headers: Mapping[str, str] | None = None,
149
+ max_redirects: int = 0,
150
+ ) -> WebResponse:
151
+ rawresp = self.testclient.request(
152
+ method=method,
153
+ url=url,
154
+ headers=headers,
155
+ content=content,
156
+ follow_redirects=False, # don't follow for cookie processing
157
+ )
158
+ # the wrapper client does not set cookies
159
+ # and does not set cookie while redirecting,
160
+ # so we reimplement it here
161
+ if "set-cookie" in rawresp.headers:
162
+ for name, cookie in rawresp.cookies.items():
163
+ self.cookies.set(name, cookie)
164
+ resp = WebResponse(
142
165
  self,
143
166
  url,
144
- resp,
167
+ rawresp,
145
168
  )
169
+ if resp.is_redirect and max_redirects > 0:
170
+ if resp.status_code != 307:
171
+ method = "GET"
172
+ headers = None
173
+ content = None
174
+ return self.request(
175
+ method=method,
176
+ url=resp.headers["location"],
177
+ content=content,
178
+ headers=headers,
179
+ max_redirects=max_redirects - 1,
180
+ )
181
+ return resp
146
182
 
147
- def post(self, url: str, data: Mapping[str, Any]) -> WebResponse:
148
- return WebResponse(
149
- self,
183
+ def get(self, url: str, follow_redirects: bool = False) -> WebResponse:
184
+ return self.request(
185
+ "GET",
186
+ url,
187
+ max_redirects=int(follow_redirects) * 10,
188
+ )
189
+
190
+ def post(
191
+ self, url: str, data: Mapping[str, Any], follow_redirects: bool = True
192
+ ) -> WebResponse:
193
+ return self.request(
194
+ "POST",
150
195
  url,
151
- self.testclient.post(
152
- url,
153
- content=urlencode(data),
154
- headers={"Content-Type": "application/x-www-form-urlencoded"},
155
- follow_redirects=False,
156
- ),
196
+ content=urlencode(data),
197
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
198
+ max_redirects=int(follow_redirects) * 10,
157
199
  )
File without changes
File without changes