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 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,5 @@
1
+ from typing import Final
2
+
3
+
4
+ REDIS_QUEUE_NAME: Final[str] = "melony_tasks"
5
+ REDIS_RESULT_BACKEND_KEY: Final[str] = "melony_result_backend:"
@@ -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,7 @@
1
+ asyncio>=4.0.0
2
+ classes>=0.4.1
3
+ pytest-asyncio>=1.1.0
4
+ pytest>=8.4.1
5
+ redis>=6.4.0
6
+ setuptools>=80.10.2
7
+ wemake-python-styleguide>=1.3.0
@@ -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
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
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
+ )