qena-shared-lib 0.1.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.
- qena_shared_lib/__init__.py +27 -0
- qena_shared_lib/application.py +190 -0
- qena_shared_lib/background.py +109 -0
- qena_shared_lib/dependencies/__init__.py +19 -0
- qena_shared_lib/dependencies/http.py +62 -0
- qena_shared_lib/dependencies/miscellaneous.py +35 -0
- qena_shared_lib/exception_handlers.py +165 -0
- qena_shared_lib/exceptions.py +319 -0
- qena_shared_lib/http.py +631 -0
- qena_shared_lib/logging.py +63 -0
- qena_shared_lib/logstash/__init__.py +17 -0
- qena_shared_lib/logstash/_base.py +573 -0
- qena_shared_lib/logstash/_http_sender.py +61 -0
- qena_shared_lib/logstash/_tcp_sender.py +84 -0
- qena_shared_lib/py.typed +0 -0
- qena_shared_lib/rabbitmq/__init__.py +52 -0
- qena_shared_lib/rabbitmq/_base.py +741 -0
- qena_shared_lib/rabbitmq/_channel.py +196 -0
- qena_shared_lib/rabbitmq/_exception_handlers.py +159 -0
- qena_shared_lib/rabbitmq/_exceptions.py +46 -0
- qena_shared_lib/rabbitmq/_listener.py +1292 -0
- qena_shared_lib/rabbitmq/_pool.py +74 -0
- qena_shared_lib/rabbitmq/_publisher.py +73 -0
- qena_shared_lib/rabbitmq/_rpc_client.py +286 -0
- qena_shared_lib/rabbitmq/_utils.py +18 -0
- qena_shared_lib/scheduler.py +402 -0
- qena_shared_lib/security.py +205 -0
- qena_shared_lib/utils.py +28 -0
- qena_shared_lib-0.1.0.dist-info/METADATA +473 -0
- qena_shared_lib-0.1.0.dist-info/RECORD +31 -0
- qena_shared_lib-0.1.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,402 @@
|
|
1
|
+
from asyncio import Future, Task, iscoroutinefunction, sleep
|
2
|
+
from dataclasses import dataclass
|
3
|
+
from datetime import datetime
|
4
|
+
from functools import partial
|
5
|
+
from importlib import import_module
|
6
|
+
from inspect import signature
|
7
|
+
from os import name as osname
|
8
|
+
from typing import Any, Callable, TypeVar
|
9
|
+
from zoneinfo import ZoneInfo
|
10
|
+
|
11
|
+
from cronsim import CronSim
|
12
|
+
from prometheus_client import Enum as PrometheusEnum
|
13
|
+
from punq import Container, Scope
|
14
|
+
|
15
|
+
from .dependencies.miscellaneous import validate_annotation
|
16
|
+
from .logging import LoggerProvider
|
17
|
+
from .logstash import BaseLogstashSender
|
18
|
+
from .utils import AsyncEventLoopMixin
|
19
|
+
|
20
|
+
__all__ = [
|
21
|
+
"schedule",
|
22
|
+
"ScheduleManager",
|
23
|
+
"Scheduler",
|
24
|
+
"SchedulerBase",
|
25
|
+
]
|
26
|
+
|
27
|
+
S = TypeVar("S")
|
28
|
+
SCHEDULER_ATTRIBUTE = "__scheduler__"
|
29
|
+
SCHEDULED_TASK_ATTRIBUTE = "__scheduled_task__"
|
30
|
+
|
31
|
+
|
32
|
+
@dataclass
|
33
|
+
class ScheduledTask:
|
34
|
+
task: Callable
|
35
|
+
cron_expression: str
|
36
|
+
zone_info: ZoneInfo | None
|
37
|
+
|
38
|
+
def __post_init__(self):
|
39
|
+
self._next_run_in = None
|
40
|
+
self._ran = False
|
41
|
+
self._paramters = {}
|
42
|
+
|
43
|
+
for parameter_name, paramter in signature(self.task).parameters.items():
|
44
|
+
dependency = validate_annotation(paramter.annotation)
|
45
|
+
|
46
|
+
if dependency is None:
|
47
|
+
raise TypeError(
|
48
|
+
f"scheduler parament annotation for `{parameter_name}` not valid, expected `Annotated[type, DependsOn(type)]`"
|
49
|
+
)
|
50
|
+
|
51
|
+
self._paramters[parameter_name] = dependency
|
52
|
+
|
53
|
+
@property
|
54
|
+
def next_run_in(self) -> int | None:
|
55
|
+
return self._next_run_in
|
56
|
+
|
57
|
+
@next_run_in.setter
|
58
|
+
def next_run_in(self, value: int):
|
59
|
+
self._next_run_in = value
|
60
|
+
|
61
|
+
@property
|
62
|
+
def ran(self) -> bool:
|
63
|
+
return self._ran
|
64
|
+
|
65
|
+
@ran.setter
|
66
|
+
def ran(self, value: bool):
|
67
|
+
self._ran = value
|
68
|
+
|
69
|
+
@property
|
70
|
+
def parameters(self) -> dict[str, type]:
|
71
|
+
return self._paramters
|
72
|
+
|
73
|
+
|
74
|
+
class Scheduler:
|
75
|
+
def __init__(self):
|
76
|
+
self._scheduled_tasks: list[ScheduledTask] = []
|
77
|
+
|
78
|
+
def schedule(
|
79
|
+
self, cron_expression: str, timezone: str | None = None
|
80
|
+
) -> Callable[[Callable], Callable]:
|
81
|
+
def wrapper(task: Callable) -> Callable:
|
82
|
+
self.add_task(
|
83
|
+
task=task, cron_expression=cron_expression, timezone=timezone
|
84
|
+
)
|
85
|
+
|
86
|
+
return task
|
87
|
+
|
88
|
+
return wrapper
|
89
|
+
|
90
|
+
def add_task(
|
91
|
+
self, task: Callable, cron_expression: str, timezone: str | None
|
92
|
+
):
|
93
|
+
self._scheduled_tasks.append(
|
94
|
+
ScheduledTask(
|
95
|
+
task=task,
|
96
|
+
cron_expression=cron_expression,
|
97
|
+
zone_info=ZoneInfo(timezone) if timezone is not None else None,
|
98
|
+
)
|
99
|
+
)
|
100
|
+
|
101
|
+
def __call__(self, scheduler: type[S]) -> type[S]:
|
102
|
+
setattr(scheduler, SCHEDULER_ATTRIBUTE, self)
|
103
|
+
|
104
|
+
return scheduler
|
105
|
+
|
106
|
+
@property
|
107
|
+
def scheduled_tasks(self) -> list[ScheduledTask]:
|
108
|
+
return self._scheduled_tasks
|
109
|
+
|
110
|
+
|
111
|
+
@dataclass
|
112
|
+
class ScheduledTaskMeta:
|
113
|
+
cron_expression: str
|
114
|
+
timezone: str | None = None
|
115
|
+
|
116
|
+
|
117
|
+
def schedule(
|
118
|
+
cron_expression: str, *, timezone: str | None = None
|
119
|
+
) -> Callable[[Callable], Callable]:
|
120
|
+
def wrapper(task: Callable) -> Callable:
|
121
|
+
setattr(
|
122
|
+
task,
|
123
|
+
SCHEDULED_TASK_ATTRIBUTE,
|
124
|
+
ScheduledTaskMeta(
|
125
|
+
cron_expression=cron_expression, timezone=timezone
|
126
|
+
),
|
127
|
+
)
|
128
|
+
|
129
|
+
return task
|
130
|
+
|
131
|
+
return wrapper
|
132
|
+
|
133
|
+
|
134
|
+
class SchedulerBase:
|
135
|
+
def get_scheduler(self) -> Scheduler:
|
136
|
+
scheduler = getattr(self, SCHEDULER_ATTRIBUTE, None)
|
137
|
+
|
138
|
+
if scheduler is None or not isinstance(scheduler, Scheduler):
|
139
|
+
raise TypeError(
|
140
|
+
f"{self.__class__.__name__} not a scheduler, possibly no annotated with either `Scheduler`"
|
141
|
+
)
|
142
|
+
|
143
|
+
return scheduler
|
144
|
+
|
145
|
+
def register_scheduled_tasks(self) -> Scheduler:
|
146
|
+
scheduler = self.get_scheduler()
|
147
|
+
|
148
|
+
for attribute_name in dir(self):
|
149
|
+
attribute = getattr(self, attribute_name, None)
|
150
|
+
|
151
|
+
if attribute is None:
|
152
|
+
continue
|
153
|
+
|
154
|
+
scheduled_task_meta = getattr(
|
155
|
+
attribute, SCHEDULED_TASK_ATTRIBUTE, None
|
156
|
+
)
|
157
|
+
|
158
|
+
if scheduled_task_meta is None:
|
159
|
+
continue
|
160
|
+
|
161
|
+
if not isinstance(scheduled_task_meta, ScheduledTaskMeta):
|
162
|
+
raise TypeError(
|
163
|
+
f"expected `{SCHEDULED_TASK_ATTRIBUTE}` to by of type `ScheduledTaskMeta`, got {type(scheduled_task_meta)}"
|
164
|
+
)
|
165
|
+
|
166
|
+
scheduler.add_task(
|
167
|
+
task=attribute,
|
168
|
+
cron_expression=scheduled_task_meta.cron_expression,
|
169
|
+
timezone=scheduled_task_meta.timezone,
|
170
|
+
)
|
171
|
+
|
172
|
+
return scheduler
|
173
|
+
|
174
|
+
|
175
|
+
class ScheduleManager(AsyncEventLoopMixin):
|
176
|
+
SCHEDULE_MANAGER_STATE = PrometheusEnum(
|
177
|
+
name="schedule_manager_state",
|
178
|
+
documentation="Schedule manager state",
|
179
|
+
states=["running", "stopped"],
|
180
|
+
)
|
181
|
+
|
182
|
+
def __init__(
|
183
|
+
self,
|
184
|
+
schedulers: list[Scheduler | type[SchedulerBase]],
|
185
|
+
logstash: BaseLogstashSender,
|
186
|
+
container: Container | None = None,
|
187
|
+
):
|
188
|
+
self._container = container or Container()
|
189
|
+
self._logstash = logstash
|
190
|
+
self._scheduled_tasks: list[ScheduledTask] = []
|
191
|
+
|
192
|
+
for index, scheduler in enumerate(schedulers):
|
193
|
+
if isinstance(scheduler, Scheduler):
|
194
|
+
self._scheduled_tasks.extend(scheduler.scheduled_tasks)
|
195
|
+
elif isinstance(scheduler, type) and issubclass(
|
196
|
+
scheduler, SchedulerBase
|
197
|
+
):
|
198
|
+
self._container.register(
|
199
|
+
service=SchedulerBase,
|
200
|
+
factory=scheduler,
|
201
|
+
scope=Scope.singleton,
|
202
|
+
)
|
203
|
+
else:
|
204
|
+
raise TypeError(
|
205
|
+
f"scheduler {index} is {type(scheduler)}, expected instance of type or subclass of `Scheduler` or `type[SchedulerBase]`"
|
206
|
+
)
|
207
|
+
|
208
|
+
self._next_run_in = None
|
209
|
+
self._logger = LoggerProvider.default().get_logger("schedule_manager")
|
210
|
+
|
211
|
+
@property
|
212
|
+
def container(self) -> Container:
|
213
|
+
return self._container
|
214
|
+
|
215
|
+
@property
|
216
|
+
def next_run_in(self) -> int:
|
217
|
+
self._calculate_next_schedule()
|
218
|
+
|
219
|
+
return self._next_run_in or 0
|
220
|
+
|
221
|
+
@property
|
222
|
+
def scheduled_task_count(self) -> int:
|
223
|
+
return len(self._scheduled_tasks)
|
224
|
+
|
225
|
+
def start(self):
|
226
|
+
if not self._aquired_lock():
|
227
|
+
return
|
228
|
+
|
229
|
+
if (
|
230
|
+
getattr(self, "_scheduler_task", None) is not None
|
231
|
+
and not self._scheduler_task.done()
|
232
|
+
):
|
233
|
+
return RuntimeError("scheduler already running")
|
234
|
+
|
235
|
+
self.use_schedulers()
|
236
|
+
self._logger.info(
|
237
|
+
"schedule manager started for %d %s",
|
238
|
+
self.scheduled_task_count,
|
239
|
+
"tasks" if self.scheduled_task_count > 1 else "task",
|
240
|
+
)
|
241
|
+
|
242
|
+
if self.scheduled_task_count == 0:
|
243
|
+
return
|
244
|
+
|
245
|
+
self._scheduler_task = self.loop.create_task(self._run_scheduler())
|
246
|
+
|
247
|
+
self._scheduler_task.add_done_callback(self._on_scheduler_done)
|
248
|
+
self.SCHEDULE_MANAGER_STATE.state("running")
|
249
|
+
|
250
|
+
def use_schedulers(self):
|
251
|
+
for scheduler in [
|
252
|
+
scheduler.register_scheduled_tasks()
|
253
|
+
for scheduler in self._container.resolve_all(SchedulerBase)
|
254
|
+
]:
|
255
|
+
self._scheduled_tasks.extend(scheduler.scheduled_tasks)
|
256
|
+
|
257
|
+
def stop(self):
|
258
|
+
if not self._scheduler_task.done():
|
259
|
+
self._scheduler_task.cancel()
|
260
|
+
|
261
|
+
self.SCHEDULE_MANAGER_STATE.state("stopped")
|
262
|
+
|
263
|
+
def _on_scheduler_done(self, task: Task):
|
264
|
+
if task.cancelled():
|
265
|
+
return
|
266
|
+
|
267
|
+
exception = task.exception()
|
268
|
+
|
269
|
+
if exception is not None:
|
270
|
+
self._logstash.error(
|
271
|
+
message="error occured in schedule manager",
|
272
|
+
exception=exception,
|
273
|
+
)
|
274
|
+
|
275
|
+
return
|
276
|
+
|
277
|
+
self._logger.info("schedule manager stopping")
|
278
|
+
|
279
|
+
def _aquired_lock(self) -> bool:
|
280
|
+
if osname != "posix":
|
281
|
+
self._logger.warning("lock not supported in %s", osname)
|
282
|
+
|
283
|
+
return False
|
284
|
+
|
285
|
+
try:
|
286
|
+
fcntl = import_module("fcntl")
|
287
|
+
lockf = fcntl.lockf
|
288
|
+
LOCK_EX = fcntl.LOCK_EX
|
289
|
+
LOCK_NB = fcntl.LOCK_NB
|
290
|
+
|
291
|
+
self._fd = open(file="scheduler.lock", mode="w+", encoding="utf-8")
|
292
|
+
|
293
|
+
lockf(self._fd, LOCK_EX | LOCK_NB)
|
294
|
+
except OSError:
|
295
|
+
self._logger.warning("a schedule manager already running")
|
296
|
+
|
297
|
+
return False
|
298
|
+
except ModuleNotFoundError:
|
299
|
+
self._logger.exception("module `fcntl` no found")
|
300
|
+
|
301
|
+
return False
|
302
|
+
|
303
|
+
return True
|
304
|
+
|
305
|
+
async def _run_scheduler(self):
|
306
|
+
while True:
|
307
|
+
self._calculate_next_schedule()
|
308
|
+
self._logger.debug(
|
309
|
+
"next tasks will be executed after `%d` seconds",
|
310
|
+
self._next_run_in or 0,
|
311
|
+
)
|
312
|
+
|
313
|
+
await sleep(self._next_run_in or 0)
|
314
|
+
self._logger.debug(
|
315
|
+
"executing tasks after `%d` seconds since last execution",
|
316
|
+
self.next_run_in or 0,
|
317
|
+
)
|
318
|
+
|
319
|
+
for scheduled_task in self._scheduled_tasks:
|
320
|
+
next_run_in_diff = abs(
|
321
|
+
(self._next_run_in or 0) - (scheduled_task.next_run_in or 0)
|
322
|
+
)
|
323
|
+
|
324
|
+
if next_run_in_diff > 60:
|
325
|
+
continue
|
326
|
+
|
327
|
+
args = self._resolve_dependencies(scheduled_task)
|
328
|
+
|
329
|
+
if iscoroutinefunction(scheduled_task.task):
|
330
|
+
self.loop.create_task(
|
331
|
+
scheduled_task.task(**args)
|
332
|
+
).add_done_callback(self._on_task_done)
|
333
|
+
else:
|
334
|
+
self.loop.run_in_executor(
|
335
|
+
executor=None, func=scheduled_task.task
|
336
|
+
).add_done_callback(partial(self._on_task_done, **args))
|
337
|
+
|
338
|
+
scheduled_task.ran = True
|
339
|
+
|
340
|
+
def _resolve_dependencies(
|
341
|
+
self, scheduled_task: ScheduledTask
|
342
|
+
) -> dict[str, Any]:
|
343
|
+
args = {}
|
344
|
+
|
345
|
+
if self._container is None:
|
346
|
+
return args
|
347
|
+
|
348
|
+
for parameter_name, dependency in scheduled_task.parameters.items():
|
349
|
+
args[parameter_name] = self._container.resolve(dependency)
|
350
|
+
|
351
|
+
return args
|
352
|
+
|
353
|
+
def _on_task_done(self, task_or_future: Task | Future):
|
354
|
+
if task_or_future.cancelled():
|
355
|
+
return
|
356
|
+
|
357
|
+
exception = task_or_future.exception()
|
358
|
+
|
359
|
+
if exception is not None:
|
360
|
+
self._logstash.error(
|
361
|
+
message="error occured while executing task",
|
362
|
+
exception=exception,
|
363
|
+
)
|
364
|
+
|
365
|
+
def _calculate_next_schedule(self):
|
366
|
+
prev_run_in = self._next_run_in or 0
|
367
|
+
self._next_run_in = None
|
368
|
+
|
369
|
+
for scheduled_task in self._scheduled_tasks:
|
370
|
+
if (
|
371
|
+
not scheduled_task.ran
|
372
|
+
and scheduled_task.next_run_in is not None
|
373
|
+
):
|
374
|
+
scheduled_task.next_run_in = (
|
375
|
+
scheduled_task.next_run_in - prev_run_in
|
376
|
+
)
|
377
|
+
|
378
|
+
if (
|
379
|
+
self._next_run_in is not None
|
380
|
+
and (scheduled_task.next_run_in or 0) < self._next_run_in
|
381
|
+
) or self._next_run_in is None:
|
382
|
+
self._next_run_in = scheduled_task.next_run_in
|
383
|
+
|
384
|
+
continue
|
385
|
+
|
386
|
+
current_datetime = datetime.now(tz=scheduled_task.zone_info)
|
387
|
+
next_datetime = next(
|
388
|
+
CronSim(
|
389
|
+
expr=scheduled_task.cron_expression,
|
390
|
+
dt=datetime.now(tz=scheduled_task.zone_info),
|
391
|
+
)
|
392
|
+
)
|
393
|
+
next_run_in = (next_datetime - current_datetime).seconds
|
394
|
+
|
395
|
+
if next_run_in == 0:
|
396
|
+
continue
|
397
|
+
|
398
|
+
if self._next_run_in is None or next_run_in < self._next_run_in:
|
399
|
+
self._next_run_in = next_run_in
|
400
|
+
|
401
|
+
scheduled_task.next_run_in = next_run_in
|
402
|
+
scheduled_task.ran = False
|
@@ -0,0 +1,205 @@
|
|
1
|
+
from asyncio import Future
|
2
|
+
from enum import Enum
|
3
|
+
from os import environ
|
4
|
+
from typing import AbstractSet, Annotated, Any
|
5
|
+
|
6
|
+
from fastapi import Depends, Header
|
7
|
+
from jwt import JWT, AbstractJWKBase, jwk_from_dict
|
8
|
+
from jwt.exceptions import JWTDecodeError
|
9
|
+
from jwt.utils import get_int_from_datetime, get_time_from_int
|
10
|
+
from passlib.context import CryptContext
|
11
|
+
from pydantic import BaseModel, Field, ValidationError
|
12
|
+
|
13
|
+
from .dependencies.http import DependsOn
|
14
|
+
from .exceptions import Unauthorized
|
15
|
+
from .utils import AsyncEventLoopMixin
|
16
|
+
|
17
|
+
__all__ = [
|
18
|
+
"EndpointACL",
|
19
|
+
"get_int_from_datetime",
|
20
|
+
"get_time_from_int",
|
21
|
+
"jwk_from_dict",
|
22
|
+
"JwtAdapter",
|
23
|
+
"PasswordHasher",
|
24
|
+
"PermissionMatch",
|
25
|
+
"UserInfo",
|
26
|
+
]
|
27
|
+
|
28
|
+
|
29
|
+
MESSAGE = "you are not authorized to access request resouce"
|
30
|
+
RESPONSE_CODE = int(environ.get("UNAUTHORIZED_RESPONSE_CODE") or 0)
|
31
|
+
|
32
|
+
|
33
|
+
class PasswordHasher(AsyncEventLoopMixin):
|
34
|
+
def __init__(self):
|
35
|
+
self._crypt_context = CryptContext(
|
36
|
+
schemes=["bcrypt"], deprecated="auto"
|
37
|
+
)
|
38
|
+
|
39
|
+
def hash(self, password: str) -> Future[str]:
|
40
|
+
return self.loop.run_in_executor(
|
41
|
+
None, self._crypt_context.hash, password
|
42
|
+
)
|
43
|
+
|
44
|
+
def verify(self, password: str, password_hash: str) -> Future[bool]:
|
45
|
+
return self.loop.run_in_executor(
|
46
|
+
None, self._crypt_context.verify, password, password_hash
|
47
|
+
)
|
48
|
+
|
49
|
+
|
50
|
+
class JwtAdapter(AsyncEventLoopMixin):
|
51
|
+
def __init__(self):
|
52
|
+
self._jwt = JWT()
|
53
|
+
|
54
|
+
def encode(
|
55
|
+
self,
|
56
|
+
payload: dict[str, Any],
|
57
|
+
key: AbstractJWKBase | None = None,
|
58
|
+
algorithm: str = "HS256",
|
59
|
+
optional_headers: dict[str, str] | None = None,
|
60
|
+
) -> Future[str]:
|
61
|
+
return self.loop.run_in_executor(
|
62
|
+
None, self._jwt.encode, payload, key, algorithm, optional_headers
|
63
|
+
)
|
64
|
+
|
65
|
+
def decode(
|
66
|
+
self,
|
67
|
+
message: str,
|
68
|
+
key: AbstractJWKBase | None = None,
|
69
|
+
do_verify: bool = True,
|
70
|
+
algorithms: AbstractSet[str] | None = None,
|
71
|
+
do_time_check: bool = True,
|
72
|
+
) -> Future[dict[str, Any]]:
|
73
|
+
return self.loop.run_in_executor(
|
74
|
+
None,
|
75
|
+
self._jwt.decode,
|
76
|
+
message,
|
77
|
+
key,
|
78
|
+
do_verify,
|
79
|
+
algorithms,
|
80
|
+
do_time_check,
|
81
|
+
)
|
82
|
+
|
83
|
+
|
84
|
+
class UserInfo(BaseModel):
|
85
|
+
user_id: str = Field(alias="userId")
|
86
|
+
user_type: str = Field(alias="type")
|
87
|
+
user_permissions: list[str] | None = Field(
|
88
|
+
default=None, alias="permissions"
|
89
|
+
)
|
90
|
+
|
91
|
+
|
92
|
+
async def extract_user_info(
|
93
|
+
jwt_adapter: Annotated[JwtAdapter, DependsOn(JwtAdapter)],
|
94
|
+
token: Annotated[
|
95
|
+
str | None, Header(alias=environ.get("TOKEN_HEADER") or "authorization")
|
96
|
+
] = None,
|
97
|
+
user_agent: Annotated[str | None, Header(alias="user-agent")] = None,
|
98
|
+
) -> UserInfo:
|
99
|
+
extra = {"userAgent": user_agent} if user_agent is not None else None
|
100
|
+
|
101
|
+
if token is None:
|
102
|
+
raise Unauthorized(
|
103
|
+
message=MESSAGE,
|
104
|
+
response_code=RESPONSE_CODE,
|
105
|
+
extra=extra,
|
106
|
+
)
|
107
|
+
|
108
|
+
try:
|
109
|
+
payload = await jwt_adapter.decode(
|
110
|
+
message=token, do_verify=False, do_time_check=True
|
111
|
+
)
|
112
|
+
except JWTDecodeError as e:
|
113
|
+
raise Unauthorized(
|
114
|
+
message=MESSAGE,
|
115
|
+
response_code=RESPONSE_CODE,
|
116
|
+
extra=extra,
|
117
|
+
extract_exc_info=True,
|
118
|
+
) from e
|
119
|
+
|
120
|
+
try:
|
121
|
+
user_info = UserInfo.model_validate(payload)
|
122
|
+
except ValidationError as e:
|
123
|
+
raise Unauthorized(
|
124
|
+
message=MESSAGE,
|
125
|
+
response_code=RESPONSE_CODE,
|
126
|
+
extra=extra,
|
127
|
+
extract_exc_info=True,
|
128
|
+
) from e
|
129
|
+
|
130
|
+
return user_info
|
131
|
+
|
132
|
+
|
133
|
+
class PermissionMatch(Enum):
|
134
|
+
SOME = 0
|
135
|
+
ALL = 1
|
136
|
+
|
137
|
+
|
138
|
+
def EndpointACL(
|
139
|
+
user_type: str | None = None,
|
140
|
+
permissions: list[str] | None = None,
|
141
|
+
permission_match_strategy: PermissionMatch | None = None,
|
142
|
+
) -> Any:
|
143
|
+
return Depends(
|
144
|
+
EndpointAclValidator(
|
145
|
+
user_type=user_type,
|
146
|
+
permissions=permissions,
|
147
|
+
permission_match_strategy=permission_match_strategy,
|
148
|
+
)
|
149
|
+
)
|
150
|
+
|
151
|
+
|
152
|
+
class EndpointAclValidator:
|
153
|
+
def __init__(
|
154
|
+
self,
|
155
|
+
user_type: str | None = None,
|
156
|
+
permissions: list[str] | None = None,
|
157
|
+
permission_match_strategy: PermissionMatch | None = None,
|
158
|
+
):
|
159
|
+
self._user_type = user_type
|
160
|
+
self._permissions = permissions
|
161
|
+
self._permission_match_strategy = (
|
162
|
+
permission_match_strategy or PermissionMatch.SOME
|
163
|
+
)
|
164
|
+
|
165
|
+
def __call__(
|
166
|
+
self, user_info: Annotated[UserInfo, Depends(extract_user_info)]
|
167
|
+
) -> UserInfo:
|
168
|
+
if (
|
169
|
+
self._user_type is not None
|
170
|
+
and (
|
171
|
+
user_info.user_type is None
|
172
|
+
or user_info.user_type != self._user_type
|
173
|
+
)
|
174
|
+
) or (
|
175
|
+
self._permissions is not None
|
176
|
+
and (
|
177
|
+
user_info.user_permissions is None
|
178
|
+
or not self._permissions_match(user_info.user_permissions)
|
179
|
+
)
|
180
|
+
):
|
181
|
+
raise Unauthorized(
|
182
|
+
message=MESSAGE,
|
183
|
+
response_code=RESPONSE_CODE,
|
184
|
+
tags=[user_info.user_id],
|
185
|
+
extra={
|
186
|
+
"userId": user_info.user_id,
|
187
|
+
"userYype": user_info.user_type,
|
188
|
+
"userPermissions": str(user_info.user_permissions or []),
|
189
|
+
"requiredUserType": self._user_type or "None",
|
190
|
+
"requiredPermissions": str(self._permissions or []),
|
191
|
+
"permissionMatchStrategy": self._permission_match_strategy.name,
|
192
|
+
},
|
193
|
+
)
|
194
|
+
|
195
|
+
return user_info
|
196
|
+
|
197
|
+
def _permissions_match(self, user_permissions: list[str]) -> bool:
|
198
|
+
assert self._permissions is not None
|
199
|
+
|
200
|
+
if self._permission_match_strategy == PermissionMatch.ALL:
|
201
|
+
return sorted(self._permissions) == sorted(user_permissions)
|
202
|
+
|
203
|
+
return any(
|
204
|
+
permission in self._permissions for permission in user_permissions
|
205
|
+
)
|
qena_shared_lib/utils.py
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
from asyncio import AbstractEventLoop, get_running_loop
|
2
|
+
|
3
|
+
__all__ = ["AsyncEventLoopMixin"]
|
4
|
+
|
5
|
+
ABSTRACT_EVENT_LOOP_ATTRIBUTE = "__abstract_event_loop__"
|
6
|
+
|
7
|
+
|
8
|
+
class AsyncEventLoopMixin:
|
9
|
+
@property
|
10
|
+
def loop(self) -> AbstractEventLoop:
|
11
|
+
previous_running_loop = getattr(
|
12
|
+
self, ABSTRACT_EVENT_LOOP_ATTRIBUTE, None
|
13
|
+
)
|
14
|
+
current_running_loop = None
|
15
|
+
|
16
|
+
try:
|
17
|
+
current_running_loop = get_running_loop()
|
18
|
+
except RuntimeError:
|
19
|
+
if previous_running_loop is None:
|
20
|
+
raise
|
21
|
+
|
22
|
+
if previous_running_loop is None or (
|
23
|
+
current_running_loop is not None
|
24
|
+
and previous_running_loop != current_running_loop
|
25
|
+
):
|
26
|
+
setattr(self, ABSTRACT_EVENT_LOOP_ATTRIBUTE, current_running_loop)
|
27
|
+
|
28
|
+
return getattr(self, ABSTRACT_EVENT_LOOP_ATTRIBUTE)
|