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.
@@ -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
+ )
@@ -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)