sheppy 0.0.1__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.
- sheppy/__init__.py +24 -0
- sheppy/backend/__init__.py +10 -0
- sheppy/backend/base.py +91 -0
- sheppy/backend/memory.py +290 -0
- sheppy/backend/redis.py +451 -0
- sheppy/cli/__main__.py +3 -0
- sheppy/cli/cli.py +55 -0
- sheppy/cli/commands/cron/list.py +53 -0
- sheppy/cli/commands/queue/list.py +41 -0
- sheppy/cli/commands/task/add.py +103 -0
- sheppy/cli/commands/task/info.py +78 -0
- sheppy/cli/commands/task/list.py +106 -0
- sheppy/cli/commands/task/retry.py +65 -0
- sheppy/cli/commands/task/schedule.py +122 -0
- sheppy/cli/commands/task/test.py +100 -0
- sheppy/cli/commands/work.py +102 -0
- sheppy/cli/utils.py +75 -0
- sheppy/models.py +150 -0
- sheppy/py.typed +0 -0
- sheppy/queue.py +200 -0
- sheppy/task_factory.py +145 -0
- sheppy/testqueue.py +167 -0
- sheppy/utils/fastapi.py +18 -0
- sheppy/utils/task_execution.py +285 -0
- sheppy/utils/validation.py +133 -0
- sheppy/worker.py +326 -0
- sheppy-0.0.1.dist-info/METADATA +205 -0
- sheppy-0.0.1.dist-info/RECORD +31 -0
- sheppy-0.0.1.dist-info/WHEEL +4 -0
- sheppy-0.0.1.dist-info/entry_points.txt +2 -0
- sheppy-0.0.1.dist-info/licenses/LICENSE +21 -0
sheppy/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from .backend import Backend, BackendError, MemoryBackend, RedisBackend
|
|
2
|
+
from .models import Task
|
|
3
|
+
from .queue import Queue
|
|
4
|
+
from .task_factory import task
|
|
5
|
+
from .testqueue import TestQueue
|
|
6
|
+
from .utils.fastapi import Depends
|
|
7
|
+
from .worker import Worker
|
|
8
|
+
|
|
9
|
+
__version__ = "0.0.1"
|
|
10
|
+
|
|
11
|
+
__all__ = [ # noqa
|
|
12
|
+
# fastapi
|
|
13
|
+
"Depends",
|
|
14
|
+
# task
|
|
15
|
+
"task", "Task",
|
|
16
|
+
# queue
|
|
17
|
+
"Queue",
|
|
18
|
+
# testqueue
|
|
19
|
+
"TestQueue",
|
|
20
|
+
# worker
|
|
21
|
+
"Worker",
|
|
22
|
+
# backend/
|
|
23
|
+
"Backend", "MemoryBackend", "RedisBackend", "BackendError",
|
|
24
|
+
]
|
sheppy/backend/base.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BackendError(Exception):
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Backend(ABC):
|
|
11
|
+
|
|
12
|
+
@abstractmethod
|
|
13
|
+
async def connect(self) -> None:
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
async def disconnect(self) -> None:
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def is_connected(self) -> bool:
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
async def append(self, queue_name: str, tasks: list[dict[str, Any]], unique: bool = True) -> list[bool]:
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
async def pop(self, queue_name: str, limit: int = 1, timeout: float | None = None) -> list[dict[str, Any]]:
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
async def get_tasks(self, queue_name: str, task_ids: list[str]) -> dict[str, dict[str, Any]]:
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
async def get_all_tasks(self, queue_name: str) -> list[dict[str, Any]]:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
async def get_pending(self, queue_name: str, count: int = 1) -> list[dict[str, Any]]:
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
async def schedule(self, queue_name: str, task_data: dict[str, Any], at: datetime, unique: bool = True) -> bool:
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
async def get_scheduled(self, queue_name: str) -> list[dict[str, Any]]:
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
async def pop_scheduled(self, queue_name: str, now: datetime | None = None) -> list[dict[str, Any]]:
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
@abstractmethod
|
|
58
|
+
async def store_result(self, queue_name: str, task_data: dict[str, Any]) -> bool:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
@abstractmethod
|
|
62
|
+
async def get_results(self, queue_name: str, task_ids: list[str], timeout: float | None = None) -> dict[str, dict[str, Any]]:
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
@abstractmethod
|
|
66
|
+
async def size(self, queue_name: str) -> int:
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
@abstractmethod
|
|
70
|
+
async def clear(self, queue_name: str) -> int:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
@abstractmethod
|
|
74
|
+
async def get_stats(self, queue_name: str) -> dict[str, int]:
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
@abstractmethod
|
|
78
|
+
async def list_queues(self) -> dict[str, int]:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
@abstractmethod
|
|
82
|
+
async def add_cron(self, queue_name: str, deterministic_id: str, task_cron: dict[str, Any]) -> bool:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
@abstractmethod
|
|
86
|
+
async def delete_cron(self, queue_name: str, deterministic_id: str) -> bool:
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
@abstractmethod
|
|
90
|
+
async def get_crons(self, queue_name: str) -> list[dict[str, Any]]:
|
|
91
|
+
pass
|
sheppy/backend/memory.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import heapq
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .base import Backend, BackendError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(order=True)
|
|
12
|
+
class ScheduledTask:
|
|
13
|
+
scheduled_time: datetime
|
|
14
|
+
task_id: str = field(compare=False)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MemoryBackend(Backend):
|
|
18
|
+
|
|
19
|
+
def __init__(self) -> None:
|
|
20
|
+
self._task_metadata: dict[str, dict[str, dict[str, Any]]] = defaultdict(dict) # {QUEUE_NAME: {TASK_ID: task_data}}
|
|
21
|
+
self._pending: dict[str, list[str]] = defaultdict(list)
|
|
22
|
+
self._scheduled: dict[str, list[ScheduledTask]] = defaultdict(list)
|
|
23
|
+
self._crons: dict[str, dict[str, dict[str, Any]]] = defaultdict(dict)
|
|
24
|
+
|
|
25
|
+
self._locks: dict[str, asyncio.Lock] = defaultdict(asyncio.Lock) # for thread-safety
|
|
26
|
+
self._connected = False
|
|
27
|
+
|
|
28
|
+
async def connect(self) -> None:
|
|
29
|
+
self._connected = True
|
|
30
|
+
|
|
31
|
+
async def disconnect(self) -> None:
|
|
32
|
+
self._connected = False
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def is_connected(self) -> bool:
|
|
36
|
+
return self._connected
|
|
37
|
+
|
|
38
|
+
async def _create_tasks(self, queue_name: str, tasks: list[dict[str, Any]]) -> list[bool]:
|
|
39
|
+
self._check_connected()
|
|
40
|
+
|
|
41
|
+
async with self._locks[queue_name]:
|
|
42
|
+
success = []
|
|
43
|
+
for task in tasks:
|
|
44
|
+
if task["id"] not in self._task_metadata[queue_name]:
|
|
45
|
+
self._task_metadata[queue_name][task["id"]] = task
|
|
46
|
+
success.append(True)
|
|
47
|
+
else:
|
|
48
|
+
success.append(False)
|
|
49
|
+
|
|
50
|
+
return success
|
|
51
|
+
|
|
52
|
+
async def append(self, queue_name: str, tasks: list[dict[str, Any]], unique: bool = True) -> list[bool]:
|
|
53
|
+
self._check_connected()
|
|
54
|
+
|
|
55
|
+
if unique:
|
|
56
|
+
success = await self._create_tasks(queue_name, tasks)
|
|
57
|
+
to_queue = [t for i, t in enumerate(tasks) if success[i]]
|
|
58
|
+
else:
|
|
59
|
+
success = [True] * len(tasks)
|
|
60
|
+
to_queue = tasks
|
|
61
|
+
|
|
62
|
+
async with self._locks[queue_name]:
|
|
63
|
+
for task in to_queue:
|
|
64
|
+
if not unique:
|
|
65
|
+
self._task_metadata[queue_name][task["id"]] = task
|
|
66
|
+
|
|
67
|
+
self._pending[queue_name].append(task["id"])
|
|
68
|
+
|
|
69
|
+
return success
|
|
70
|
+
|
|
71
|
+
async def pop(self, queue_name: str, limit: int = 1, timeout: float | None = None) -> list[dict[str, Any]]:
|
|
72
|
+
self._check_connected()
|
|
73
|
+
|
|
74
|
+
start_time = asyncio.get_event_loop().time()
|
|
75
|
+
|
|
76
|
+
while True:
|
|
77
|
+
async with self._locks[queue_name]:
|
|
78
|
+
if self._pending[queue_name]:
|
|
79
|
+
tasks = []
|
|
80
|
+
q = self._pending[queue_name]
|
|
81
|
+
|
|
82
|
+
for _ in range(min(limit, len(q))):
|
|
83
|
+
task_id = q.pop(0)
|
|
84
|
+
task_data = self._task_metadata[queue_name].get(task_id)
|
|
85
|
+
if task_data:
|
|
86
|
+
tasks.append(task_data)
|
|
87
|
+
|
|
88
|
+
return tasks
|
|
89
|
+
|
|
90
|
+
if timeout is None or timeout <= 0:
|
|
91
|
+
return []
|
|
92
|
+
|
|
93
|
+
elapsed = asyncio.get_event_loop().time() - start_time
|
|
94
|
+
if elapsed >= timeout:
|
|
95
|
+
return []
|
|
96
|
+
|
|
97
|
+
await asyncio.sleep(min(0.05, timeout - elapsed))
|
|
98
|
+
|
|
99
|
+
async def get_pending(self, queue_name: str, count: int = 1) -> list[dict[str, Any]]:
|
|
100
|
+
self._check_connected()
|
|
101
|
+
|
|
102
|
+
async with self._locks[queue_name]:
|
|
103
|
+
task_ids = list(self._pending[queue_name])[:count]
|
|
104
|
+
|
|
105
|
+
tasks = []
|
|
106
|
+
for t in task_ids:
|
|
107
|
+
if task_data := self._task_metadata[queue_name].get(t):
|
|
108
|
+
tasks.append(task_data)
|
|
109
|
+
|
|
110
|
+
return tasks
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def size(self, queue_name: str) -> int:
|
|
114
|
+
self._check_connected()
|
|
115
|
+
|
|
116
|
+
async with self._locks[queue_name]:
|
|
117
|
+
return len(self._pending[queue_name])
|
|
118
|
+
|
|
119
|
+
async def clear(self, queue_name: str) -> int:
|
|
120
|
+
self._check_connected()
|
|
121
|
+
|
|
122
|
+
async with self._locks[queue_name]:
|
|
123
|
+
queue_size = len(self._task_metadata[queue_name])
|
|
124
|
+
queue_cron_size = len(self._crons[queue_name])
|
|
125
|
+
|
|
126
|
+
self._task_metadata[queue_name].clear()
|
|
127
|
+
self._pending[queue_name].clear()
|
|
128
|
+
self._scheduled[queue_name].clear()
|
|
129
|
+
self._crons[queue_name].clear()
|
|
130
|
+
|
|
131
|
+
return queue_size + queue_cron_size
|
|
132
|
+
|
|
133
|
+
async def get_tasks(self, queue_name: str, task_ids: list[str]) -> dict[str,dict[str, Any]]:
|
|
134
|
+
self._check_connected()
|
|
135
|
+
|
|
136
|
+
async with self._locks[queue_name]:
|
|
137
|
+
results = {}
|
|
138
|
+
for task_id in task_ids:
|
|
139
|
+
result = self._task_metadata[queue_name].get(task_id)
|
|
140
|
+
if result:
|
|
141
|
+
results[task_id] = result
|
|
142
|
+
|
|
143
|
+
return results
|
|
144
|
+
|
|
145
|
+
async def schedule(self, queue_name: str, task_data: dict[str, Any], at: datetime, unique: bool = True) -> bool:
|
|
146
|
+
self._check_connected()
|
|
147
|
+
|
|
148
|
+
if unique:
|
|
149
|
+
success = await self._create_tasks(queue_name, [task_data])
|
|
150
|
+
if not success[0]:
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
async with self._locks[queue_name]:
|
|
154
|
+
if not unique:
|
|
155
|
+
self._task_metadata[queue_name][task_data["id"]] = task_data
|
|
156
|
+
|
|
157
|
+
scheduled_task = ScheduledTask(at, task_data["id"])
|
|
158
|
+
heapq.heappush(self._scheduled[queue_name], scheduled_task)
|
|
159
|
+
|
|
160
|
+
return True
|
|
161
|
+
|
|
162
|
+
async def pop_scheduled(self, queue_name: str, now: datetime | None = None) -> list[dict[str, Any]]:
|
|
163
|
+
self._check_connected()
|
|
164
|
+
|
|
165
|
+
if now is None:
|
|
166
|
+
now = datetime.now(timezone.utc)
|
|
167
|
+
|
|
168
|
+
async with self._locks[queue_name]:
|
|
169
|
+
tasks = []
|
|
170
|
+
scheduled_tasks = self._scheduled[queue_name]
|
|
171
|
+
|
|
172
|
+
while scheduled_tasks and scheduled_tasks[0].scheduled_time <= now:
|
|
173
|
+
scheduled_task = heapq.heappop(scheduled_tasks)
|
|
174
|
+
task_data = self._task_metadata[queue_name].get(scheduled_task.task_id)
|
|
175
|
+
if task_data:
|
|
176
|
+
tasks.append(task_data)
|
|
177
|
+
|
|
178
|
+
return tasks
|
|
179
|
+
|
|
180
|
+
async def store_result(self, queue_name: str, task_data: dict[str, Any]) -> bool:
|
|
181
|
+
self._check_connected()
|
|
182
|
+
|
|
183
|
+
async with self._locks[queue_name]:
|
|
184
|
+
self._task_metadata[queue_name][task_data['id']] = task_data
|
|
185
|
+
|
|
186
|
+
return True
|
|
187
|
+
|
|
188
|
+
async def get_results(self, queue_name: str, task_ids: list[str], timeout: float | None = None) -> dict[str,dict[str, Any]]:
|
|
189
|
+
self._check_connected()
|
|
190
|
+
|
|
191
|
+
start_time = asyncio.get_event_loop().time()
|
|
192
|
+
|
|
193
|
+
if not task_ids:
|
|
194
|
+
return {}
|
|
195
|
+
|
|
196
|
+
results = {}
|
|
197
|
+
remaining_ids = task_ids[:]
|
|
198
|
+
|
|
199
|
+
while True:
|
|
200
|
+
async with self._locks[queue_name]:
|
|
201
|
+
for task_id in task_ids:
|
|
202
|
+
task_data = self._task_metadata[queue_name].get(task_id, {})
|
|
203
|
+
|
|
204
|
+
if task_data.get("finished_at"):
|
|
205
|
+
results[task_id] = task_data
|
|
206
|
+
remaining_ids.remove(task_id)
|
|
207
|
+
|
|
208
|
+
if not remaining_ids:
|
|
209
|
+
return results
|
|
210
|
+
|
|
211
|
+
if timeout is None or timeout < 0:
|
|
212
|
+
return results
|
|
213
|
+
|
|
214
|
+
# endless wait if timeout == 0
|
|
215
|
+
if timeout == 0:
|
|
216
|
+
await asyncio.sleep(0.05)
|
|
217
|
+
continue
|
|
218
|
+
|
|
219
|
+
elapsed = asyncio.get_event_loop().time() - start_time
|
|
220
|
+
if elapsed >= timeout:
|
|
221
|
+
raise TimeoutError(f"Did not complete within {timeout} seconds")
|
|
222
|
+
|
|
223
|
+
await asyncio.sleep(min(0.05, timeout - elapsed))
|
|
224
|
+
|
|
225
|
+
async def get_stats(self, queue_name: str) -> dict[str, int]:
|
|
226
|
+
self._check_connected()
|
|
227
|
+
|
|
228
|
+
async with self._locks[queue_name]:
|
|
229
|
+
return {
|
|
230
|
+
"pending": len(self._pending[queue_name]),
|
|
231
|
+
"completed": len([t for t in self._task_metadata[queue_name].values() if t["finished_at"]]),
|
|
232
|
+
"scheduled": len(self._scheduled[queue_name]),
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async def get_all_tasks(self, queue_name: str) -> list[dict[str, Any]]:
|
|
236
|
+
self._check_connected()
|
|
237
|
+
|
|
238
|
+
async with self._locks[queue_name]:
|
|
239
|
+
tasks = self._task_metadata[queue_name]
|
|
240
|
+
return list(tasks.values())
|
|
241
|
+
|
|
242
|
+
async def list_queues(self) -> dict[str, int]:
|
|
243
|
+
self._check_connected()
|
|
244
|
+
|
|
245
|
+
queues = {}
|
|
246
|
+
for queue_name in self._task_metadata:
|
|
247
|
+
async with self._locks[queue_name]:
|
|
248
|
+
queues[queue_name] = len(self._pending[queue_name])
|
|
249
|
+
|
|
250
|
+
return queues
|
|
251
|
+
|
|
252
|
+
async def get_scheduled(self, queue_name: str) -> list[dict[str, Any]]:
|
|
253
|
+
self._check_connected()
|
|
254
|
+
|
|
255
|
+
async with self._locks[queue_name]:
|
|
256
|
+
tasks = []
|
|
257
|
+
for scheduled_task in self._scheduled[queue_name]:
|
|
258
|
+
task_data = self._task_metadata[queue_name].get(scheduled_task.task_id)
|
|
259
|
+
if task_data:
|
|
260
|
+
tasks.append(task_data)
|
|
261
|
+
|
|
262
|
+
return tasks
|
|
263
|
+
|
|
264
|
+
async def add_cron(self, queue_name: str, deterministic_id: str, task_cron: dict[str, Any]) -> bool:
|
|
265
|
+
self._check_connected()
|
|
266
|
+
|
|
267
|
+
async with self._locks[queue_name]:
|
|
268
|
+
if deterministic_id not in self._crons[queue_name]:
|
|
269
|
+
self._crons[queue_name][deterministic_id] = task_cron
|
|
270
|
+
return True
|
|
271
|
+
return False
|
|
272
|
+
|
|
273
|
+
async def delete_cron(self, queue_name: str, deterministic_id: str) -> bool:
|
|
274
|
+
self._check_connected()
|
|
275
|
+
|
|
276
|
+
async with self._locks[queue_name]:
|
|
277
|
+
if deterministic_id in self._crons[queue_name]:
|
|
278
|
+
del self._crons[queue_name][deterministic_id]
|
|
279
|
+
return True
|
|
280
|
+
return False
|
|
281
|
+
|
|
282
|
+
async def get_crons(self, queue_name: str) -> list[dict[str, Any]]:
|
|
283
|
+
self._check_connected()
|
|
284
|
+
|
|
285
|
+
async with self._locks[queue_name]:
|
|
286
|
+
return list(self._crons[queue_name].values())
|
|
287
|
+
|
|
288
|
+
def _check_connected(self) -> None:
|
|
289
|
+
if not self.is_connected:
|
|
290
|
+
raise BackendError("Not connected")
|