fastlifeweb 0.1.1__py3-none-any.whl → 0.1.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.
- fastlife/configurator/base.py +9 -0
- fastlife/configurator/configurator.py +19 -1
- fastlife/configurator/settings.py +13 -0
- fastlife/session/__init__.py +21 -0
- fastlife/session/middleware.py +70 -0
- fastlife/session/serializer.py +39 -0
- fastlife/testing/testclient.py +61 -19
- {fastlifeweb-0.1.1.dist-info → fastlifeweb-0.1.2.dist-info}/METADATA +2 -1
- {fastlifeweb-0.1.1.dist-info → fastlifeweb-0.1.2.dist-info}/RECORD +11 -7
- {fastlifeweb-0.1.1.dist-info → fastlifeweb-0.1.2.dist-info}/LICENSE +0 -0
- {fastlifeweb-0.1.1.dist-info → fastlifeweb-0.1.2.dist-info}/WHEEL +0 -0
@@ -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
|
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
|
fastlife/testing/testclient.py
CHANGED
@@ -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(
|
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
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
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
|
-
|
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
|
148
|
-
return
|
149
|
-
|
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
|
-
|
152
|
-
|
153
|
-
|
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
|
)
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: fastlifeweb
|
3
|
-
Version: 0.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)
|
@@ -1,13 +1,17 @@
|
|
1
1
|
fastlife/__init__.py,sha256=__RsTYXTkhcxwHRvT1xWQ5XfygdwBtotbrff71lu-kk,190
|
2
2
|
fastlife/configurator/__init__.py,sha256=2EPjM1o5iHJIViPwgJjaPQS3pMhE-9dik_mm53eX2DY,91
|
3
|
-
fastlife/configurator/
|
3
|
+
fastlife/configurator/base.py,sha256=2ahvTudLmD99YQjnIeGN5JDPCSl3k-mauu7bsSEB5RE,216
|
4
|
+
fastlife/configurator/configurator.py,sha256=3LMeHFhQxCVFDFgAJLSVv8BJJqc5-J-gsnWARJoM-2I,5155
|
4
5
|
fastlife/configurator/registry.py,sha256=YRewgqw6FoKHa3vjliwm2_XshydJKPJL_IAnvldfp3o,1115
|
5
|
-
fastlife/configurator/settings.py,sha256=
|
6
|
+
fastlife/configurator/settings.py,sha256=if2Z82U485rBXtkz0qQA4ArHw3oRYgDuFNLHTaCdTXo,1168
|
6
7
|
fastlife/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
7
8
|
fastlife/request/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
8
9
|
fastlife/request/form_data.py,sha256=7gV2hLeFyhPRrNYL_UlRAqM-oqxSu2-VvxKHMv5F900,3849
|
9
10
|
fastlife/security/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
10
11
|
fastlife/security/csrf.py,sha256=-2XVfkSpmU1HJB2-_tZiOmqs9KLtib0tb_l1QbIFj34,1005
|
12
|
+
fastlife/session/__init__.py,sha256=eXpF1k6eVjuRsGG-VhGNPt1RBq5Xkd6O5alSy_vSyD8,780
|
13
|
+
fastlife/session/middleware.py,sha256=2xvaMMaEaP8Pcx8MMzIpNkho-1aHuDGiyg-25jx93cY,2873
|
14
|
+
fastlife/session/serializer.py,sha256=3VwDafQ_dXttlf3Dj2WULhV0PVDBy0EvetVYpdcThM8,1169
|
11
15
|
fastlife/shared_utils/infer.py,sha256=_hmGzu84VlZAkdw_owkW8eHknULqH3MLDBlXj7LkEsc,466
|
12
16
|
fastlife/shared_utils/resolver.py,sha256=cZYcaV27sIC5vLc_xo-yj0S3nTimeY5KRZTanHY6e_Y,1295
|
13
17
|
fastlife/templates/base.jinja2,sha256=JOHL2bexmNfaRwStdOnd_oLW1YhKnhQbNQfVItMVBkM,112
|
@@ -36,10 +40,10 @@ fastlife/templating/renderer/widgets/sequence.py,sha256=b2e7YOU4BCASJ46peYtSaaoq
|
|
36
40
|
fastlife/templating/renderer/widgets/text.py,sha256=6sQ9tlmWVn8-bogSbb8m2gAL-1Lrkb026W5ekez4Jlc,789
|
37
41
|
fastlife/templating/renderer/widgets/union.py,sha256=pT_Mcrb-_ZTZV3ZPkyQYdEW2AE3PglojXdYaMfrgZ0k,1645
|
38
42
|
fastlife/testing/__init__.py,sha256=KgTlRI0g8z7HRpL7mD5QgI__LT9Y4QDSzKMlxJG3wNk,67
|
39
|
-
fastlife/testing/testclient.py,sha256=
|
43
|
+
fastlife/testing/testclient.py,sha256=JZog4nYpPXVjPk4hs13nnVODyQFznnieCLqO3O9jGKU,6166
|
40
44
|
fastlife/views/__init__.py,sha256=nn4B_8YTbTmhGPvSd20yyKK_9Dh1Pfh_Iq7z6iK8-CE,154
|
41
45
|
fastlife/views/pydantic_form.py,sha256=4Et5oJ1dFiQlUY1msJA85aHZG5ngZPPdzaZ77W1Py8I,835
|
42
|
-
fastlifeweb-0.1.
|
43
|
-
fastlifeweb-0.1.
|
44
|
-
fastlifeweb-0.1.
|
45
|
-
fastlifeweb-0.1.
|
46
|
+
fastlifeweb-0.1.2.dist-info/LICENSE,sha256=F75xSseSKMwqzFj8rswYU6NWS3VoWOc_gY3fJYf9_LI,1504
|
47
|
+
fastlifeweb-0.1.2.dist-info/METADATA,sha256=XWUf_kT68w8DIgNGleiYlmy3Jwpkx7X0MB_6NJj7CJM,1471
|
48
|
+
fastlifeweb-0.1.2.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
|
49
|
+
fastlifeweb-0.1.2.dist-info/RECORD,,
|
File without changes
|
File without changes
|