melony 1.0.1__tar.gz
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.
- melony-1.0.1/PKG-INFO +19 -0
- melony-1.0.1/README.md +0 -0
- melony-1.0.1/melony/__init__.py +0 -0
- melony-1.0.1/melony/core/__init__.py +0 -0
- melony-1.0.1/melony/core/brokers.py +104 -0
- melony-1.0.1/melony/core/consts.py +5 -0
- melony-1.0.1/melony/core/consumers.py +217 -0
- melony-1.0.1/melony/core/dto.py +22 -0
- melony-1.0.1/melony/core/json_task_converter.py +63 -0
- melony-1.0.1/melony/core/publishers.py +29 -0
- melony-1.0.1/melony/core/result_backends.py +24 -0
- melony-1.0.1/melony/core/task_executor.py +127 -0
- melony-1.0.1/melony/core/task_finders.py +21 -0
- melony-1.0.1/melony/core/task_wrappers.py +87 -0
- melony-1.0.1/melony/core/tasks.py +121 -0
- melony-1.0.1/melony/logger.py +42 -0
- melony-1.0.1/melony/redis_broker/__init__.py +0 -0
- melony-1.0.1/melony/redis_broker/brokers.py +70 -0
- melony-1.0.1/melony/redis_broker/consumers.py +104 -0
- melony-1.0.1/melony/redis_broker/publishers.py +50 -0
- melony-1.0.1/melony/redis_broker/result_backends.py +74 -0
- melony-1.0.1/melony.egg-info/PKG-INFO +19 -0
- melony-1.0.1/melony.egg-info/SOURCES.txt +27 -0
- melony-1.0.1/melony.egg-info/dependency_links.txt +1 -0
- melony-1.0.1/melony.egg-info/requires.txt +7 -0
- melony-1.0.1/melony.egg-info/top_level.txt +1 -0
- melony-1.0.1/pyproject.toml +14 -0
- melony-1.0.1/setup.cfg +4 -0
- melony-1.0.1/setup.py +38 -0
melony-1.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: melony
|
|
3
|
+
Version: 1.0.1
|
|
4
|
+
Summary: Multisync task manager for python
|
|
5
|
+
Home-page: https://melony-python.com/
|
|
6
|
+
Author: wimble3
|
|
7
|
+
Author-email: wimble@internet.ru
|
|
8
|
+
License: UNKNOWN
|
|
9
|
+
Project-URL: Documentation, https://melony-python.com/docs
|
|
10
|
+
Keywords: task manager python melony
|
|
11
|
+
Platform: UNKNOWN
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Requires-Python: >=3.12
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
UNKNOWN
|
|
19
|
+
|
melony-1.0.1/README.md
ADDED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from functools import wraps
|
|
5
|
+
from inspect import signature
|
|
6
|
+
from typing import Callable, ParamSpec, TypeVar, final, overload
|
|
7
|
+
|
|
8
|
+
from melony.core.consumers import BaseConsumer
|
|
9
|
+
from melony.core.publishers import Publisher
|
|
10
|
+
from melony.core.task_wrappers import AsyncTaskWrapper, SyncTaskWrapper, TaskWrapper
|
|
11
|
+
|
|
12
|
+
_TaskParams = ParamSpec("_TaskParams")
|
|
13
|
+
_TaskResult = TypeVar("_TaskResult")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BaseBroker(ABC):
|
|
17
|
+
@property
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def publisher(self) -> Publisher:
|
|
20
|
+
...
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def consumer(self) -> BaseConsumer:
|
|
25
|
+
...
|
|
26
|
+
|
|
27
|
+
@overload
|
|
28
|
+
def task(
|
|
29
|
+
self,
|
|
30
|
+
func: Callable[_TaskParams, _TaskResult]
|
|
31
|
+
) -> Callable[_TaskParams, TaskWrapper]:
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
@overload
|
|
35
|
+
def task(
|
|
36
|
+
self,
|
|
37
|
+
*,
|
|
38
|
+
retries: int = 1,
|
|
39
|
+
retry_timeout: int = 0
|
|
40
|
+
) -> Callable[
|
|
41
|
+
[Callable[_TaskParams, _TaskResult]], # type: ignore
|
|
42
|
+
Callable[_TaskParams, TaskWrapper]
|
|
43
|
+
]:
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
@final
|
|
47
|
+
def task(
|
|
48
|
+
self,
|
|
49
|
+
func: Callable[_TaskParams, _TaskResult] | None = None,
|
|
50
|
+
*,
|
|
51
|
+
retries: int = 1,
|
|
52
|
+
retry_timeout: int = 0,
|
|
53
|
+
) -> Callable[_TaskParams, TaskWrapper] | Callable[[Callable], Callable]:
|
|
54
|
+
self._validate_params(retries, retry_timeout)
|
|
55
|
+
def _decorate( # noqa: WPS430
|
|
56
|
+
func: Callable[_TaskParams, _TaskResult]
|
|
57
|
+
) -> Callable[_TaskParams, TaskWrapper]:
|
|
58
|
+
@wraps(func)
|
|
59
|
+
def wrapper(
|
|
60
|
+
*args: _TaskParams.args,
|
|
61
|
+
**kwargs: _TaskParams.kwargs
|
|
62
|
+
) -> TaskWrapper:
|
|
63
|
+
sig = signature(func)
|
|
64
|
+
bound = sig.bind(*args, **kwargs)
|
|
65
|
+
bound.apply_defaults()
|
|
66
|
+
|
|
67
|
+
if asyncio.iscoroutinefunction(func):
|
|
68
|
+
return AsyncTaskWrapper(
|
|
69
|
+
func=func,
|
|
70
|
+
broker=self,
|
|
71
|
+
bound_args=bound.arguments,
|
|
72
|
+
retries=retries,
|
|
73
|
+
retry_timeout=retry_timeout
|
|
74
|
+
)
|
|
75
|
+
else:
|
|
76
|
+
return SyncTaskWrapper(
|
|
77
|
+
func=func,
|
|
78
|
+
broker=self,
|
|
79
|
+
bound_args=bound.arguments,
|
|
80
|
+
retries=retries,
|
|
81
|
+
retry_timeout=retry_timeout
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
wrapper.__annotations__ = {
|
|
85
|
+
**func.__annotations__,
|
|
86
|
+
"return": AsyncTaskWrapper
|
|
87
|
+
}
|
|
88
|
+
return wrapper
|
|
89
|
+
|
|
90
|
+
if func is None:
|
|
91
|
+
return _decorate
|
|
92
|
+
else:
|
|
93
|
+
return _decorate(func)
|
|
94
|
+
|
|
95
|
+
@final
|
|
96
|
+
def _validate_params(self, retries: int, retry_timeout: int) -> None:
|
|
97
|
+
if retries <= 0:
|
|
98
|
+
raise ValueError(
|
|
99
|
+
"Parameter 'retries' must be positive integer (without zero)"
|
|
100
|
+
)
|
|
101
|
+
if retry_timeout < 0:
|
|
102
|
+
raise ValueError(
|
|
103
|
+
"Parameter 'retry_timeout' must be positive integer or zero"
|
|
104
|
+
)
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import multiprocessing
|
|
3
|
+
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Sequence, final
|
|
7
|
+
from redis.exceptions import ConnectionError
|
|
8
|
+
|
|
9
|
+
from melony.core.dto import FilteredTasksDTO, TaskExecResultsDTO
|
|
10
|
+
from melony.core.publishers import IAsyncPublisher, ISyncPublisher
|
|
11
|
+
from melony.core.result_backends import (
|
|
12
|
+
IAsyncResultBackendSaver,
|
|
13
|
+
IResultBackend,
|
|
14
|
+
ISyncResultBackendSaver
|
|
15
|
+
)
|
|
16
|
+
from melony.core.task_executor import AsyncTaskExecutor, SyncTaskExecutor
|
|
17
|
+
from melony.core.tasks import Task
|
|
18
|
+
from melony.logger import log_error, log_info
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BaseConsumer:
|
|
22
|
+
@final
|
|
23
|
+
def _filter_tasks_by_execution_time(
|
|
24
|
+
self,
|
|
25
|
+
tasks: Sequence[Task],
|
|
26
|
+
consumer_id
|
|
27
|
+
) -> FilteredTasksDTO:
|
|
28
|
+
tasks_to_execute: list[Task] = []
|
|
29
|
+
tasks_to_push_back: list[Task] = []
|
|
30
|
+
current_timestamp = datetime.timestamp(datetime.now())
|
|
31
|
+
|
|
32
|
+
for task in tasks:
|
|
33
|
+
task_execution_timestamp = task.get_execution_timestamp()
|
|
34
|
+
if task_execution_timestamp < current_timestamp:
|
|
35
|
+
tasks_to_execute.append(task)
|
|
36
|
+
else:
|
|
37
|
+
tasks_to_push_back.append(task)
|
|
38
|
+
|
|
39
|
+
log_info(f"@@@ {len(tasks_to_execute)=}", consumer_id=consumer_id)
|
|
40
|
+
return FilteredTasksDTO(
|
|
41
|
+
tasks_to_execute=tasks_to_execute,
|
|
42
|
+
tasks_to_push_back=tasks_to_push_back
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class BaseAsyncConsumer(ABC, BaseConsumer): # noqa: WPS214
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
publisher: IAsyncPublisher,
|
|
50
|
+
result_backend: IResultBackend | None = None
|
|
51
|
+
) -> None:
|
|
52
|
+
self._publisher = publisher
|
|
53
|
+
self._connection = self._publisher.connection
|
|
54
|
+
self._result_backend = result_backend
|
|
55
|
+
|
|
56
|
+
result_backend_saver = result_backend.saver if result_backend else None
|
|
57
|
+
assert isinstance(result_backend_saver, IAsyncResultBackendSaver)
|
|
58
|
+
|
|
59
|
+
self._task_executor = AsyncTaskExecutor(
|
|
60
|
+
connection=self._connection,
|
|
61
|
+
result_backend_saver=result_backend_saver
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@final
|
|
66
|
+
async def start_consume(self, processes: int = 1) -> None:
|
|
67
|
+
if processes < 1:
|
|
68
|
+
raise ValueError("Param 'processes' must be positive integer (without zero)")
|
|
69
|
+
|
|
70
|
+
if processes == 1:
|
|
71
|
+
await self._consumer_loop()
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
for process_num in range(processes):
|
|
75
|
+
process = multiprocessing.Process(
|
|
76
|
+
name=f"melony-process-{process_num}",
|
|
77
|
+
target=self._run_consumer_in_process,
|
|
78
|
+
args=(process_num,),
|
|
79
|
+
daemon=False
|
|
80
|
+
)
|
|
81
|
+
process.start()
|
|
82
|
+
|
|
83
|
+
@abstractmethod
|
|
84
|
+
async def _pop_tasks(self) -> Sequence[Task]:
|
|
85
|
+
...
|
|
86
|
+
|
|
87
|
+
@final
|
|
88
|
+
async def _consumer_loop(self, consumer_id: int = 1) -> None:
|
|
89
|
+
log_info("Start listening...", consumer_id=consumer_id)
|
|
90
|
+
while True:
|
|
91
|
+
try:
|
|
92
|
+
await self._consumer_loop_iteration(consumer_id)
|
|
93
|
+
except ConnectionError as exc:
|
|
94
|
+
log_error(f"Redis connection error", exc=exc)
|
|
95
|
+
break
|
|
96
|
+
except Exception as exc:
|
|
97
|
+
log_error(f"Unexpected error at consuming loop", exc=exc)
|
|
98
|
+
break
|
|
99
|
+
|
|
100
|
+
@final
|
|
101
|
+
async def _consumer_loop_iteration(self, consumer_id) -> None:
|
|
102
|
+
tasks = await self._pop_tasks()
|
|
103
|
+
filtered_tasks = self._filter_tasks_by_execution_time(tasks, consumer_id)
|
|
104
|
+
await self._push_bulk(tasks=filtered_tasks.tasks_to_push_back)
|
|
105
|
+
wait_task_results = await self._task_executor.execute_tasks(
|
|
106
|
+
tasks=filtered_tasks.tasks_to_execute
|
|
107
|
+
)
|
|
108
|
+
await self._retry_policy(wait_task_results)
|
|
109
|
+
|
|
110
|
+
@final
|
|
111
|
+
async def _push_bulk(self, tasks: Sequence[Task]) -> None:
|
|
112
|
+
push_coroutines = [self._publisher.push(task) for task in tasks]
|
|
113
|
+
await asyncio.gather(*[coro for coro in push_coroutines if coro is not None])
|
|
114
|
+
|
|
115
|
+
@final
|
|
116
|
+
async def _retry_policy(
|
|
117
|
+
self,
|
|
118
|
+
tasks_exec_results: TaskExecResultsDTO
|
|
119
|
+
) -> None:
|
|
120
|
+
tasks_to_push_back: list[Task] = []
|
|
121
|
+
for task_to_retry in tasks_exec_results.tasks_to_retry:
|
|
122
|
+
if task_to_retry._meta.retries_left:
|
|
123
|
+
if task_to_retry.retry_timeout:
|
|
124
|
+
task_to_retry._meta.timestamp += task_to_retry.retry_timeout
|
|
125
|
+
tasks_to_push_back.append(task_to_retry)
|
|
126
|
+
|
|
127
|
+
await self._push_bulk(tasks_to_push_back)
|
|
128
|
+
|
|
129
|
+
@final
|
|
130
|
+
def _run_consumer_in_process(
|
|
131
|
+
self,
|
|
132
|
+
consumer_id: int = 0
|
|
133
|
+
) -> None:
|
|
134
|
+
asyncio.run(self._consumer_loop(consumer_id=consumer_id))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class BaseSyncConsumer(ABC, BaseConsumer):
|
|
138
|
+
def __init__(
|
|
139
|
+
self,
|
|
140
|
+
publisher: ISyncPublisher,
|
|
141
|
+
result_backend: IResultBackend | None = None
|
|
142
|
+
) -> None:
|
|
143
|
+
self._publisher = publisher
|
|
144
|
+
self._connection = self._publisher.connection
|
|
145
|
+
self._result_backend = result_backend
|
|
146
|
+
|
|
147
|
+
result_backend_saver = result_backend.saver if result_backend else None
|
|
148
|
+
assert isinstance(result_backend_saver, ISyncResultBackendSaver)
|
|
149
|
+
|
|
150
|
+
self._task_executor = SyncTaskExecutor(
|
|
151
|
+
connection=self._connection,
|
|
152
|
+
result_backend_saver=result_backend_saver
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
@final
|
|
156
|
+
def start_consume(self, processes: int = 1) -> None:
|
|
157
|
+
if processes < 1:
|
|
158
|
+
raise ValueError("Param 'processes' must be positive integer (without zero)")
|
|
159
|
+
|
|
160
|
+
if processes == 1:
|
|
161
|
+
self._consumer_loop()
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
for process_num in range(processes):
|
|
165
|
+
process = multiprocessing.Process(
|
|
166
|
+
name=f"melony-process-{process_num}",
|
|
167
|
+
target=self._consumer_loop,
|
|
168
|
+
args=(process_num,),
|
|
169
|
+
daemon=False
|
|
170
|
+
)
|
|
171
|
+
process.start()
|
|
172
|
+
|
|
173
|
+
@abstractmethod
|
|
174
|
+
def _pop_tasks(self) -> Sequence[Task]:
|
|
175
|
+
...
|
|
176
|
+
|
|
177
|
+
@final
|
|
178
|
+
def _consumer_loop(self, consumer_id: int = 1) -> None:
|
|
179
|
+
log_info("Start listening...", consumer_id=consumer_id)
|
|
180
|
+
while True:
|
|
181
|
+
try:
|
|
182
|
+
self._consumer_loop_iteration(consumer_id)
|
|
183
|
+
except ConnectionError as exc:
|
|
184
|
+
log_error(f"Redis connection error", exc=exc)
|
|
185
|
+
break
|
|
186
|
+
except Exception as exc:
|
|
187
|
+
log_error(f"Unexpected error at consuming loop", exc=exc)
|
|
188
|
+
break
|
|
189
|
+
|
|
190
|
+
@final
|
|
191
|
+
def _consumer_loop_iteration(self, consumer_id) -> None:
|
|
192
|
+
tasks = self._pop_tasks()
|
|
193
|
+
filtered_tasks = self._filter_tasks_by_execution_time(tasks, consumer_id)
|
|
194
|
+
self._push_bulk(tasks=filtered_tasks.tasks_to_push_back)
|
|
195
|
+
wait_task_results = self._task_executor.execute_tasks(
|
|
196
|
+
tasks=filtered_tasks.tasks_to_execute
|
|
197
|
+
)
|
|
198
|
+
self._retry_policy(wait_task_results)
|
|
199
|
+
|
|
200
|
+
@final
|
|
201
|
+
def _push_bulk(self, tasks: Sequence[Task]) -> None:
|
|
202
|
+
for task in tasks:
|
|
203
|
+
self._publisher.push(task)
|
|
204
|
+
|
|
205
|
+
@final
|
|
206
|
+
def _retry_policy(
|
|
207
|
+
self,
|
|
208
|
+
tasks_exec_results: TaskExecResultsDTO
|
|
209
|
+
) -> None:
|
|
210
|
+
tasks_to_push_back: list[Task] = []
|
|
211
|
+
for task_to_retry in tasks_exec_results.tasks_to_retry:
|
|
212
|
+
if task_to_retry._meta.retries_left:
|
|
213
|
+
if task_to_retry.retry_timeout:
|
|
214
|
+
task_to_retry._meta.timestamp += task_to_retry.retry_timeout
|
|
215
|
+
tasks_to_push_back.append(task_to_retry)
|
|
216
|
+
|
|
217
|
+
self._push_bulk(tasks_to_push_back)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Any, Sequence
|
|
3
|
+
|
|
4
|
+
from melony.core.tasks import Task
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True, kw_only=True)
|
|
8
|
+
class FilteredTasksDTO:
|
|
9
|
+
tasks_to_execute: Sequence[Task]
|
|
10
|
+
tasks_to_push_back: Sequence[Task]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, kw_only=True)
|
|
14
|
+
class TaskResultDTO:
|
|
15
|
+
task: Task
|
|
16
|
+
task_result: Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, kw_only=True)
|
|
20
|
+
class TaskExecResultsDTO:
|
|
21
|
+
tasks_with_result: Sequence[TaskResultDTO]
|
|
22
|
+
tasks_to_retry: Sequence[Task]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
from melony.core.brokers import BaseBroker
|
|
5
|
+
from melony.core.task_finders import find_task_func
|
|
6
|
+
from melony.core.tasks import _TaskMeta, AsyncTask, SyncTask, Task
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class _BaseJsonTaskConverter(ABC):
|
|
10
|
+
@abstractmethod
|
|
11
|
+
def deserialize_task(
|
|
12
|
+
self,
|
|
13
|
+
serialized_task: str,
|
|
14
|
+
broker: BaseBroker
|
|
15
|
+
) -> Task:
|
|
16
|
+
...
|
|
17
|
+
|
|
18
|
+
def serialize_task(self, task: Task) -> str:
|
|
19
|
+
return task.as_json()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AsyncJsonTaskConverter(_BaseJsonTaskConverter):
|
|
23
|
+
def deserialize_task(
|
|
24
|
+
self,
|
|
25
|
+
serialized_task: str,
|
|
26
|
+
broker: BaseBroker
|
|
27
|
+
) -> Task:
|
|
28
|
+
task_dict = json.loads(serialized_task)
|
|
29
|
+
task_func_path = task_dict["func_path"]
|
|
30
|
+
task_func = find_task_func(task_func_path)
|
|
31
|
+
return AsyncTask(
|
|
32
|
+
task_id=task_dict["task_id"],
|
|
33
|
+
kwargs=task_dict["kwargs"],
|
|
34
|
+
countdown=task_dict["countdown"],
|
|
35
|
+
func=task_func,
|
|
36
|
+
func_path=task_func_path,
|
|
37
|
+
broker=broker,
|
|
38
|
+
retries=task_dict["retries"],
|
|
39
|
+
retry_timeout=task_dict["retry_timeout"],
|
|
40
|
+
_meta=_TaskMeta(**task_dict["_meta"])
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SyncJsonTaskConverter(_BaseJsonTaskConverter):
|
|
45
|
+
def deserialize_task(
|
|
46
|
+
self,
|
|
47
|
+
serialized_task: str,
|
|
48
|
+
broker: BaseBroker
|
|
49
|
+
) -> Task:
|
|
50
|
+
task_dict = json.loads(serialized_task)
|
|
51
|
+
task_func_path = task_dict["func_path"]
|
|
52
|
+
task_func = find_task_func(task_func_path)
|
|
53
|
+
return SyncTask(
|
|
54
|
+
task_id=task_dict["task_id"],
|
|
55
|
+
kwargs=task_dict["kwargs"],
|
|
56
|
+
countdown=task_dict["countdown"],
|
|
57
|
+
func=task_func,
|
|
58
|
+
func_path=task_func_path,
|
|
59
|
+
broker=broker,
|
|
60
|
+
retries=task_dict["retries"],
|
|
61
|
+
retry_timeout=task_dict["retry_timeout"],
|
|
62
|
+
_meta=_TaskMeta(**task_dict["_meta"])
|
|
63
|
+
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from redis.asyncio import Redis
|
|
3
|
+
from redis import Redis as SyncRedis
|
|
4
|
+
|
|
5
|
+
from melony.core.tasks import Task
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
type Publisher = IAsyncPublisher | ISyncPublisher
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class IAsyncPublisher(ABC):
|
|
12
|
+
@property
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def connection(self) -> Redis:
|
|
15
|
+
...
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
async def push(self, task: Task) -> None:
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
class ISyncPublisher(ABC):
|
|
22
|
+
@property
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def connection(self) -> SyncRedis:
|
|
25
|
+
...
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def push(self, task: Task) -> None:
|
|
29
|
+
...
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Sequence
|
|
3
|
+
|
|
4
|
+
from melony.core.dto import TaskResultDTO
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
type ResultBackendSaver = IAsyncResultBackendSaver | ISyncResultBackendSaver
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class IAsyncResultBackendSaver(ABC):
|
|
11
|
+
@abstractmethod
|
|
12
|
+
async def save_results(self, task_results: Sequence[TaskResultDTO]) -> None:
|
|
13
|
+
...
|
|
14
|
+
|
|
15
|
+
class ISyncResultBackendSaver(ABC):
|
|
16
|
+
@abstractmethod
|
|
17
|
+
def save_results(self, task_results: Sequence[TaskResultDTO]) -> None:
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
class IResultBackend(ABC):
|
|
21
|
+
@property
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def saver(self) -> ResultBackendSaver:
|
|
24
|
+
...
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from typing import Any, Sequence, final
|
|
5
|
+
from redis.asyncio import Redis
|
|
6
|
+
from redis import Redis as SyncRedis
|
|
7
|
+
|
|
8
|
+
from melony.core.dto import TaskResultDTO, TaskExecResultsDTO
|
|
9
|
+
from melony.core.result_backends import IAsyncResultBackendSaver, IResultBackend, ISyncResultBackendSaver
|
|
10
|
+
from melony.core.tasks import Task
|
|
11
|
+
from melony.logger import log_error, log_info
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@final
|
|
15
|
+
class AsyncTaskExecutor:
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
connection: Redis,
|
|
19
|
+
result_backend_saver: IAsyncResultBackendSaver | None = None
|
|
20
|
+
) -> None:
|
|
21
|
+
self._connection = connection
|
|
22
|
+
self._result_backend = result_backend_saver
|
|
23
|
+
|
|
24
|
+
@final
|
|
25
|
+
async def execute_tasks(self, tasks: Sequence[Task]) -> TaskExecResultsDTO:
|
|
26
|
+
task_map: dict[asyncio.Task, Task] = {}
|
|
27
|
+
asyncio_tasks: list[asyncio.Task] = []
|
|
28
|
+
|
|
29
|
+
for task in tasks:
|
|
30
|
+
asyncio_task = asyncio.create_task(task.execute())
|
|
31
|
+
task_map[asyncio_task] = task
|
|
32
|
+
asyncio_tasks.append(asyncio_task)
|
|
33
|
+
if task._meta.retries_left:
|
|
34
|
+
task._meta.retries_left -= 1
|
|
35
|
+
|
|
36
|
+
tasks_exec_results = await self._asyncio_wait_tasks(
|
|
37
|
+
pending=asyncio_tasks,
|
|
38
|
+
task_map=task_map
|
|
39
|
+
)
|
|
40
|
+
return tasks_exec_results
|
|
41
|
+
|
|
42
|
+
@final
|
|
43
|
+
async def _asyncio_wait_tasks( # noqa: WPS210, WPS231
|
|
44
|
+
self,
|
|
45
|
+
pending: Iterable[asyncio.Task],
|
|
46
|
+
task_map: dict[asyncio.Task, Task]
|
|
47
|
+
) -> TaskExecResultsDTO:
|
|
48
|
+
tasks_to_retry: list[Task] = []
|
|
49
|
+
tasks_done: list[TaskResultDTO] = []
|
|
50
|
+
while pending:
|
|
51
|
+
done, pending = await asyncio.wait(
|
|
52
|
+
pending, return_when=asyncio.FIRST_COMPLETED
|
|
53
|
+
)
|
|
54
|
+
for completed_asyncio_task in done:
|
|
55
|
+
task = task_map[completed_asyncio_task]
|
|
56
|
+
task_result = self._get_task_result(asyncio_task=completed_asyncio_task)
|
|
57
|
+
if isinstance(task_result, BaseException):
|
|
58
|
+
log_error(
|
|
59
|
+
f"Task with id {task.task_id} has been executed with error: "
|
|
60
|
+
f"{task_result}",
|
|
61
|
+
exc=task_result
|
|
62
|
+
)
|
|
63
|
+
tasks_to_retry.append(task)
|
|
64
|
+
continue
|
|
65
|
+
log_info(
|
|
66
|
+
f"Task with id '{task.task_id}' completed with result {task_result=}"
|
|
67
|
+
)
|
|
68
|
+
tasks_done.append(TaskResultDTO(task=task, task_result=task_result))
|
|
69
|
+
if self._result_backend:
|
|
70
|
+
await self._result_backend.save_results(task_results=tasks_done)
|
|
71
|
+
return TaskExecResultsDTO(
|
|
72
|
+
tasks_with_result=tasks_done,
|
|
73
|
+
tasks_to_retry=tasks_to_retry
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
@final
|
|
77
|
+
def _get_task_result(self, asyncio_task: asyncio.Task) -> Any: # TODO: typing
|
|
78
|
+
task_exc = asyncio_task.exception()
|
|
79
|
+
if task_exc:
|
|
80
|
+
return task_exc
|
|
81
|
+
task_result = asyncio_task.result()
|
|
82
|
+
return task_result
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@final
|
|
86
|
+
class SyncTaskExecutor:
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
connection: SyncRedis,
|
|
90
|
+
result_backend_saver: ISyncResultBackendSaver | None = None
|
|
91
|
+
) -> None:
|
|
92
|
+
self._connection = connection
|
|
93
|
+
self._result_backend_saver = result_backend_saver
|
|
94
|
+
|
|
95
|
+
@final
|
|
96
|
+
def execute_tasks(self, tasks: Sequence[Task]) -> TaskExecResultsDTO:
|
|
97
|
+
tasks_to_retry: list[Task] = []
|
|
98
|
+
tasks_done: list[TaskResultDTO] = []
|
|
99
|
+
for task in tasks:
|
|
100
|
+
if task._meta.retries_left:
|
|
101
|
+
task._meta.retries_left -= 1
|
|
102
|
+
try:
|
|
103
|
+
task_result = task.execute()
|
|
104
|
+
except Exception as exc:
|
|
105
|
+
task_result = exc
|
|
106
|
+
|
|
107
|
+
if isinstance(task_result, BaseException):
|
|
108
|
+
log_error(
|
|
109
|
+
f"Task with id '{task.task_id}' has been executed with error: "
|
|
110
|
+
f"{task_result}",
|
|
111
|
+
exc=task_result
|
|
112
|
+
)
|
|
113
|
+
tasks_to_retry.append(task)
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
log_info(
|
|
117
|
+
f"Task with id '{task.task_id}' completed with result {task_result=}"
|
|
118
|
+
)
|
|
119
|
+
tasks_done.append(TaskResultDTO(task=task, task_result=task_result))
|
|
120
|
+
|
|
121
|
+
if self._result_backend_saver:
|
|
122
|
+
self._result_backend_saver.save_results(task_results=tasks_done)
|
|
123
|
+
|
|
124
|
+
return TaskExecResultsDTO(
|
|
125
|
+
tasks_with_result=tasks_done,
|
|
126
|
+
tasks_to_retry=tasks_to_retry
|
|
127
|
+
)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
|
|
3
|
+
from typing import Callable
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def find_task_func(func_path: str) -> Callable:
|
|
7
|
+
try:
|
|
8
|
+
return _find_func(func_path)
|
|
9
|
+
except (ImportError, AttributeError, ValueError) as exc:
|
|
10
|
+
raise ImportError(f"Cannot import function '{func_path}': {exc}")
|
|
11
|
+
# TODO: check that its Melony Task
|
|
12
|
+
|
|
13
|
+
def _find_func(func_path: str) -> Callable:
|
|
14
|
+
module_name, func_name = func_path.rsplit(".", 1)
|
|
15
|
+
module = importlib.import_module(module_name)
|
|
16
|
+
func = getattr(module, func_name)
|
|
17
|
+
|
|
18
|
+
if not callable(func):
|
|
19
|
+
raise ValueError(f"'{func_name}' is not callable")
|
|
20
|
+
|
|
21
|
+
return func
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from collections.abc import Awaitable, Callable
|
|
2
|
+
from inspect import signature
|
|
3
|
+
from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar, final
|
|
4
|
+
from uuid import uuid4
|
|
5
|
+
|
|
6
|
+
from melony.core.tasks import AsyncTask, SyncTask, Task
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from melony.core.brokers import BaseBroker
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
type TaskWrapper = AsyncTaskWrapper | SyncTaskWrapper
|
|
13
|
+
|
|
14
|
+
_TaskParams = ParamSpec("_TaskParams")
|
|
15
|
+
_TaskResult = TypeVar("_TaskResult")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _BaseTaskWrapper(Awaitable):
|
|
19
|
+
@final
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
func: Callable[_TaskParams, _TaskResult],
|
|
23
|
+
broker: "BaseBroker",
|
|
24
|
+
bound_args: dict[str, Any],
|
|
25
|
+
retries: int,
|
|
26
|
+
retry_timeout: int,
|
|
27
|
+
) -> None:
|
|
28
|
+
self._func = func
|
|
29
|
+
self._broker = broker
|
|
30
|
+
self._sig = signature(func)
|
|
31
|
+
self._bound_args = bound_args or {}
|
|
32
|
+
self._func_path = f"{func.__module__}.{func.__qualname__}"
|
|
33
|
+
self._retries = retries
|
|
34
|
+
self._retry_timeout = retry_timeout
|
|
35
|
+
|
|
36
|
+
@final
|
|
37
|
+
def __call__(
|
|
38
|
+
self,
|
|
39
|
+
*args: _TaskParams.args, # type: ignore
|
|
40
|
+
**kwargs: _TaskParams.kwargs
|
|
41
|
+
) -> "_BaseTaskWrapper":
|
|
42
|
+
bound = self._sig.bind(*args, **kwargs)
|
|
43
|
+
bound.apply_defaults()
|
|
44
|
+
self._bound_args = bound.arguments
|
|
45
|
+
return self
|
|
46
|
+
|
|
47
|
+
@final
|
|
48
|
+
def __await__(self) -> Any:
|
|
49
|
+
raise RuntimeError(
|
|
50
|
+
"Task cannot be awaited, did you forget to call .delay()?"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
@final
|
|
54
|
+
class AsyncTaskWrapper(_BaseTaskWrapper):
|
|
55
|
+
async def delay(self, countdown: int = 0) -> Task:
|
|
56
|
+
from melony.core.publishers import IAsyncPublisher
|
|
57
|
+
assert isinstance(self._broker.publisher, IAsyncPublisher)
|
|
58
|
+
task = AsyncTask(
|
|
59
|
+
task_id=str(uuid4()),
|
|
60
|
+
func=self._func,
|
|
61
|
+
func_path=self._func_path,
|
|
62
|
+
kwargs=self._bound_args,
|
|
63
|
+
broker=self._broker,
|
|
64
|
+
countdown=countdown,
|
|
65
|
+
retries=self._retries,
|
|
66
|
+
retry_timeout=self._retry_timeout
|
|
67
|
+
)
|
|
68
|
+
await self._broker.publisher.push(task)
|
|
69
|
+
return task
|
|
70
|
+
|
|
71
|
+
@final
|
|
72
|
+
class SyncTaskWrapper(_BaseTaskWrapper):
|
|
73
|
+
def delay(self, countdown: int = 0) -> Task:
|
|
74
|
+
from melony.core.publishers import ISyncPublisher
|
|
75
|
+
assert isinstance(self._broker.publisher, ISyncPublisher)
|
|
76
|
+
task = SyncTask(
|
|
77
|
+
task_id=str(uuid4()),
|
|
78
|
+
func=self._func,
|
|
79
|
+
func_path=self._func_path,
|
|
80
|
+
kwargs=self._bound_args,
|
|
81
|
+
broker=self._broker,
|
|
82
|
+
countdown=countdown,
|
|
83
|
+
retries=self._retries,
|
|
84
|
+
retry_timeout=self._retry_timeout
|
|
85
|
+
)
|
|
86
|
+
self._broker.publisher.push(task)
|
|
87
|
+
return task
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from dataclasses import asdict, dataclass, field
|
|
5
|
+
from inspect import unwrap
|
|
6
|
+
from typing import (
|
|
7
|
+
Callable,
|
|
8
|
+
Any,
|
|
9
|
+
TYPE_CHECKING,
|
|
10
|
+
final,
|
|
11
|
+
Final
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from melony.core.brokers import BaseBroker
|
|
17
|
+
|
|
18
|
+
type Task = AsyncTask | SyncTask
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_MAX_COUNTDOWN_SEC: Final[int] = 900
|
|
22
|
+
_MAX_COUNTDOWN_MIN: Final[float] = _MAX_COUNTDOWN_SEC / 60
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@final
|
|
26
|
+
@dataclass(kw_only=True)
|
|
27
|
+
class _TaskMeta:
|
|
28
|
+
retries_left: int | None = None
|
|
29
|
+
timestamp: float = field(default_factory=lambda: datetime.timestamp(datetime.now()))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, kw_only=True)
|
|
33
|
+
class _BaseTask:
|
|
34
|
+
task_id: str
|
|
35
|
+
kwargs: dict[str, Any]
|
|
36
|
+
countdown: int
|
|
37
|
+
retries: int
|
|
38
|
+
retry_timeout: int
|
|
39
|
+
|
|
40
|
+
_meta: _TaskMeta = field(default_factory=_TaskMeta)
|
|
41
|
+
|
|
42
|
+
@final
|
|
43
|
+
def __post_init__(self) -> None:
|
|
44
|
+
self._validate_countdown()
|
|
45
|
+
self._set_retries_left_to_meta()
|
|
46
|
+
|
|
47
|
+
@final
|
|
48
|
+
@property
|
|
49
|
+
def timestamp(self) -> float:
|
|
50
|
+
return self._meta.timestamp
|
|
51
|
+
|
|
52
|
+
@final
|
|
53
|
+
def get_execution_timestamp(self) -> float:
|
|
54
|
+
return self.timestamp + self.countdown
|
|
55
|
+
|
|
56
|
+
@final
|
|
57
|
+
def _validate_countdown(self) -> None:
|
|
58
|
+
if self.countdown < 0:
|
|
59
|
+
raise ValueError("Countdown cannot be negative")
|
|
60
|
+
elif self.countdown >= _MAX_COUNTDOWN_SEC:
|
|
61
|
+
raise ValueError(
|
|
62
|
+
f"Countdown cannot be greater than {_MAX_COUNTDOWN_SEC} "
|
|
63
|
+
f"({_MAX_COUNTDOWN_MIN} minutes)"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
@final
|
|
67
|
+
def _set_retries_left_to_meta(self) -> None:
|
|
68
|
+
if not self._meta.retries_left:
|
|
69
|
+
self._meta.retries_left = self.retries
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@final
|
|
73
|
+
@dataclass(frozen=True, kw_only=True)
|
|
74
|
+
class _TaskJSONSerializable(_BaseTask):
|
|
75
|
+
func_name: str
|
|
76
|
+
func_path: str
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, kw_only=True)
|
|
79
|
+
class _Task(_BaseTask):
|
|
80
|
+
func: Callable
|
|
81
|
+
func_path: str
|
|
82
|
+
broker: "BaseBroker"
|
|
83
|
+
|
|
84
|
+
@final
|
|
85
|
+
def as_json_serializable_obj(self) -> _TaskJSONSerializable:
|
|
86
|
+
return _TaskJSONSerializable(
|
|
87
|
+
task_id=self.task_id,
|
|
88
|
+
kwargs=self.kwargs,
|
|
89
|
+
countdown=self.countdown,
|
|
90
|
+
func_name=self.func.__name__,
|
|
91
|
+
func_path=self.func_path,
|
|
92
|
+
retries=self.retries,
|
|
93
|
+
retry_timeout=self.retry_timeout,
|
|
94
|
+
_meta=self._meta
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
@final
|
|
98
|
+
def as_dict(self) -> dict[str, Any]:
|
|
99
|
+
return asdict(self.as_json_serializable_obj())
|
|
100
|
+
|
|
101
|
+
@final
|
|
102
|
+
def as_json(self) -> str:
|
|
103
|
+
json_str = json.dumps(self.as_dict())
|
|
104
|
+
return json_str
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@final
|
|
108
|
+
@dataclass(frozen=True, kw_only=True)
|
|
109
|
+
class AsyncTask(_Task):
|
|
110
|
+
async def execute(self) -> Any:
|
|
111
|
+
unwrapped_func = unwrap(self.func)
|
|
112
|
+
task_result = await unwrapped_func(**self.kwargs)
|
|
113
|
+
return task_result
|
|
114
|
+
|
|
115
|
+
@final
|
|
116
|
+
@dataclass(frozen=True, kw_only=True)
|
|
117
|
+
class SyncTask(_Task):
|
|
118
|
+
def execute(self) -> Any:
|
|
119
|
+
unwrapped_func = unwrap(self.func)
|
|
120
|
+
task_result = unwrapped_func(**self.kwargs)
|
|
121
|
+
return task_result
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import traceback
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from logging import getLogger
|
|
5
|
+
from typing import Final
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
_logger = getLogger(__name__)
|
|
9
|
+
_MELONY_LOG_PREFIX: Final[str] = "[Melony]"
|
|
10
|
+
_DEBUG = True # Toggle this for debugging via print python function TODO: to env
|
|
11
|
+
_WRAP_LINE_WIDTH: Final[int] = 60
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def log_info(message: str, consumer_id: int | None = None) -> None:
|
|
15
|
+
if _DEBUG:
|
|
16
|
+
if consumer_id is None:
|
|
17
|
+
print(f"{_MELONY_LOG_PREFIX}[INFO][{datetime.now()}]: {message}") # noqa: WPS421, WPS226
|
|
18
|
+
else:
|
|
19
|
+
print( # noqa: WPS421
|
|
20
|
+
f"{_MELONY_LOG_PREFIX}[INFO][consumer-{consumer_id}][{datetime.now()}]: " # noqa: WPS226
|
|
21
|
+
f"{message}"
|
|
22
|
+
)
|
|
23
|
+
else:
|
|
24
|
+
if consumer_id is None:
|
|
25
|
+
_logger.info(f"{_MELONY_LOG_PREFIX}[INFO]: {message}")
|
|
26
|
+
else:
|
|
27
|
+
_logger.info(
|
|
28
|
+
f"{_MELONY_LOG_PREFIX}[INFO][consumer-{consumer_id}]: {message}"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def log_error(message: str, exc: BaseException | None = None) -> None:
|
|
34
|
+
if _DEBUG:
|
|
35
|
+
print(f"{_MELONY_LOG_PREFIX}[ERROR][{datetime.now()}]: {message}") # noqa: WPS421
|
|
36
|
+
if exc:
|
|
37
|
+
formatted_traceback = traceback.format_exception(exc)
|
|
38
|
+
for line in formatted_traceback:
|
|
39
|
+
print(line) # noqa: WPS421
|
|
40
|
+
print("-" * _WRAP_LINE_WIDTH) # noqa: WPS421
|
|
41
|
+
else:
|
|
42
|
+
_logger.error(message, exc_info=True)
|
|
File without changes
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from typing import assert_never, final, override
|
|
2
|
+
from redis.asyncio.client import Redis
|
|
3
|
+
from redis import Redis as SyncRedis
|
|
4
|
+
|
|
5
|
+
from melony.core.publishers import IAsyncPublisher, ISyncPublisher, Publisher
|
|
6
|
+
from melony.core.brokers import BaseBroker
|
|
7
|
+
from melony.core.result_backends import (
|
|
8
|
+
IAsyncResultBackendSaver,
|
|
9
|
+
IResultBackend,
|
|
10
|
+
ISyncResultBackendSaver,
|
|
11
|
+
)
|
|
12
|
+
from melony.redis_broker.consumers import (
|
|
13
|
+
AsyncRedisConsumer,
|
|
14
|
+
SyncRedisConsumer,
|
|
15
|
+
RedisConsumer
|
|
16
|
+
)
|
|
17
|
+
from melony.redis_broker.publishers import AsyncRedisPublisher, SyncRedisPublisher
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@final
|
|
21
|
+
class RedisBroker(BaseBroker):
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
redis_connection: Redis | SyncRedis,
|
|
25
|
+
result_backend: IResultBackend | None = None
|
|
26
|
+
) -> None:
|
|
27
|
+
self._connection = redis_connection
|
|
28
|
+
self._result_backend = result_backend
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
@override
|
|
32
|
+
def publisher(self) -> Publisher:
|
|
33
|
+
match self._connection:
|
|
34
|
+
case Redis():
|
|
35
|
+
return AsyncRedisPublisher(connection=self._connection)
|
|
36
|
+
case SyncRedis():
|
|
37
|
+
return SyncRedisPublisher(connection=self._connection)
|
|
38
|
+
case _:
|
|
39
|
+
assert_never(_)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
@override
|
|
43
|
+
def consumer(self) -> RedisConsumer:
|
|
44
|
+
match self._connection:
|
|
45
|
+
case Redis():
|
|
46
|
+
assert isinstance(self.publisher, IAsyncPublisher)
|
|
47
|
+
if self._result_backend:
|
|
48
|
+
assert isinstance(
|
|
49
|
+
self._result_backend.saver,
|
|
50
|
+
IAsyncResultBackendSaver
|
|
51
|
+
)
|
|
52
|
+
return AsyncRedisConsumer(
|
|
53
|
+
publisher=self.publisher,
|
|
54
|
+
broker=self,
|
|
55
|
+
result_backend=self._result_backend
|
|
56
|
+
)
|
|
57
|
+
case SyncRedis():
|
|
58
|
+
assert isinstance(self.publisher, ISyncPublisher)
|
|
59
|
+
if self._result_backend:
|
|
60
|
+
assert isinstance(
|
|
61
|
+
self._result_backend.saver,
|
|
62
|
+
ISyncResultBackendSaver
|
|
63
|
+
)
|
|
64
|
+
return SyncRedisConsumer(
|
|
65
|
+
publisher=self.publisher,
|
|
66
|
+
broker=self,
|
|
67
|
+
result_backend=self._result_backend
|
|
68
|
+
)
|
|
69
|
+
case _:
|
|
70
|
+
assert_never(self._connection)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from typing import Awaitable, Final, Optional, Sequence, cast, final, override
|
|
2
|
+
|
|
3
|
+
from melony.core.json_task_converter import AsyncJsonTaskConverter, SyncJsonTaskConverter
|
|
4
|
+
from melony.core.brokers import BaseBroker
|
|
5
|
+
from melony.core.consts import REDIS_QUEUE_NAME
|
|
6
|
+
from melony.core.consumers import BaseAsyncConsumer, BaseSyncConsumer
|
|
7
|
+
from melony.core.publishers import IAsyncPublisher, ISyncPublisher
|
|
8
|
+
from melony.core.result_backends import IAsyncResultBackendSaver, IResultBackend, ISyncResultBackendSaver
|
|
9
|
+
from melony.core.tasks import Task
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
type RedisConsumer = AsyncRedisConsumer | SyncRedisConsumer
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@final
|
|
16
|
+
class AsyncRedisConsumer(BaseAsyncConsumer):
|
|
17
|
+
_brpop_timeout: Final[float] = 0.01
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
publisher: IAsyncPublisher,
|
|
22
|
+
broker: BaseBroker,
|
|
23
|
+
result_backend: IResultBackend | None = None
|
|
24
|
+
) -> None:
|
|
25
|
+
super().__init__(publisher, result_backend)
|
|
26
|
+
self._broker = broker
|
|
27
|
+
self._task_converter = AsyncJsonTaskConverter()
|
|
28
|
+
|
|
29
|
+
@override
|
|
30
|
+
async def _pop_tasks(self) -> Sequence[Task]:
|
|
31
|
+
tasks: list[Task] = []
|
|
32
|
+
redis_task = await cast(
|
|
33
|
+
Awaitable[Sequence[bytes]],
|
|
34
|
+
self._connection.brpop(keys=[REDIS_QUEUE_NAME])
|
|
35
|
+
)
|
|
36
|
+
tasks.append(self._deserialize_to_task_from_redis(redis_task))
|
|
37
|
+
|
|
38
|
+
while redis_task:
|
|
39
|
+
redis_task = await cast(
|
|
40
|
+
Awaitable[Optional[Sequence[bytes]]],
|
|
41
|
+
self._connection.brpop(
|
|
42
|
+
keys=[REDIS_QUEUE_NAME],
|
|
43
|
+
timeout=self._brpop_timeout
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
if redis_task:
|
|
47
|
+
tasks.append(self._deserialize_to_task_from_redis(redis_task))
|
|
48
|
+
|
|
49
|
+
return tasks
|
|
50
|
+
|
|
51
|
+
def _deserialize_to_task_from_redis(self, redis_task: Sequence[bytes]) -> Task:
|
|
52
|
+
serialized_task_bytes = redis_task[1]
|
|
53
|
+
serialized_task = serialized_task_bytes.decode("utf-8")
|
|
54
|
+
task = self._task_converter.deserialize_task(
|
|
55
|
+
serialized_task=serialized_task,
|
|
56
|
+
broker=self._broker
|
|
57
|
+
)
|
|
58
|
+
return task
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@final
|
|
62
|
+
class SyncRedisConsumer(BaseSyncConsumer):
|
|
63
|
+
_brpop_timeout: Final[float] = 0.01
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
publisher: ISyncPublisher,
|
|
68
|
+
broker: BaseBroker,
|
|
69
|
+
result_backend: IResultBackend | None = None
|
|
70
|
+
) -> None:
|
|
71
|
+
super().__init__(publisher, result_backend)
|
|
72
|
+
self._broker = broker
|
|
73
|
+
self._task_converter = SyncJsonTaskConverter()
|
|
74
|
+
|
|
75
|
+
@override
|
|
76
|
+
def _pop_tasks(self) -> Sequence[Task]:
|
|
77
|
+
tasks: list[Task] = []
|
|
78
|
+
redis_task = cast(
|
|
79
|
+
Sequence[bytes],
|
|
80
|
+
self._connection.brpop(keys=[REDIS_QUEUE_NAME])
|
|
81
|
+
)
|
|
82
|
+
tasks.append(self._deserialize_to_task_from_redis(redis_task))
|
|
83
|
+
|
|
84
|
+
while redis_task:
|
|
85
|
+
redis_task = cast(
|
|
86
|
+
Optional[Sequence[bytes]],
|
|
87
|
+
self._connection.brpop(
|
|
88
|
+
keys=[REDIS_QUEUE_NAME],
|
|
89
|
+
timeout=self._brpop_timeout
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
if redis_task:
|
|
93
|
+
tasks.append(self._deserialize_to_task_from_redis(redis_task))
|
|
94
|
+
|
|
95
|
+
return tasks
|
|
96
|
+
|
|
97
|
+
def _deserialize_to_task_from_redis(self, redis_task: Sequence[bytes]) -> Task:
|
|
98
|
+
serialized_task_bytes = redis_task[1]
|
|
99
|
+
serialized_task = serialized_task_bytes.decode("utf-8")
|
|
100
|
+
task = self._task_converter.deserialize_task(
|
|
101
|
+
serialized_task=serialized_task,
|
|
102
|
+
broker=self._broker
|
|
103
|
+
)
|
|
104
|
+
return task
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from typing import Awaitable, cast, final, override
|
|
2
|
+
from redis.asyncio import Redis
|
|
3
|
+
from redis import Redis as SyncRedis
|
|
4
|
+
|
|
5
|
+
from melony.core.consts import REDIS_QUEUE_NAME
|
|
6
|
+
from melony.core.publishers import IAsyncPublisher, ISyncPublisher
|
|
7
|
+
from melony.core.tasks import Task
|
|
8
|
+
from melony.core.json_task_converter import AsyncJsonTaskConverter, SyncJsonTaskConverter
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@final
|
|
12
|
+
class AsyncRedisPublisher(IAsyncPublisher):
|
|
13
|
+
def __init__(self, connection: Redis) -> None:
|
|
14
|
+
self._connection = connection
|
|
15
|
+
self._task_converter = AsyncJsonTaskConverter()
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
@override
|
|
19
|
+
def connection(self) -> Redis:
|
|
20
|
+
return self._connection
|
|
21
|
+
|
|
22
|
+
@override
|
|
23
|
+
async def push(self, task: Task) -> None:
|
|
24
|
+
await cast(
|
|
25
|
+
Awaitable[int], self._connection.lpush(
|
|
26
|
+
REDIS_QUEUE_NAME,
|
|
27
|
+
self._task_converter.serialize_task(task)
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@final
|
|
33
|
+
class SyncRedisPublisher(ISyncPublisher):
|
|
34
|
+
def __init__(self, connection: SyncRedis) -> None:
|
|
35
|
+
self._connection = connection
|
|
36
|
+
self._task_converter = SyncJsonTaskConverter()
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
@override
|
|
40
|
+
def connection(self) -> SyncRedis:
|
|
41
|
+
return self._connection
|
|
42
|
+
|
|
43
|
+
@override
|
|
44
|
+
def push(self, task: Task) -> None:
|
|
45
|
+
cast(
|
|
46
|
+
int, self._connection.lpush(
|
|
47
|
+
REDIS_QUEUE_NAME,
|
|
48
|
+
self._task_converter.serialize_task(task)
|
|
49
|
+
)
|
|
50
|
+
)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
from typing import Sequence, final, override
|
|
5
|
+
from redis.asyncio import Redis
|
|
6
|
+
from redis import Redis as SyncRedis
|
|
7
|
+
|
|
8
|
+
from melony.core.consts import REDIS_RESULT_BACKEND_KEY
|
|
9
|
+
from melony.core.dto import TaskResultDTO
|
|
10
|
+
from melony.core.result_backends import (
|
|
11
|
+
IAsyncResultBackendSaver,
|
|
12
|
+
IResultBackend,
|
|
13
|
+
ISyncResultBackendSaver,
|
|
14
|
+
ResultBackendSaver
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@final
|
|
19
|
+
class _AsyncRedisResultBackendSaver(IAsyncResultBackendSaver):
|
|
20
|
+
def __init__(self, redis_connection: Redis | SyncRedis) -> None:
|
|
21
|
+
self._connection = redis_connection
|
|
22
|
+
|
|
23
|
+
@override
|
|
24
|
+
async def save_results(self, task_results: Sequence[TaskResultDTO]) -> None:
|
|
25
|
+
set_result_coroutines = []
|
|
26
|
+
|
|
27
|
+
for task_result_info in task_results:
|
|
28
|
+
task_id = task_result_info.task.task_id
|
|
29
|
+
redis_key = f"{REDIS_RESULT_BACKEND_KEY}{task_id}"
|
|
30
|
+
save_task_data = {}
|
|
31
|
+
save_task_data["result"] = task_result_info.task_result
|
|
32
|
+
save_task_data["task"] = task_result_info.task.as_dict()
|
|
33
|
+
set_result_coroutines.append(
|
|
34
|
+
self._connection.set(
|
|
35
|
+
name=redis_key,
|
|
36
|
+
value=json.dumps(save_task_data)
|
|
37
|
+
)
|
|
38
|
+
)
|
|
39
|
+
await asyncio.gather(*set_result_coroutines)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@final
|
|
43
|
+
class _SyncRedisResultBackendSaver(ISyncResultBackendSaver):
|
|
44
|
+
def __init__(self, redis_connection: Redis | SyncRedis) -> None:
|
|
45
|
+
self._connection = redis_connection
|
|
46
|
+
|
|
47
|
+
@override
|
|
48
|
+
def save_results(self, task_results: Sequence[TaskResultDTO]) -> None:
|
|
49
|
+
for task_result_info in task_results:
|
|
50
|
+
task_id = task_result_info.task.task_id
|
|
51
|
+
redis_key = f"{REDIS_RESULT_BACKEND_KEY}{task_id}"
|
|
52
|
+
save_task_data = {}
|
|
53
|
+
save_task_data["result"] = task_result_info.task_result
|
|
54
|
+
save_task_data["task"] = task_result_info.task.as_dict()
|
|
55
|
+
self._connection.set(
|
|
56
|
+
name=redis_key,
|
|
57
|
+
value=json.dumps(save_task_data)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
@final
|
|
61
|
+
class RedisResultBackend(IResultBackend):
|
|
62
|
+
def __init__(self, redis_connection: Redis | SyncRedis) -> None:
|
|
63
|
+
self._connection = redis_connection
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def saver(self) -> ResultBackendSaver:
|
|
67
|
+
match self._connection:
|
|
68
|
+
case Redis():
|
|
69
|
+
return _AsyncRedisResultBackendSaver(redis_connection=self._connection)
|
|
70
|
+
case SyncRedis():
|
|
71
|
+
return _SyncRedisResultBackendSaver(redis_connection=self._connection)
|
|
72
|
+
case _:
|
|
73
|
+
assert_never(_)
|
|
74
|
+
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: melony
|
|
3
|
+
Version: 1.0.1
|
|
4
|
+
Summary: Multisync task manager for python
|
|
5
|
+
Home-page: https://melony-python.com/
|
|
6
|
+
Author: wimble3
|
|
7
|
+
Author-email: wimble@internet.ru
|
|
8
|
+
License: UNKNOWN
|
|
9
|
+
Project-URL: Documentation, https://melony-python.com/docs
|
|
10
|
+
Keywords: task manager python melony
|
|
11
|
+
Platform: UNKNOWN
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Requires-Python: >=3.12
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
UNKNOWN
|
|
19
|
+
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
setup.py
|
|
4
|
+
melony/__init__.py
|
|
5
|
+
melony/logger.py
|
|
6
|
+
melony.egg-info/PKG-INFO
|
|
7
|
+
melony.egg-info/SOURCES.txt
|
|
8
|
+
melony.egg-info/dependency_links.txt
|
|
9
|
+
melony.egg-info/requires.txt
|
|
10
|
+
melony.egg-info/top_level.txt
|
|
11
|
+
melony/core/__init__.py
|
|
12
|
+
melony/core/brokers.py
|
|
13
|
+
melony/core/consts.py
|
|
14
|
+
melony/core/consumers.py
|
|
15
|
+
melony/core/dto.py
|
|
16
|
+
melony/core/json_task_converter.py
|
|
17
|
+
melony/core/publishers.py
|
|
18
|
+
melony/core/result_backends.py
|
|
19
|
+
melony/core/task_executor.py
|
|
20
|
+
melony/core/task_finders.py
|
|
21
|
+
melony/core/task_wrappers.py
|
|
22
|
+
melony/core/tasks.py
|
|
23
|
+
melony/redis_broker/__init__.py
|
|
24
|
+
melony/redis_broker/brokers.py
|
|
25
|
+
melony/redis_broker/consumers.py
|
|
26
|
+
melony/redis_broker/publishers.py
|
|
27
|
+
melony/redis_broker/result_backends.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
melony
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "melony"
|
|
3
|
+
version = "1.0.1"
|
|
4
|
+
description = "multisync task manager for python"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"asyncio>=4.0.0",
|
|
9
|
+
"classes>=0.4.1",
|
|
10
|
+
"pytest>=8.4.1",
|
|
11
|
+
"pytest-asyncio>=1.1.0",
|
|
12
|
+
"redis>=6.4.0",
|
|
13
|
+
"wemake-python-styleguide>=1.3.0",
|
|
14
|
+
]
|
melony-1.0.1/setup.cfg
ADDED
melony-1.0.1/setup.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from setuptools import setup, find_packages
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def readme():
|
|
5
|
+
with open("README.md", "r") as file:
|
|
6
|
+
return file.read()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
setup(
|
|
10
|
+
name="melony",
|
|
11
|
+
version="1.0.1",
|
|
12
|
+
author="wimble3",
|
|
13
|
+
author_email="wimble@internet.ru",
|
|
14
|
+
description='Multisync task manager for python',
|
|
15
|
+
long_description=readme(),
|
|
16
|
+
long_description_content_type="text/markdown",
|
|
17
|
+
url="https://melony-python.com/",
|
|
18
|
+
packages=find_packages(),
|
|
19
|
+
install_requires=[
|
|
20
|
+
"asyncio>=4.0.0",
|
|
21
|
+
"classes>=0.4.1",
|
|
22
|
+
"pytest>=8.4.1",
|
|
23
|
+
"pytest-asyncio>=1.1.0",
|
|
24
|
+
"redis>=6.4.0",
|
|
25
|
+
"setuptools>=80.10.2",
|
|
26
|
+
"wemake-python-styleguide>=1.3.0",
|
|
27
|
+
],
|
|
28
|
+
classifiers=[
|
|
29
|
+
"Programming Language :: Python :: 3.12",
|
|
30
|
+
"License :: OSI Approved :: MIT License",
|
|
31
|
+
"Operating System :: OS Independent"
|
|
32
|
+
],
|
|
33
|
+
keywords='task manager python melony',
|
|
34
|
+
project_urls={
|
|
35
|
+
"Documentation": "https://melony-python.com/docs"
|
|
36
|
+
},
|
|
37
|
+
python_requires='>=3.12'
|
|
38
|
+
)
|