fastlifeweb 0.21.0__py3-none-any.whl → 0.22.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.
- CHANGELOG.md +11 -1
- fastlife/__init__.py +49 -13
- fastlife/adapters/__init__.py +1 -1
- fastlife/adapters/fastapi/__init__.py +9 -0
- fastlife/adapters/fastapi/form.py +26 -0
- fastlife/{request → adapters/fastapi}/form_data.py +1 -1
- fastlife/{request → adapters/fastapi}/localizer.py +4 -2
- fastlife/adapters/fastapi/request.py +33 -0
- fastlife/{routing → adapters/fastapi/routing}/route.py +3 -3
- fastlife/{routing → adapters/fastapi/routing}/router.py +1 -1
- fastlife/adapters/itsdangerous/__init__.py +3 -0
- fastlife/adapters/itsdangerous/session.py +50 -0
- fastlife/adapters/jinjax/jinjax_ext/inspectable_component.py +7 -7
- fastlife/adapters/jinjax/jinjax_ext/jinjax_doc.py +1 -1
- fastlife/adapters/jinjax/renderer.py +5 -5
- fastlife/adapters/jinjax/widget_factory/factory.py +13 -3
- fastlife/adapters/jinjax/widgets/base.py +1 -1
- fastlife/adapters/jinjax/widgets/model.py +1 -1
- fastlife/adapters/jinjax/widgets/sequence.py +1 -1
- fastlife/adapters/jinjax/widgets/union.py +1 -1
- fastlife/components/Form.jinja +1 -1
- fastlife/components/pydantic_form/FatalError.jinja +8 -0
- fastlife/config/__init__.py +3 -6
- fastlife/config/configurator.py +18 -16
- fastlife/config/resources.py +1 -2
- fastlife/config/views.py +2 -2
- fastlife/domain/model/asgi.py +3 -0
- fastlife/{request → domain/model}/form.py +13 -22
- fastlife/{request → domain/model}/request.py +10 -32
- fastlife/domain/model/security_policy.py +105 -0
- fastlife/middlewares/base.py +1 -1
- fastlife/middlewares/reverse_proxy/x_forwarded.py +1 -2
- fastlife/middlewares/session/__init__.py +2 -2
- fastlife/middlewares/session/middleware.py +4 -3
- fastlife/middlewares/session/serializer.py +0 -44
- fastlife/{services/policy.py → service/check_permission.py} +1 -1
- fastlife/{security → service}/csrf.py +2 -2
- fastlife/service/locale_negociator.py +23 -0
- fastlife/{config → service}/registry.py +13 -7
- fastlife/service/security_policy.py +100 -0
- fastlife/{services → service}/templates.py +1 -6
- fastlife/{services → service}/translations.py +3 -0
- fastlife/{config/settings.py → settings.py} +6 -12
- fastlife/shared_utils/infer.py +24 -1
- fastlife/{templates/constants.py → template_globals.py} +2 -2
- fastlife/testing/testclient.py +2 -2
- fastlife/views/__init__.py +1 -0
- fastlife/views/pydantic_form.py +6 -0
- {fastlifeweb-0.21.0.dist-info → fastlifeweb-0.22.1.dist-info}/METADATA +1 -1
- {fastlifeweb-0.21.0.dist-info → fastlifeweb-0.22.1.dist-info}/RECORD +57 -53
- tailwind.config.js +1 -1
- fastlife/request/__init__.py +0 -5
- fastlife/security/__init__.py +0 -1
- fastlife/security/policy.py +0 -188
- fastlife/services/locale_negociator.py +0 -26
- fastlife/templates/__init__.py +0 -7
- fastlife/templates/inline.py +0 -26
- /fastlife/{routing → adapters/fastapi/routing}/__init__.py +0 -0
- /fastlife/domain/model/{security.py → csrf.py} +0 -0
- /fastlife/{services → service}/__init__.py +0 -0
- {fastlifeweb-0.21.0.dist-info → fastlifeweb-0.22.1.dist-info}/WHEEL +0 -0
- {fastlifeweb-0.21.0.dist-info → fastlifeweb-0.22.1.dist-info}/entry_points.txt +0 -0
- {fastlifeweb-0.21.0.dist-info → fastlifeweb-0.22.1.dist-info}/licenses/LICENSE +0 -0
@@ -1,21 +1,21 @@
|
|
1
1
|
"""HTTP Request representation in a python object."""
|
2
2
|
|
3
|
-
from typing import TYPE_CHECKING,
|
3
|
+
from typing import TYPE_CHECKING, Any, Generic
|
4
4
|
|
5
|
-
from
|
6
|
-
from fastapi.params import Depends
|
5
|
+
from starlette.requests import Request as BaseRequest
|
7
6
|
|
8
|
-
from fastlife.
|
9
|
-
from fastlife.domain.model.
|
7
|
+
from fastlife.domain.model.csrf import CSRFToken, create_csrf_token
|
8
|
+
from fastlife.domain.model.security_policy import TUser
|
9
|
+
from fastlife.service.registry import TRegistry
|
10
10
|
|
11
11
|
if TYPE_CHECKING:
|
12
|
-
from fastlife.
|
12
|
+
from fastlife.service.security_policy import ( # coverage: ignore
|
13
13
|
AbstractSecurityPolicy,
|
14
14
|
HasPermission,
|
15
15
|
)
|
16
16
|
|
17
17
|
|
18
|
-
class GenericRequest(
|
18
|
+
class GenericRequest(BaseRequest, Generic[TUser, TRegistry]):
|
19
19
|
"""HTTP Request representation."""
|
20
20
|
|
21
21
|
registry: TRegistry
|
@@ -23,12 +23,12 @@ class GenericRequest(FastAPIRequest, Generic[TRegistry]):
|
|
23
23
|
locale_name: str
|
24
24
|
"""Request locale used for the i18n of the response."""
|
25
25
|
|
26
|
-
security_policy: "AbstractSecurityPolicy[
|
26
|
+
security_policy: "AbstractSecurityPolicy[TUser, TRegistry] | None"
|
27
27
|
"""Request locale used for the i18n of the response."""
|
28
28
|
|
29
29
|
renderer_globals: dict[str, Any]
|
30
30
|
|
31
|
-
def __init__(self, registry: TRegistry, request:
|
31
|
+
def __init__(self, registry: TRegistry, request: BaseRequest) -> None:
|
32
32
|
super().__init__(request.scope, request.receive)
|
33
33
|
self.registry = registry
|
34
34
|
self.locale_name = registry.locale_negociator(self)
|
@@ -64,29 +64,7 @@ class GenericRequest(FastAPIRequest, Generic[TRegistry]):
|
|
64
64
|
if self.security_policy is None:
|
65
65
|
raise RuntimeError(
|
66
66
|
f"Request {self.url.path} require a security policy, "
|
67
|
-
"explicit fastlife.
|
67
|
+
"explicit fastlife.service.security_policy.InsecurePolicy is required."
|
68
68
|
)
|
69
69
|
|
70
70
|
return await self.security_policy.has_permission(permission)
|
71
|
-
|
72
|
-
|
73
|
-
def get_request(request: FastAPIRequest) -> GenericRequest[Any]:
|
74
|
-
return request # type: ignore
|
75
|
-
|
76
|
-
|
77
|
-
Request = Annotated[GenericRequest[DefaultRegistry], Depends(get_request)]
|
78
|
-
"""A request that is associated to the default registry."""
|
79
|
-
# FastAPI handle its Request objects using a lenient_issubclass,
|
80
|
-
# basically a issubclass(Request), doe to the Generic[T], it does not work.
|
81
|
-
|
82
|
-
|
83
|
-
AnyRequest = Annotated[GenericRequest[Any], Depends(get_request)]
|
84
|
-
"""A request version that is associated to the any registry."""
|
85
|
-
|
86
|
-
|
87
|
-
def get_registry(request: Request) -> DefaultRegistry:
|
88
|
-
return request.registry
|
89
|
-
|
90
|
-
|
91
|
-
Registry = Annotated[DefaultRegistry, Depends(get_registry)]
|
92
|
-
"""FastAPI dependency to access to the registry."""
|
@@ -0,0 +1,105 @@
|
|
1
|
+
"""Security policy."""
|
2
|
+
|
3
|
+
import logging
|
4
|
+
from collections.abc import Callable, Coroutine
|
5
|
+
from typing import Any, Literal, TypeVar
|
6
|
+
|
7
|
+
from fastapi import HTTPException
|
8
|
+
from starlette.status import HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN
|
9
|
+
|
10
|
+
CheckPermissionHook = Callable[..., Coroutine[Any, Any, None]] | Callable[..., None]
|
11
|
+
CheckPermission = Callable[[str], CheckPermissionHook]
|
12
|
+
|
13
|
+
TUser = TypeVar("TUser")
|
14
|
+
|
15
|
+
log = logging.getLogger(__name__)
|
16
|
+
|
17
|
+
|
18
|
+
class Unauthorized(HTTPException):
|
19
|
+
"""An exception raised to stop a request exectution and return an HTTP Error."""
|
20
|
+
|
21
|
+
def __init__(
|
22
|
+
self,
|
23
|
+
status_code: int = HTTP_401_UNAUTHORIZED,
|
24
|
+
detail: str = "Unauthorized",
|
25
|
+
headers: dict[str, str] | None = None,
|
26
|
+
) -> None:
|
27
|
+
super().__init__(status_code, detail, headers)
|
28
|
+
|
29
|
+
|
30
|
+
class Forbidden(HTTPException):
|
31
|
+
"""An exception raised to stop a request exectution and return an HTTP Error."""
|
32
|
+
|
33
|
+
def __init__(
|
34
|
+
self,
|
35
|
+
status_code: int = HTTP_403_FORBIDDEN,
|
36
|
+
detail: str = "Forbidden",
|
37
|
+
headers: dict[str, str] | None = None,
|
38
|
+
) -> None:
|
39
|
+
super().__init__(status_code, detail, headers)
|
40
|
+
|
41
|
+
|
42
|
+
class BoolMeta(type):
|
43
|
+
def __bool__(cls) -> bool:
|
44
|
+
return cls.kind == "allowed" # type: ignore
|
45
|
+
|
46
|
+
def __repr__(cls) -> str:
|
47
|
+
return cls.reason # type: ignore
|
48
|
+
|
49
|
+
|
50
|
+
class HasPermission(int, metaclass=BoolMeta):
|
51
|
+
"""
|
52
|
+
A type used to know if a permission is allowed or not.
|
53
|
+
|
54
|
+
It behave has a boolean, but 3 possibilities exists defind has 3 sub-types
|
55
|
+
{class}`Allowed` {class}`Unauthenticated` or {class}`Denied`.
|
56
|
+
|
57
|
+
In many cases Unauthenticated call may redirect to a login page,
|
58
|
+
where authenticated user are not redirected. they have an error message,
|
59
|
+
or the frontend may use the information to adapt its interface.
|
60
|
+
"""
|
61
|
+
|
62
|
+
kind: Literal["allowed", "unauthenticated", "denied"]
|
63
|
+
"""
|
64
|
+
Identified basic information of the response.
|
65
|
+
It distinguished unauthenticated and denied to eventually raised 401 over 403 error.
|
66
|
+
"""
|
67
|
+
reason: str
|
68
|
+
"""A human explanation of the response."""
|
69
|
+
|
70
|
+
def __new__(cls, reason: str) -> "HasPermission":
|
71
|
+
instance = super().__new__(cls)
|
72
|
+
instance.reason = reason
|
73
|
+
return instance
|
74
|
+
|
75
|
+
def __repr__(self) -> str:
|
76
|
+
return self.reason
|
77
|
+
|
78
|
+
def __bool__(self) -> bool:
|
79
|
+
return self.kind == "allowed"
|
80
|
+
|
81
|
+
|
82
|
+
class Allowed(HasPermission):
|
83
|
+
"""Represent a permission check result that is allowed."""
|
84
|
+
|
85
|
+
kind = "allowed"
|
86
|
+
reason = "Allowed"
|
87
|
+
|
88
|
+
|
89
|
+
class Unauthenticated(HasPermission):
|
90
|
+
"""
|
91
|
+
Represent a permission check result that is not allowed due to
|
92
|
+
missing authentication mechanism.
|
93
|
+
"""
|
94
|
+
|
95
|
+
kind = "unauthenticated"
|
96
|
+
reason = "Authentication required"
|
97
|
+
|
98
|
+
|
99
|
+
class Denied(HasPermission):
|
100
|
+
"""
|
101
|
+
Represent a permission check result that is not allowed due to lack of permission.
|
102
|
+
"""
|
103
|
+
|
104
|
+
kind = "denied"
|
105
|
+
reason = "Access denied to this resource"
|
fastlife/middlewares/base.py
CHANGED
@@ -1,8 +1,7 @@
|
|
1
1
|
import logging
|
2
2
|
from collections.abc import Sequence
|
3
3
|
|
4
|
-
from
|
5
|
-
|
4
|
+
from fastlife.domain.model.asgi import ASGIApp, Receive, Scope, Send
|
6
5
|
from fastlife.middlewares.base import AbstractMiddleware
|
7
6
|
|
8
7
|
log = logging.getLogger(__name__)
|
@@ -15,9 +15,9 @@ from fastlife import Configurator, configure
|
|
15
15
|
from fastlife.shared_utils.resolver import resolve
|
16
16
|
|
17
17
|
from .middleware import SessionMiddleware
|
18
|
-
from .serializer import AbsractSessionSerializer
|
18
|
+
from .serializer import AbsractSessionSerializer
|
19
19
|
|
20
|
-
__all__ = ["
|
20
|
+
__all__ = ["AbsractSessionSerializer", "SessionMiddleware"]
|
21
21
|
|
22
22
|
|
23
23
|
@configure
|
@@ -5,11 +5,11 @@ from typing import Literal
|
|
5
5
|
|
6
6
|
from starlette.datastructures import MutableHeaders
|
7
7
|
from starlette.requests import HTTPConnection
|
8
|
-
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
9
8
|
|
9
|
+
from fastlife.domain.model.asgi import ASGIApp, Message, Receive, Scope, Send
|
10
10
|
from fastlife.middlewares.base import AbstractMiddleware
|
11
11
|
|
12
|
-
from .serializer import AbsractSessionSerializer
|
12
|
+
from .serializer import AbsractSessionSerializer
|
13
13
|
|
14
14
|
|
15
15
|
class SessionMiddleware(AbstractMiddleware):
|
@@ -17,6 +17,7 @@ class SessionMiddleware(AbstractMiddleware):
|
|
17
17
|
|
18
18
|
def __init__(
|
19
19
|
self,
|
20
|
+
*,
|
20
21
|
app: ASGIApp,
|
21
22
|
cookie_name: str,
|
22
23
|
secret_key: str,
|
@@ -25,7 +26,7 @@ class SessionMiddleware(AbstractMiddleware):
|
|
25
26
|
cookie_same_site: Literal["lax", "strict", "none"] = "lax",
|
26
27
|
cookie_secure: bool = False,
|
27
28
|
cookie_domain: str = "",
|
28
|
-
serializer: type[AbsractSessionSerializer]
|
29
|
+
serializer: type[AbsractSessionSerializer],
|
29
30
|
) -> None:
|
30
31
|
self.app = app
|
31
32
|
self.max_age = int(duration.total_seconds())
|
@@ -1,13 +1,9 @@
|
|
1
1
|
"""Serialize session."""
|
2
2
|
|
3
3
|
import abc
|
4
|
-
import json
|
5
|
-
from base64 import b64decode, b64encode
|
6
4
|
from collections.abc import Mapping
|
7
5
|
from typing import Any
|
8
6
|
|
9
|
-
import itsdangerous
|
10
|
-
|
11
7
|
|
12
8
|
class AbsractSessionSerializer(abc.ABC):
|
13
9
|
"""Session serializer base class"""
|
@@ -24,43 +20,3 @@ class AbsractSessionSerializer(abc.ABC):
|
|
24
20
|
def deserialize(self, data: bytes) -> tuple[Mapping[str, Any], bool]:
|
25
21
|
"""Derialize the session raw bytes content and return it as a mapping."""
|
26
22
|
...
|
27
|
-
|
28
|
-
|
29
|
-
class SignedSessionSerializer(AbsractSessionSerializer):
|
30
|
-
"""
|
31
|
-
The default fastlife session serializer.
|
32
|
-
|
33
|
-
It's based on the itsdangerous package to sign the session with a secret key.
|
34
|
-
|
35
|
-
:param secret_key: a secret used to sign the session payload.
|
36
|
-
|
37
|
-
:param max_age: session lifetime in seconds.
|
38
|
-
"""
|
39
|
-
|
40
|
-
def __init__(self, secret_key: str, max_age: int) -> None:
|
41
|
-
self.signer = itsdangerous.TimestampSigner(secret_key)
|
42
|
-
self.max_age = max_age
|
43
|
-
|
44
|
-
def serialize(self, data: Mapping[str, Any]) -> bytes:
|
45
|
-
"""Serialize and sign the session."""
|
46
|
-
dump = json.dumps(data).encode("utf-8")
|
47
|
-
encoded = b64encode(dump)
|
48
|
-
signed = self.signer.sign(encoded)
|
49
|
-
return signed
|
50
|
-
|
51
|
-
def deserialize(self, data: bytes) -> tuple[Mapping[str, Any], bool]:
|
52
|
-
"""Deserialize the session.
|
53
|
-
|
54
|
-
If the signature is incorect, the session restart from the begining.
|
55
|
-
No exception raised.
|
56
|
-
"""
|
57
|
-
try:
|
58
|
-
data = self.signer.unsign(data, max_age=self.max_age)
|
59
|
-
# We can't deserialize something wrong since the serialize
|
60
|
-
# is signing the content.
|
61
|
-
# If the signature key is compromise and we have invalid payload,
|
62
|
-
# raising exceptions here is fine, it's dangerous afterall.
|
63
|
-
session = json.loads(b64decode(data))
|
64
|
-
except itsdangerous.BadSignature:
|
65
|
-
return {}, True
|
66
|
-
return session, False
|
@@ -26,7 +26,7 @@ def check_permission(permission_name: str) -> CheckPermissionHook:
|
|
26
26
|
if request.security_policy is None:
|
27
27
|
raise RuntimeError(
|
28
28
|
f"Request {request.url.path} require a security policy, "
|
29
|
-
"explicit fastlife.
|
29
|
+
"explicit fastlife.service.security_policy.InsecurePolicy is required"
|
30
30
|
)
|
31
31
|
allowed = await request.security_policy.has_permission(permission_name)
|
32
32
|
match allowed.kind:
|
@@ -6,7 +6,7 @@ Fast life did not reinvent the wheel on CSRF Protection.
|
|
6
6
|
It use the good old method. A CSRF token is saved in a cookie.
|
7
7
|
Forms post the CSRF token, and the token in the cookies and the form must match
|
8
8
|
to process the request, otherwise an exception
|
9
|
-
{class}`fastlife.
|
9
|
+
{class}`fastlife.service.csrf.CSRFAttack` is raised.
|
10
10
|
|
11
11
|
The cookie named is configurabllefia the settings
|
12
12
|
:attr:`fastlife.config.settings.Settings.csrf_token_name`
|
@@ -21,7 +21,7 @@ no way to prevent to set the cookie in the request.
|
|
21
21
|
from collections.abc import Callable, Coroutine
|
22
22
|
from typing import Any
|
23
23
|
|
24
|
-
from fastlife.request import Request
|
24
|
+
from fastlife.adapters.fastapi.request import Request
|
25
25
|
|
26
26
|
|
27
27
|
class CSRFAttack(Exception):
|
@@ -0,0 +1,23 @@
|
|
1
|
+
"""Find the localization gor the given request."""
|
2
|
+
|
3
|
+
from collections.abc import Callable
|
4
|
+
from typing import Any
|
5
|
+
|
6
|
+
from fastlife.settings import Settings
|
7
|
+
|
8
|
+
LocaleName = str
|
9
|
+
"""The LocaleName is a locale such as en, fr that will be consume for translations."""
|
10
|
+
|
11
|
+
from fastlife.adapters.fastapi.request import GenericRequest # coverage: ignore
|
12
|
+
|
13
|
+
LocaleNegociator = Callable[[GenericRequest[Any, Any]], LocaleName] # coverage: ignore
|
14
|
+
"""Interface to implement to negociate a locale""" # coverage: ignore
|
15
|
+
|
16
|
+
|
17
|
+
def default_negociator(settings: Settings) -> LocaleNegociator:
|
18
|
+
"""The default local negociator return the locale set in the conf."""
|
19
|
+
|
20
|
+
def locale_negociator(request: "GenericRequest[Any, Any]") -> str:
|
21
|
+
return settings.default_locale
|
22
|
+
|
23
|
+
return locale_negociator
|
@@ -1,15 +1,14 @@
|
|
1
1
|
from collections.abc import Mapping
|
2
2
|
from typing import TYPE_CHECKING, Generic, TypeVar
|
3
3
|
|
4
|
-
from fastlife.services.locale_negociator import LocaleNegociator, default_negociator
|
5
|
-
from fastlife.services.translations import LocalizerFactory
|
6
|
-
|
7
4
|
if TYPE_CHECKING:
|
8
|
-
from fastlife.
|
5
|
+
from fastlife.service.locale_negociator import LocaleNegociator # coverage: ignore
|
6
|
+
from fastlife.service.templates import ( # coverage: ignore
|
9
7
|
AbstractTemplateRendererFactory, # coverage: ignore
|
10
8
|
) # coverage: ignore
|
9
|
+
from fastlife.service.translations import LocalizerFactory # coverage: ignore
|
11
10
|
|
12
|
-
from .settings import Settings
|
11
|
+
from fastlife.settings import Settings
|
13
12
|
|
14
13
|
TSettings = TypeVar("TSettings", bound=Settings, covariant=True)
|
15
14
|
"""
|
@@ -24,11 +23,18 @@ class GenericRegistry(Generic[TSettings]):
|
|
24
23
|
"""
|
25
24
|
|
26
25
|
settings: Settings
|
26
|
+
"""Application settings."""
|
27
27
|
renderers: Mapping[str, "AbstractTemplateRendererFactory"]
|
28
|
-
|
29
|
-
|
28
|
+
"""Registered template engine."""
|
29
|
+
locale_negociator: "LocaleNegociator"
|
30
|
+
"""Used to fine the best language for the response."""
|
31
|
+
localizer: "LocalizerFactory"
|
32
|
+
"""Used to localized message."""
|
30
33
|
|
31
34
|
def __init__(self, settings: Settings) -> None:
|
35
|
+
from fastlife.service.locale_negociator import default_negociator
|
36
|
+
from fastlife.service.translations import LocalizerFactory
|
37
|
+
|
32
38
|
self.settings = settings
|
33
39
|
self.locale_negociator = default_negociator(self.settings)
|
34
40
|
self.renderers = {}
|
@@ -0,0 +1,100 @@
|
|
1
|
+
"""Security policy."""
|
2
|
+
|
3
|
+
import abc
|
4
|
+
from typing import Annotated, Any, Generic
|
5
|
+
from uuid import UUID
|
6
|
+
|
7
|
+
from fastapi import Depends
|
8
|
+
|
9
|
+
from fastlife import GenericRequest, get_request
|
10
|
+
from fastlife.domain.model.security_policy import (
|
11
|
+
Allowed,
|
12
|
+
Forbidden,
|
13
|
+
HasPermission,
|
14
|
+
TUser,
|
15
|
+
Unauthorized,
|
16
|
+
)
|
17
|
+
from fastlife.service.registry import TRegistry
|
18
|
+
|
19
|
+
|
20
|
+
class AbstractSecurityPolicy(abc.ABC, Generic[TUser, TRegistry]):
|
21
|
+
"""Security policy base class."""
|
22
|
+
|
23
|
+
Forbidden = Forbidden
|
24
|
+
"""The exception raised if the user identified is not granted."""
|
25
|
+
Unauthorized = Unauthorized
|
26
|
+
"""The exception raised if no user has been identified."""
|
27
|
+
|
28
|
+
request: GenericRequest[TUser, TRegistry]
|
29
|
+
"""Request where the security policy is applied."""
|
30
|
+
|
31
|
+
def __init__(
|
32
|
+
self, request: Annotated[GenericRequest[TUser, TRegistry], Depends(get_request)]
|
33
|
+
):
|
34
|
+
"""
|
35
|
+
Build the security policy.
|
36
|
+
|
37
|
+
When implementing a security policy, multiple parameters can be added
|
38
|
+
to the constructor as FastAPI dependencies, using the `Depends` FastAPI
|
39
|
+
annotation.
|
40
|
+
The security policy is installed has a depenency of the router that hold
|
41
|
+
a route prefix of the application.
|
42
|
+
"""
|
43
|
+
self.request = request
|
44
|
+
self.request.security_policy = self # we do backref to implement has_permission
|
45
|
+
|
46
|
+
@abc.abstractmethod
|
47
|
+
async def identity(self) -> TUser | None:
|
48
|
+
"""
|
49
|
+
Return app-specific user object or raise an HTTPException.
|
50
|
+
"""
|
51
|
+
|
52
|
+
@abc.abstractmethod
|
53
|
+
async def authenticated_userid(self) -> str | UUID | None:
|
54
|
+
"""
|
55
|
+
Return app-specific user object or raise an HTTPException.
|
56
|
+
"""
|
57
|
+
|
58
|
+
@abc.abstractmethod
|
59
|
+
async def has_permission(
|
60
|
+
self, permission: str
|
61
|
+
) -> HasPermission | type[HasPermission]:
|
62
|
+
"""Allow access to everything if signed in."""
|
63
|
+
|
64
|
+
@abc.abstractmethod
|
65
|
+
async def remember(self, user: TUser) -> None:
|
66
|
+
"""Save the user identity in the request session."""
|
67
|
+
|
68
|
+
@abc.abstractmethod
|
69
|
+
async def forget(self) -> None:
|
70
|
+
"""Destroy the request session."""
|
71
|
+
|
72
|
+
|
73
|
+
class InsecurePolicy(AbstractSecurityPolicy[None, Any]):
|
74
|
+
"""
|
75
|
+
An implementation of the security policy made for explicit unsecured access.
|
76
|
+
|
77
|
+
Setting a permission on a view require a security policy, if not set, accessing
|
78
|
+
to a view will raise a RuntimeError. To bypass this error for testing purpose
|
79
|
+
or your own reason, the InsecurePolicy has to be set to the configurator.
|
80
|
+
"""
|
81
|
+
|
82
|
+
async def identity(self) -> None:
|
83
|
+
"""Nobodies is identified."""
|
84
|
+
return None
|
85
|
+
|
86
|
+
async def authenticated_userid(self) -> str | UUID:
|
87
|
+
"""An uuid mades of 0."""
|
88
|
+
return UUID(int=0)
|
89
|
+
|
90
|
+
async def has_permission(
|
91
|
+
self, permission: str
|
92
|
+
) -> HasPermission | type[HasPermission]:
|
93
|
+
"""Access is allways granted."""
|
94
|
+
return Allowed
|
95
|
+
|
96
|
+
async def remember(self, user: None) -> None:
|
97
|
+
"""Do nothing."""
|
98
|
+
|
99
|
+
async def forget(self) -> None:
|
100
|
+
"""Do nothing."""
|
@@ -13,9 +13,7 @@ from collections.abc import Mapping
|
|
13
13
|
from typing import Any
|
14
14
|
|
15
15
|
from fastlife import Request, Response
|
16
|
-
from fastlife.
|
17
|
-
|
18
|
-
TemplateParams = Mapping[str, Any]
|
16
|
+
from fastlife.domain.model.template import InlineTemplate
|
19
17
|
|
20
18
|
|
21
19
|
class AbstractTemplateRenderer(abc.ABC):
|
@@ -77,9 +75,6 @@ class AbstractTemplateRenderer(abc.ABC):
|
|
77
75
|
class AbstractTemplateRendererFactory(abc.ABC):
|
78
76
|
"""
|
79
77
|
The template render factory.
|
80
|
-
|
81
|
-
The implementation of this class is found using the settings
|
82
|
-
:attr:`fastlife.config.settings.Settings.template_renderer_class`.
|
83
78
|
"""
|
84
79
|
|
85
80
|
@abc.abstractmethod
|
@@ -64,6 +64,9 @@ class Localizer:
|
|
64
64
|
self.translations[domain].merge(trans)
|
65
65
|
self.global_translations.merge(trans)
|
66
66
|
|
67
|
+
def __call__(self, message: str, mapping: dict[str, str] | None = None) -> str:
|
68
|
+
return self.gettext(message, mapping)
|
69
|
+
|
67
70
|
def gettext(self, message: str, mapping: dict[str, str] | None = None) -> str:
|
68
71
|
ret = self.global_translations.gettext(message)
|
69
72
|
if mapping:
|
@@ -29,15 +29,9 @@ class Settings(BaseSettings):
|
|
29
29
|
a python module name. for instance `fastlife:components` is the directory components
|
30
30
|
found in the fastlife package.
|
31
31
|
"""
|
32
|
-
registry_class: str = Field(default="fastlife.
|
32
|
+
registry_class: str = Field(default="fastlife.service.registry:DefaultRegistry")
|
33
33
|
"""Implementation class for the application regitry."""
|
34
|
-
|
35
|
-
default="fastlife.templates.renderer:JinjaxEngine"
|
36
|
-
)
|
37
|
-
"""
|
38
|
-
Implementation class for the
|
39
|
-
{class}`fastlife.templates.renderer.AbstractTemplateRenderer`.
|
40
|
-
"""
|
34
|
+
|
41
35
|
form_data_model_prefix: str = Field(default="payload")
|
42
36
|
"""
|
43
37
|
Pydantic form default model prefix for serialized field in www-urlencoded-form.
|
@@ -70,12 +64,12 @@ class Settings(BaseSettings):
|
|
70
64
|
Set to true while developing, set false for production.
|
71
65
|
"""
|
72
66
|
jinjax_global_catalog_class: str = Field(
|
73
|
-
default="fastlife.
|
67
|
+
default="fastlife.template_globals:Globals"
|
74
68
|
)
|
75
69
|
"""
|
76
70
|
Set global constants accessible in every templates.
|
77
|
-
Defaults to `fastlife.
|
78
|
-
See {class}`fastlife.
|
71
|
+
Defaults to `fastlife.template_globals:Globals`
|
72
|
+
See {class}`fastlife.template_globals.Globals`
|
79
73
|
"""
|
80
74
|
|
81
75
|
session_secret_key: str = Field(default="")
|
@@ -101,7 +95,7 @@ class Settings(BaseSettings):
|
|
101
95
|
should be true while using https on production.
|
102
96
|
"""
|
103
97
|
session_serializer: str = Field(
|
104
|
-
default="fastlife.
|
98
|
+
default="fastlife.adapters.itsdangerous:SignedSessionSerializer"
|
105
99
|
)
|
106
100
|
"""Cookie serializer for the session cookie."""
|
107
101
|
|
fastlife/shared_utils/infer.py
CHANGED
@@ -1,10 +1,14 @@
|
|
1
1
|
"""Type inference."""
|
2
2
|
|
3
|
+
import inspect
|
4
|
+
from collections.abc import Callable
|
3
5
|
from types import UnionType
|
4
|
-
from typing import Any, Union, get_origin
|
6
|
+
from typing import Any, Union, get_args, get_origin
|
5
7
|
|
6
8
|
from pydantic import BaseModel
|
7
9
|
|
10
|
+
from fastlife.domain.model.template import InlineTemplate
|
11
|
+
|
8
12
|
|
9
13
|
def is_complex_type(typ: type[Any]) -> bool:
|
10
14
|
"""
|
@@ -25,3 +29,22 @@ def is_union(typ: type[Any]) -> bool:
|
|
25
29
|
if type_origin is UnionType: # T | U
|
26
30
|
return True
|
27
31
|
return False
|
32
|
+
|
33
|
+
|
34
|
+
def is_inline_template_returned(endpoint: Callable[..., Any]) -> bool:
|
35
|
+
"""Test if a view, the endpoint return a template."""
|
36
|
+
signature = inspect.signature(endpoint)
|
37
|
+
return_annotation = signature.return_annotation
|
38
|
+
|
39
|
+
if isinstance(return_annotation, type) and issubclass(
|
40
|
+
return_annotation, InlineTemplate
|
41
|
+
):
|
42
|
+
return True
|
43
|
+
|
44
|
+
if is_union(return_annotation):
|
45
|
+
return any(
|
46
|
+
isinstance(arg, type) and issubclass(arg, InlineTemplate)
|
47
|
+
for arg in get_args(return_annotation)
|
48
|
+
)
|
49
|
+
|
50
|
+
return False
|
@@ -16,7 +16,7 @@ def space_join(*segments: str) -> str:
|
|
16
16
|
return " ".join(segments)
|
17
17
|
|
18
18
|
|
19
|
-
class
|
19
|
+
class Globals(BaseModel):
|
20
20
|
"""Templates constants."""
|
21
21
|
|
22
22
|
A_CLASS: str = space_join(
|
@@ -315,7 +315,7 @@ class Constants(BaseModel):
|
|
315
315
|
SUMMARY_CLASS: str = "flex items-center items-center font-medium cursor-pointer"
|
316
316
|
"""Default css class for {jinjax:component}`Summary`."""
|
317
317
|
|
318
|
-
TABLE_CLASS: str = "table-auto w-full text-left border-
|
318
|
+
TABLE_CLASS: str = "table-auto w-full text-left border-collapse"
|
319
319
|
"""Default css class for {jinjax:component}`Table`."""
|
320
320
|
|
321
321
|
TD_CLASS: str = "px-4 py-2 font-normal border-b dark:border-neutral-500"
|
fastlife/testing/testclient.py
CHANGED
@@ -8,10 +8,10 @@ import bs4
|
|
8
8
|
import httpx
|
9
9
|
from fastapi.testclient import TestClient
|
10
10
|
from multidict import MultiDict
|
11
|
-
from starlette.types import ASGIApp
|
12
11
|
|
13
|
-
from fastlife.
|
12
|
+
from fastlife.domain.model.asgi import ASGIApp
|
14
13
|
from fastlife.middlewares.session.serializer import AbsractSessionSerializer
|
14
|
+
from fastlife.settings import Settings
|
15
15
|
from fastlife.shared_utils.resolver import resolve
|
16
16
|
from fastlife.testing.dom import Element
|
17
17
|
from fastlife.testing.form import WebForm
|
fastlife/views/__init__.py
CHANGED
@@ -0,0 +1 @@
|
|
1
|
+
"""Fastlife views."""
|
fastlife/views/pydantic_form.py
CHANGED