fastlifeweb 0.13.0__py3-none-any.whl → 0.15.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.
@@ -14,11 +14,12 @@ phase.
14
14
  import importlib
15
15
  import inspect
16
16
  import logging
17
- from collections.abc import Mapping
17
+ from collections import defaultdict
18
+ from collections.abc import Mapping, Sequence
18
19
  from enum import Enum
19
20
  from pathlib import Path
20
21
  from types import ModuleType
21
- from typing import TYPE_CHECKING, Annotated, Any, Callable, Self, Tuple, Type, cast
22
+ from typing import TYPE_CHECKING, Annotated, Any, Callable, Self, Tuple, Type
22
23
 
23
24
  import venusian
24
25
  from fastapi import Depends, FastAPI
@@ -27,20 +28,22 @@ from fastapi import Response
27
28
  from fastapi.params import Depends as DependsType
28
29
  from fastapi.staticfiles import StaticFiles
29
30
  from fastapi.types import IncEx
30
- from pydantic import BaseModel, Field
31
31
 
32
+ from fastlife.config.openapiextra import OpenApiTag
32
33
  from fastlife.middlewares.base import AbstractMiddleware
33
34
  from fastlife.request.request import Request
34
35
  from fastlife.routing.route import Route
35
36
  from fastlife.routing.router import Router
36
37
  from fastlife.security.csrf import check_csrf
38
+ from fastlife.services.policy import check_permission
37
39
  from fastlife.shared_utils.resolver import resolve
38
40
 
39
41
  from .settings import Settings
40
42
 
41
43
  if TYPE_CHECKING:
44
+ from fastlife.security.policy import AbstractSecurityPolicy # coverage: ignore
42
45
  from fastlife.services.templates import (
43
- AbstractTemplateRendererFactory, # coverage: ignore; coverage: ignore
46
+ AbstractTemplateRendererFactory, # coverage: ignore
44
47
  )
45
48
 
46
49
  from .registry import AppRegistry, LocaleNegociator # coverage: ignore
@@ -48,6 +51,8 @@ if TYPE_CHECKING:
48
51
  log = logging.getLogger(__name__)
49
52
  VENUSIAN_CATEGORY = "fastlife"
50
53
 
54
+ venusian_ignored_item = str | Callable[[str], bool]
55
+
51
56
 
52
57
  class ConfigurationError(Exception):
53
58
  """
@@ -55,24 +60,53 @@ class ConfigurationError(Exception):
55
60
  """
56
61
 
57
62
 
58
- class ExternalDocs(BaseModel):
59
- """OpenAPI externalDocs object."""
63
+ def rebuild_router(router: Router) -> Router:
64
+ """
65
+ Fix the router.
60
66
 
61
- description: str
62
- """link's description."""
63
- url: str
64
- """link's URL."""
67
+ FastAPI routers has dependencies that are injected to routes where they are added.
65
68
 
69
+ It means that if you add a dependencies in the router after the route has
70
+ been added, then the dependencies is missing in the route added before.
66
71
 
67
- class OpenApiTag(BaseModel):
68
- """OpenAPI tag object."""
72
+ To prenvents issues, we rebuild the router route with the dependency.
69
73
 
70
- name: str
71
- """name of the tag."""
72
- description: str
73
- """explanation of the tag."""
74
- external_docs: ExternalDocs | None = Field(alias="externalDocs", default=None)
75
- """external link to the doc."""
74
+ :param router: the router to rebuild
75
+ :return: a new router with fixed routes.
76
+ """
77
+ if not router.dependencies:
78
+ return router
79
+ _router = Router(prefix=router.prefix)
80
+ _router.dependencies = router.dependencies
81
+ route: Route
82
+ for route in router.routes: # type: ignore
83
+ dependencies = [
84
+ dep for dep in route.dependencies if dep not in _router.dependencies
85
+ ]
86
+ _router.add_api_route(
87
+ path=route.path,
88
+ endpoint=route.endpoint,
89
+ response_model=route.response_model,
90
+ status_code=route.status_code,
91
+ tags=route.tags,
92
+ dependencies=dependencies,
93
+ summary=route.summary,
94
+ description=route.description,
95
+ response_description=route.response_description,
96
+ deprecated=route.deprecated,
97
+ methods=route.methods,
98
+ operation_id=route.operation_id,
99
+ response_model_include=route.response_model_include,
100
+ response_model_exclude=route.response_model_exclude,
101
+ response_model_by_alias=route.response_model_by_alias,
102
+ response_model_exclude_unset=route.response_model_exclude_unset,
103
+ response_model_exclude_defaults=route.response_model_exclude_defaults,
104
+ response_model_exclude_none=route.response_model_exclude_none,
105
+ include_in_schema=route.include_in_schema,
106
+ name=route.name,
107
+ openapi_extra=route.openapi_extra,
108
+ )
109
+ return _router
76
110
 
77
111
 
78
112
  class Configurator:
@@ -102,18 +136,35 @@ class Configurator:
102
136
  self.api_description: str = ""
103
137
  self.api_summary: str | None = None
104
138
 
105
- self.router = Router()
139
+ self._route_prefix: str = ""
140
+ self._routers: dict[str, Router] = defaultdict(Router)
141
+ self._security_policies: dict[str, "type[AbstractSecurityPolicy[Any]]"] = {}
142
+
106
143
  self.scanner = venusian.Scanner(fastlife=self)
107
144
  self.include("fastlife.views")
108
145
  self.include("fastlife.middlewares")
109
146
 
147
+ @property
148
+ def _current_router(self) -> Router:
149
+ return self._routers[self._route_prefix]
150
+
110
151
  def build_asgi_app(self) -> FastAPI:
111
152
  """
112
153
  Build the app after configuration in order to start after beeing configured.
113
154
 
114
155
  :return: FastAPI application.
115
156
  """
116
- _app = FastAPI(
157
+
158
+ # register our main template renderer at then end, to ensure that
159
+ # if settings have been manipulated, everythins is taken into account.
160
+ self.add_renderer(
161
+ self.registry.settings.jinjax_file_ext,
162
+ resolve("fastlife.adapters.jinjax.renderer:JinjaxTemplateRenderer")(
163
+ self.registry.settings
164
+ ),
165
+ )
166
+
167
+ app = FastAPI(
117
168
  title=self.api_title,
118
169
  version=self.api_version,
119
170
  description=self.api_description,
@@ -125,48 +176,35 @@ class Configurator:
125
176
  if self.tags
126
177
  else None,
127
178
  )
128
- _app.router.route_class = Route
129
- for _route in self.router.routes:
130
- route = cast(Route, _route)
131
- _app.router.add_api_route(
132
- path=route.path,
133
- endpoint=route.endpoint,
134
- response_model=route.response_model,
135
- status_code=route.status_code,
136
- tags=route.tags,
137
- dependencies=route.dependencies,
138
- summary=route.summary,
139
- description=route.description,
140
- response_description=route.response_description,
141
- deprecated=route.deprecated,
142
- methods=route.methods,
143
- operation_id=route.operation_id,
144
- response_model_include=route.response_model_include,
145
- response_model_exclude=route.response_model_exclude,
146
- response_model_by_alias=route.response_model_by_alias,
147
- response_model_exclude_unset=route.response_model_exclude_unset,
148
- response_model_exclude_defaults=route.response_model_exclude_defaults,
149
- response_model_exclude_none=route.response_model_exclude_none,
150
- include_in_schema=route.include_in_schema,
151
- name=route.name,
152
- openapi_extra=route.openapi_extra,
153
- )
179
+ app.router.route_class = Route
154
180
 
155
181
  for middleware_class, options in self.middlewares:
156
- _app.add_middleware(middleware_class, **options) # type: ignore
182
+ app.add_middleware(middleware_class, **options) # type: ignore
157
183
 
158
184
  for status_code_or_exc, exception_handler in self.exception_handlers:
159
- _app.add_exception_handler(status_code_or_exc, exception_handler)
185
+ app.add_exception_handler(status_code_or_exc, exception_handler)
186
+
187
+ for prefix, router in self._routers.items():
188
+ app.include_router(rebuild_router(router), prefix=prefix)
160
189
 
161
190
  for route_path, directory, name in self.mounts:
162
- _app.mount(route_path, StaticFiles(directory=directory), name=name)
163
- return _app
191
+ app.mount(route_path, StaticFiles(directory=directory), name=name)
192
+ return app
164
193
 
165
- def include(self, module: str | ModuleType) -> Self:
194
+ def include(
195
+ self,
196
+ module: str | ModuleType,
197
+ route_prefix: str = "",
198
+ ignore: venusian_ignored_item | Sequence[venusian_ignored_item] | None = None,
199
+ ) -> Self:
166
200
  """
167
201
  Include a module in order to load its configuration.
168
202
 
169
- It will load and include all the submodule as well.
203
+ It will scan and load all the submodule until you add an ignore rule.
204
+
205
+ The `ignore` argument allows you to ignore certain modules.
206
+ If it is a scrint, it can be an absolute module name or a relative
207
+ one, if starts with a dot.
170
208
 
171
209
  Here is an example.
172
210
 
@@ -182,6 +220,8 @@ class Configurator:
182
220
  ```
183
221
 
184
222
  :param module: a module to include.
223
+ :param route_prefix: prepend all included route with a prefix
224
+ :param ignore: ignore submodules
185
225
  """
186
226
  if isinstance(module, str):
187
227
  package = None
@@ -190,7 +230,15 @@ class Configurator:
190
230
  package = caller_module.__name__ if caller_module else "__main__"
191
231
 
192
232
  module = importlib.import_module(module, package)
193
- self.scanner.scan(module, categories=[VENUSIAN_CATEGORY]) # type: ignore
233
+ old, self._route_prefix = self._route_prefix, route_prefix
234
+ try:
235
+ self.scanner.scan( # type: ignore
236
+ module,
237
+ categories=[VENUSIAN_CATEGORY],
238
+ ignore=ignore,
239
+ )
240
+ finally:
241
+ self._route_prefix = old
194
242
  return self
195
243
 
196
244
  def set_locale_negociator(self, locale_negociator: "LocaleNegociator") -> Self:
@@ -247,6 +295,27 @@ class Configurator:
247
295
  self.middlewares.append((middleware_class, options))
248
296
  return self
249
297
 
298
+ def set_security_policy(
299
+ self, security_policy: "type[AbstractSecurityPolicy[Any]]"
300
+ ) -> Self:
301
+ """
302
+ Set a security policy for the application.
303
+
304
+ ```{important}
305
+ The security policy is **per route_prefix**.
306
+ It means that if the application is splitted via multiple
307
+ route_prefix using the {meth}`Configurator.include`, they
308
+ all have a distinct security policy. A secutity policy has
309
+ to be install by all of those include call.
310
+
311
+ :param security_policy: The security policy that will applied for the app
312
+ portion behind the route prefix.
313
+ ```
314
+ """
315
+ self._security_policies[self._route_prefix] = security_policy
316
+ self._current_router.dependencies.append(Depends(security_policy))
317
+ return self
318
+
250
319
  def add_api_route(
251
320
  self,
252
321
  name: str,
@@ -296,9 +365,7 @@ class Configurator:
296
365
  :param path: path of the route, use `{curly_brace}` to inject FastAPI Path
297
366
  parameters.
298
367
  :param endpoint: the function that will reveive the request.
299
- :param permission: a permission to validate by the
300
- :attr:`fastlife.config.settings.Settings.check_permission` function.
301
-
368
+ :param permission: a permission to validate by the security policy.
302
369
  :param methods: restrict route to a list of http methods.
303
370
  :param description:OpenAPI description for the route.
304
371
  :param summary: OpenAPI summary for the route.
@@ -324,9 +391,9 @@ class Configurator:
324
391
  """
325
392
  dependencies: list[DependsType] = []
326
393
  if permission:
327
- dependencies.append(Depends(self.registry.check_permission(permission)))
394
+ dependencies.append(Depends(check_permission(permission)))
328
395
 
329
- self.router.add_api_route(
396
+ self._current_router.add_api_route(
330
397
  path,
331
398
  endpoint,
332
399
  # response_model=response_model,
@@ -379,15 +446,13 @@ class Configurator:
379
446
  :param path: path of the route, use `{curly_brace}` to inject FastAPI Path
380
447
  parameters.
381
448
  :param endpoint: the function that will reveive the request.
382
- :param permission: a permission to validate by the
383
- :attr:`fastlife.config.settings.Settings.check_permission` function.
384
-
449
+ :param permission: a permission to validate by the security policy.
385
450
  :param methods: restrict route to a list of http methods.
386
451
  :return: the configurator.
387
452
  """
388
453
  dependencies: list[DependsType] = []
389
454
  if permission:
390
- dependencies.append(Depends(self.registry.check_permission(permission)))
455
+ dependencies.append(Depends(check_permission(permission)))
391
456
 
392
457
  if template:
393
458
 
@@ -404,7 +469,7 @@ class Configurator:
404
469
 
405
470
  endpoint = render
406
471
 
407
- self.router.add_api_route(
472
+ self._current_router.add_api_route(
408
473
  path,
409
474
  endpoint,
410
475
  status_code=status_code,
@@ -444,6 +509,11 @@ class Configurator:
444
509
  """
445
510
 
446
511
  def exception_handler(request: BaseRequest, exc: Exception) -> Any:
512
+ # FastAPI exception handler does not provide our request object
513
+ # it seems like it is rebuild from the asgi scope. Even the router
514
+ # class is wrong.
515
+ # Until we store a security policy per rooter, we rebuild an
516
+ # incomplete request here.
447
517
  request = Request(self.registry, request)
448
518
  resp = handler(request, exc)
449
519
  if isinstance(resp, Response):
@@ -479,6 +549,17 @@ class Configurator:
479
549
  self.registry.renderers[f".{file_ext.lstrip('.')}"] = renderer # type: ignore
480
550
  return self
481
551
 
552
+ def add_template_search_path(self, path: str) -> Self:
553
+ """
554
+ Add a template search path directly from the code.
555
+
556
+ :param path: template path.
557
+ """
558
+ self.registry.settings.template_search_path = (
559
+ f"{self.registry.settings.template_search_path},{path}"
560
+ )
561
+ return self
562
+
482
563
 
483
564
  def configure(
484
565
  wrapped: Callable[[Configurator], None],
@@ -0,0 +1,22 @@
1
+ """Types for OpenAPI documentation."""
2
+ from pydantic import BaseModel, Field
3
+
4
+
5
+ class ExternalDocs(BaseModel):
6
+ """OpenAPI externalDocs object."""
7
+
8
+ description: str
9
+ """link's description."""
10
+ url: str
11
+ """link's URL."""
12
+
13
+
14
+ class OpenApiTag(BaseModel):
15
+ """OpenAPI tag object."""
16
+
17
+ name: str
18
+ """name of the tag."""
19
+ description: str
20
+ """explanation of the tag."""
21
+ external_docs: ExternalDocs | None = Field(alias="externalDocs", default=None)
22
+ """external link to the doc."""
@@ -5,9 +5,7 @@ from fastapi import Depends
5
5
  from fastapi import Request as FastAPIRequest
6
6
 
7
7
  from fastlife.request.request import Request
8
- from fastlife.security.policy import CheckPermission
9
8
  from fastlife.services.translations import LocalizerFactory
10
- from fastlife.shared_utils.resolver import resolve
11
9
 
12
10
  if TYPE_CHECKING:
13
11
  from fastlife.services.templates import ( # coverage: ignore
@@ -34,19 +32,13 @@ class AppRegistry:
34
32
 
35
33
  settings: Settings
36
34
  renderers: Mapping[str, "AbstractTemplateRendererFactory"]
37
- check_permission: CheckPermission
38
35
  locale_negociator: LocaleNegociator
39
36
  localizer: LocalizerFactory
40
37
 
41
38
  def __init__(self, settings: Settings) -> None:
42
39
  self.settings = settings
43
- self.check_permission = resolve(settings.check_permission)
44
40
  self.locale_negociator = _default_negociator(self.settings)
45
- self.renderers = {
46
- f".{settings.jinjax_file_ext}": resolve(
47
- "fastlife.adapters.jinjax.renderer:JinjaxTemplateRenderer"
48
- )(settings),
49
- }
41
+ self.renderers = {}
50
42
  self.localizer = LocalizerFactory()
51
43
 
52
44
  def get_renderer(self, template: str) -> "AbstractTemplateRendererFactory":
@@ -13,11 +13,12 @@ from typing import Any, Callable
13
13
  import venusian
14
14
  from fastapi.types import IncEx
15
15
 
16
+ from fastlife.config.openapiextra import ExternalDocs
17
+
16
18
  from .configurator import (
17
19
  VENUSIAN_CATEGORY,
18
20
  ConfigurationError,
19
21
  Configurator,
20
- ExternalDocs,
21
22
  OpenApiTag,
22
23
  )
23
24
 
@@ -143,6 +144,7 @@ def resource(
143
144
 
144
145
 
145
146
  def resource_view(
147
+ *,
146
148
  permission: str | None = None,
147
149
  status_code: int | None = None,
148
150
  summary: str | None = None,
@@ -106,9 +106,6 @@ class Settings(BaseSettings):
106
106
  domain_name: str = Field(default="", title="domain name where the app is served")
107
107
  """Domain name whre the app is served."""
108
108
 
109
- check_permission: str = Field(default="fastlife.security.policy:check_permission")
110
- """Handler for checking permission set on any views using the configurator."""
111
-
112
109
  decode_reverse_proxy_headers: bool = Field(default=True)
113
110
  """Ensure that the request object has information based on http proxy headers."""
114
111
 
@@ -1,11 +1,15 @@
1
1
  """HTTP Request representation in a python object."""
2
2
 
3
- from typing import TYPE_CHECKING
3
+ from typing import TYPE_CHECKING, Any
4
4
 
5
5
  from fastapi import Request as FastAPIRequest
6
6
 
7
7
  if TYPE_CHECKING:
8
8
  from fastlife.config.registry import AppRegistry # coverage: ignore
9
+ from fastlife.security.policy import ( # coverage: ignore
10
+ AbstractSecurityPolicy,
11
+ HasPermission,
12
+ )
9
13
 
10
14
 
11
15
  class Request(FastAPIRequest):
@@ -16,7 +20,30 @@ class Request(FastAPIRequest):
16
20
  locale_name: str
17
21
  """Request locale used for the i18n of the response."""
18
22
 
23
+ security_policy: "AbstractSecurityPolicy[Any] | None"
24
+ """Request locale used for the i18n of the response."""
25
+
19
26
  def __init__(self, registry: "AppRegistry", request: FastAPIRequest) -> None:
20
27
  super().__init__(request.scope, request.receive)
21
28
  self.registry = registry
22
29
  self.locale_name = registry.locale_negociator(self)
30
+ self.security_policy = None # build it from the ? registry
31
+
32
+ async def has_permission(
33
+ self, permission: str
34
+ ) -> "HasPermission | type[HasPermission]":
35
+ """
36
+ A helper to check that a user has the given permission.
37
+
38
+ Not that this method does not raised, it return a boolean like object.
39
+ It allows batch permission checks.
40
+ You might need to check multiple permissions in different contexts or
41
+ for different resources before raising an http error.
42
+ """
43
+ if self.security_policy is None:
44
+ raise RuntimeError(
45
+ f"Request {self.url.path} require a security policy, "
46
+ "explicit fastlife.security.policy.InsecurePolicy is required."
47
+ )
48
+
49
+ return await self.security_policy.has_permission(permission)
@@ -1,26 +1,171 @@
1
1
  """Security policy."""
2
- from typing import Any, Callable, Coroutine
2
+
3
+ import abc
4
+ import logging
5
+ from typing import Any, Callable, Coroutine, Literal, TypeVar
6
+ from uuid import UUID
7
+
8
+ from fastapi import HTTPException
9
+ from starlette.status import HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN
10
+ from typing_extensions import Generic
11
+
12
+ from fastlife import Request
3
13
 
4
14
  CheckPermissionHook = Callable[..., Coroutine[Any, Any, None]] | Callable[..., None]
5
15
  CheckPermission = Callable[[str], CheckPermissionHook]
6
16
 
17
+ TUser = TypeVar("TUser")
18
+
19
+ log = logging.getLogger(__name__)
20
+
21
+
22
+ class Unauthorized(HTTPException):
23
+ """An exception raised to stop a request exectution and return an HTTP Error."""
24
+
25
+ def __init__(
26
+ self,
27
+ status_code: int = HTTP_401_UNAUTHORIZED,
28
+ detail: str = "Unauthorized",
29
+ headers: dict[str, str] | None = None,
30
+ ) -> None:
31
+ super().__init__(status_code, detail, headers)
32
+
33
+
34
+ class Forbidden(HTTPException):
35
+ """An exception raised to stop a request exectution and return an HTTP Error."""
36
+
37
+ def __init__(
38
+ self,
39
+ status_code: int = HTTP_403_FORBIDDEN,
40
+ detail: str = "Forbidden",
41
+ headers: dict[str, str] | None = None,
42
+ ) -> None:
43
+ super().__init__(status_code, detail, headers)
44
+
45
+
46
+ class BoolMeta(type):
47
+ def __bool__(cls) -> bool:
48
+ return cls.kind == "allowed" # type: ignore
49
+
50
+ def __repr__(cls) -> str:
51
+ return cls.reason # type: ignore
52
+
53
+
54
+ class HasPermission(int, metaclass=BoolMeta):
55
+ kind: Literal["allowed", "unauthenticated", "denied"]
56
+ reason: str
57
+
58
+ def __new__(cls, reason: str) -> "HasPermission":
59
+ instance = super().__new__(cls)
60
+ instance.reason = reason
61
+ return instance
62
+
63
+ def __repr__(self) -> str:
64
+ return self.reason
65
+
66
+ def __bool__(self) -> bool:
67
+ return self.kind == "allowed"
68
+
69
+
70
+ class Allowed(HasPermission):
71
+ """Represent a permission check result that is allowed."""
72
+
73
+ kind = "allowed"
74
+ reason = "Allowed"
75
+
7
76
 
8
- def check_permission(permission_name: str) -> CheckPermissionHook:
77
+ class Unauthenticated(HasPermission):
78
+ """
79
+ Represent a permission check result that is not allowed due to
80
+ missing authentication mechanism.
9
81
  """
10
- A closure that check that a user as the given permission_name.
11
82
 
12
- This method has to be overriden using the setting
13
- :attr:`fastlife.config.settings.Settings.check_permission` to implement it.
83
+ kind = "unauthenticated"
84
+ reason = "Authentication required"
14
85
 
15
- When the check permission is properly set in the settings., the hook is called
16
- for every route added with a permission keyword.
17
- {meth}`fastlife.config.configurator.Configurator.add_route`
18
86
 
19
- :param permission_name: a permission name set in a view to check access.
20
- :return: a function that raise http exceptions or any configured exception here.
87
+ class Denied(HasPermission):
88
+ """
89
+ Represent a permission check result that is not allowed due to lack of permission.
21
90
  """
22
91
 
23
- def depencency_injection() -> None:
24
- ...
92
+ kind = "denied"
93
+ reason = "Access denied to this resource"
94
+
95
+
96
+ class AbstractSecurityPolicy(abc.ABC, Generic[TUser]):
97
+ """Security policy base classe."""
98
+
99
+ Forbidden = Forbidden
100
+ """The exception raised if the user identified is not granted."""
101
+ Unauthorized = Unauthorized
102
+ """The exception raised if no user has been identified."""
103
+
104
+ def __init__(self, request: Request):
105
+ """
106
+ Build the security policy.
107
+
108
+ When implementing a security policy, multiple parameters can be added
109
+ to the constructor as FastAPI dependencies, using the `Depends` FastAPI
110
+ annotation.
111
+ The security policy is installed has a depenency of the router that hold
112
+ a route prefix of the application.
113
+ """
114
+ self.request = request
115
+ self.request.security_policy = self # we do backref to implement has_permission
116
+
117
+ @abc.abstractmethod
118
+ async def identity(self) -> TUser | None:
119
+ """
120
+ Return app-specific user object or raise an HTTPException.
121
+ """
122
+
123
+ @abc.abstractmethod
124
+ async def authenticated_userid(self) -> str | UUID | None:
125
+ """
126
+ Return app-specific user object or raise an HTTPException.
127
+ """
128
+
129
+ @abc.abstractmethod
130
+ async def has_permission(
131
+ self, permission: str
132
+ ) -> HasPermission | type[HasPermission]:
133
+ """Allow access to everything if signed in."""
134
+
135
+ @abc.abstractmethod
136
+ async def remember(self, user: TUser) -> None:
137
+ """Save the user identity in the request session."""
138
+
139
+ @abc.abstractmethod
140
+ async def forget(self) -> None:
141
+ """Destroy the request session."""
142
+
143
+
144
+ class InsecurePolicy(AbstractSecurityPolicy[None]):
145
+ """
146
+ An implementation of the security policy made for explicit unsecured access.
147
+
148
+ Setting a permission on a view require a security policy, if not set, accessing
149
+ to a view will raise a RuntimeError. To bypass this error for testing purpose
150
+ or your own reason, the InsecurePolicy has to be set to the configurator.
151
+ """
152
+
153
+ async def identity(self) -> None:
154
+ """Nobodies is identified."""
155
+ return None
156
+
157
+ async def authenticated_userid(self) -> str | UUID:
158
+ """An uuid mades of 0."""
159
+ return UUID(int=0)
160
+
161
+ async def has_permission(
162
+ self, permission: str
163
+ ) -> HasPermission | type[HasPermission]:
164
+ """Access is allways granted."""
165
+ return Allowed
166
+
167
+ async def remember(self, user: None) -> None:
168
+ """Do nothing."""
25
169
 
26
- return depencency_injection
170
+ async def forget(self) -> None:
171
+ """Do nothing."""
@@ -0,0 +1,39 @@
1
+ """Security policy."""
2
+
3
+ from typing import Any, Callable, Coroutine
4
+
5
+ CheckPermissionHook = Callable[..., Coroutine[Any, Any, None]] | Callable[..., None]
6
+ CheckPermission = Callable[[str], CheckPermissionHook]
7
+
8
+
9
+ def check_permission(permission_name: str) -> CheckPermissionHook:
10
+ """
11
+ A closure that check that a user as the given permission_name.
12
+
13
+ Adding a permission on the route requires that a security policy has been
14
+ added using the method
15
+ {meth}`fastlife.config.configurator.Configurator.set_security_policy`
16
+
17
+ :param permission_name: a permission name set in a view to check access.
18
+ :return: a function that raise http exceptions or any configured exception here.
19
+ """
20
+ # the check_permission is called by the configurator
21
+ # and the request is exposed in the public module creating a circular dependency.
22
+ from fastlife import Request # a type must be resolved to inject a dependency.
23
+
24
+ async def depencency_injection(request: Request) -> None:
25
+ if request.security_policy is None:
26
+ raise RuntimeError(
27
+ f"Request {request.url.path} require a security policy, "
28
+ "explicit fastlife.security.policy.InsecurePolicy is required"
29
+ )
30
+ allowed = await request.security_policy.has_permission(permission_name)
31
+ match allowed.kind:
32
+ case "allowed":
33
+ return
34
+ case "denied":
35
+ raise request.security_policy.Forbidden(detail=allowed.reason)
36
+ case "unauthenticated":
37
+ raise request.security_policy.Unauthorized(detail=allowed.reason)
38
+
39
+ return depencency_injection
@@ -114,10 +114,10 @@ class LocalizerFactory:
114
114
  for locale_name, domain, file_ in find_mo_files(root_path):
115
115
  with file_.open("rb") as f:
116
116
  t = Translations(f, domain)
117
- if locale_name in self._translations:
118
- self._translations[locale_name].merge(t)
119
- else:
120
- self._translations[locale_name] = t
117
+ if locale_name not in self._translations:
118
+ self._translations[locale_name] = Translations()
119
+ self._translations[locale_name].add(t)
120
+ self._translations[locale_name].merge(t)
121
121
 
122
122
  def __call__(self, request: "Request") -> Localizer:
123
123
  """Create the translation context for the given request."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fastlifeweb
3
- Version: 0.13.0
3
+ Version: 0.15.0
4
4
  Summary: High-level web framework
5
5
  Home-page: https://github.com/mardiros/fastlife
6
6
  License: BSD-derived
@@ -1666,11 +1666,12 @@ fastlife/components/pydantic_form/Textarea.jinja,sha256=NzfCi5agRUSVcb5RXw0QamM8
1666
1666
  fastlife/components/pydantic_form/Union.jinja,sha256=czTska54z9KCZKu-FaycLmOvtH6y6CGUFQ8DHnkjrJk,1461
1667
1667
  fastlife/components/pydantic_form/Widget.jinja,sha256=EXskDqt22D5grpGVwlZA3ndve2Wr_6yQH4qVE9c31Og,397
1668
1668
  fastlife/config/__init__.py,sha256=O_Mw2XOxo55SArHdGKRhlTrroRN8ymwfzYKlHG0eV_s,418
1669
- fastlife/config/configurator.py,sha256=gfHCnPzxXJGnZ6rTbBy2lkGSGRZOEyGjJnVWbMCDrdo,18048
1669
+ fastlife/config/configurator.py,sha256=e0PeDn6S0swsXAJ79-E6zpnonrYwoFHsfEIEzVaCzls,21222
1670
1670
  fastlife/config/exceptions.py,sha256=2MS2MFgb3mDbtdHEwnC-QYubob3Rl0v8O8y615LY0ds,1180
1671
- fastlife/config/registry.py,sha256=pqOnbUPP7Boc42nndvweturuA5X61yhC2bbe8G08T4o,2120
1672
- fastlife/config/resources.py,sha256=Db183g_CC0Voa6IblaNSzcv7XBH1S3s2nTAFNXtz9Cg,8732
1673
- fastlife/config/settings.py,sha256=ZMjfx_XQhu6mPE5q3eI9dG5disnbGUcALUqNYDCknhk,4199
1671
+ fastlife/config/openapiextra.py,sha256=_9rBYeTqB7nVuzvUHMwZU387bTfYFHYLlP05NP0vEDs,513
1672
+ fastlife/config/registry.py,sha256=Zm3i9ZfQZOP3vNKNKQ8nar7XevI91bqy2tUXX492Fuk,1749
1673
+ fastlife/config/resources.py,sha256=pM0j5VKVbVak4Z5mFRHBjAjUqORP4TAtCnZM3res5o8,8776
1674
+ fastlife/config/settings.py,sha256=FepHFZVHPLy3yA3dQux79GiH65MkipqWyT-zgXUBOKE,4028
1674
1675
  fastlife/config/views.py,sha256=Dxi6lO4gFs6GriAW7Rh5GDvebwbrpS2HzYhf30pXJiE,2058
1675
1676
  fastlife/middlewares/__init__.py,sha256=C3DUOzR5EhlAv5Zq7h-Abyvkd7bUsJohTRSB2wpRYQE,220
1676
1677
  fastlife/middlewares/base.py,sha256=9OYqByRuVoIrLt353NOedPQTLdr7LSmxhb2BZcp20qk,638
@@ -1684,16 +1685,17 @@ fastlife/request/__init__.py,sha256=C5lZAsUZJDw0TfTegRyN5BEhzgfiqvfc8YXu2vp0siU,
1684
1685
  fastlife/request/form.py,sha256=FucGua79LCKqNBP6Ycle7-5JU6EMI6SrHCgoJBcvGY4,3532
1685
1686
  fastlife/request/form_data.py,sha256=yoP-AYF-dSClpCQuZNRTY-c1OnDga5MoTjBKIzgpTs8,4459
1686
1687
  fastlife/request/localizer.py,sha256=9MXAcsod-Po5qeg4lttD3dyumiI0y5vGHCwSSmt9or8,349
1687
- fastlife/request/request.py,sha256=ACteAZUEaQHhE7l7LDEzY1wDhTegCKr-rsQmzTwtsGY,695
1688
+ fastlife/request/request.py,sha256=NiRtEV_iw8BuESQPf7gqXxiHsQwLiyOu1yzQy7Ck9eM,1779
1688
1689
  fastlife/routing/__init__.py,sha256=8EMnQE5n8oA4J9_c3nxzwKDVt3tefZ6fGH0d2owE8mo,195
1689
1690
  fastlife/routing/route.py,sha256=8RMnAqun8GTfWp6PFIWmWu-q-K89M3SiVSi2A-DYqyM,1405
1690
1691
  fastlife/routing/router.py,sha256=bLZ4k2aDs4L423znwGnw-X2LnM36O8fjhDWc8q1WewI,481
1691
1692
  fastlife/security/__init__.py,sha256=QYDcJ3oXQzqXQxoDD_6biGAtercFrtePttoifiL1j34,25
1692
1693
  fastlife/security/csrf.py,sha256=PvC9Fqdb6c0IzzsnaMx2quQdjjKrb-nOPoAHfcwoAe8,2141
1693
- fastlife/security/policy.py,sha256=MYNPQlvnTBe1XsYNJoIwiGW2DFb8vciU3XyUi9ZlLt0,945
1694
+ fastlife/security/policy.py,sha256=3aENmA_plxHbnUKzsIWtCAboT3oyVew0dcJKDrrILqE,4880
1694
1695
  fastlife/services/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
1696
+ fastlife/services/policy.py,sha256=ZK4K3fVGT1fUeBdo5sSWnEcJg49CZuaI3z2gyg3KguQ,1653
1695
1697
  fastlife/services/templates.py,sha256=7gPJxGWD-XqputbZpy_Icsz3WHKJaWg2JgkVOeKrjfA,3840
1696
- fastlife/services/translations.py,sha256=R0KXVrm4leUu6p1zJp0QvLUwrQNW5mO88cQ5CZ2mlCE,4317
1698
+ fastlife/services/translations.py,sha256=Bo5CIjdbQ3g_ihbv4Bz60hzd8VOtqEEPOyhJEbGcvP4,4363
1697
1699
  fastlife/shared_utils/__init__.py,sha256=i66ytuf-Ezo7jSiNQHIsBMVIcB-tDX0tg28-pUOlhzE,26
1698
1700
  fastlife/shared_utils/infer.py,sha256=CJjsL_F5OQRG7-0_89MQiZhyd7IcMGyirlQhjtcaIT4,728
1699
1701
  fastlife/shared_utils/resolver.py,sha256=BRU88Ej4oA1iDIyG4Z6T7Q9WFvPHiMm6zuSh623312A,1725
@@ -1704,7 +1706,7 @@ fastlife/testing/__init__.py,sha256=vuqwoNUd3BuIp3fm7nkvmYkIGjIimf5zUGhDkeWrg2s,
1704
1706
  fastlife/testing/testclient.py,sha256=BC7lLQ_jc59UmknAKzgRtW9a3cpX_V_QLp9Mg2ScLA8,20546
1705
1707
  fastlife/views/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
1706
1708
  fastlife/views/pydantic_form.py,sha256=fvjk_5-JAugpBPxwD5GkxxsQiz9eAxzWeCSU9kiyc6s,1438
1707
- fastlifeweb-0.13.0.dist-info/LICENSE,sha256=F75xSseSKMwqzFj8rswYU6NWS3VoWOc_gY3fJYf9_LI,1504
1708
- fastlifeweb-0.13.0.dist-info/METADATA,sha256=yhn48LhvuV19WZhJI4HykIgEjmd85Mu3n3Tv_GqkpQo,3278
1709
- fastlifeweb-0.13.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
1710
- fastlifeweb-0.13.0.dist-info/RECORD,,
1709
+ fastlifeweb-0.15.0.dist-info/LICENSE,sha256=F75xSseSKMwqzFj8rswYU6NWS3VoWOc_gY3fJYf9_LI,1504
1710
+ fastlifeweb-0.15.0.dist-info/METADATA,sha256=_1D_y-_GWIJCaw4ZbZs9LT7O4nfhwdQ4p9Kzlo0BAY4,3278
1711
+ fastlifeweb-0.15.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
1712
+ fastlifeweb-0.15.0.dist-info/RECORD,,