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 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
+ ]
@@ -0,0 +1,10 @@
1
+ from .base import Backend, BackendError
2
+ from .memory import MemoryBackend
3
+ from .redis import RedisBackend
4
+
5
+ __all__ = [
6
+ "Backend",
7
+ "BackendError",
8
+ "MemoryBackend",
9
+ "RedisBackend",
10
+ ]
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
@@ -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")