queue-max 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,346 @@
1
+ """@task decorator for Queue Max.
2
+
3
+ Converts regular functions into enqueuable tasks with support for:
4
+ - Synchronous execution (direct call)
5
+ - Background execution (.delay())
6
+ - Scheduled execution (.schedule_at(), .schedule_in())
7
+ - Parallel map (.map())
8
+ - Bulk operations (.bulk_delay())
9
+ - Argument validation
10
+ - Task versioning
11
+ - Timeout support
12
+ """
13
+
14
+ import functools
15
+ import inspect
16
+ import json
17
+ import logging
18
+ from datetime import datetime, timedelta, timezone
19
+ from typing import Any, Callable, Dict, List, Optional, Tuple, Union
20
+
21
+ from queue_max.core.queue import Queue
22
+
23
+ logger = logging.getLogger("queue_max.task")
24
+
25
+ def _is_serializable(obj: Any) -> bool:
26
+ """Check if an object is JSON-serializable."""
27
+ try:
28
+ json.dumps(obj)
29
+ return True
30
+ except (TypeError, OverflowError):
31
+ return False
32
+
33
+ def task(
34
+ queue: Optional[Queue] = None,
35
+ priority: int = 0,
36
+ max_retries: Optional[int] = None,
37
+ rate_limit: Optional[int] = None,
38
+ timeout: Optional[int] = None,
39
+ retry_delay: int = 60,
40
+ on_success: Optional[Callable] = None,
41
+ on_failure: Optional[Callable] = None,
42
+ version: int = 1,
43
+ ) -> Callable:
44
+ """Decorator that converts a function into an enqueuable task.
45
+
46
+ The decorated function can be called directly (synchronous execution)
47
+ or via .delay() to enqueue it for background processing.
48
+
49
+ Args:
50
+ queue: Queue instance (creates a new one if None).
51
+ priority: Default priority (0=low, 1=medium, 2=high).
52
+ max_retries: Max retry attempts (default: queue default).
53
+ rate_limit: Optional rate limit override for the queue.
54
+ timeout: Max execution time in seconds (Unix only).
55
+ retry_delay: Base delay in seconds for retry backoff.
56
+ on_success: Callback on successful completion.
57
+ on_failure: Callback on task failure.
58
+ version: Task version for schema migrations.
59
+
60
+ Returns:
61
+ Decorated function with .delay(), .schedule_at(), .map(), etc.
62
+
63
+ Example:
64
+ @task(priority=2, timeout=30)
65
+ def send_email(to: str, subject: str):
66
+ return send(to, subject)
67
+
68
+ # Enqueue for background processing
69
+ send_email.delay("user@example.com", "Hello!")
70
+
71
+ # Schedule for later
72
+ from datetime import datetime, timedelta
73
+ future = datetime.now(timezone.utc) + timedelta(minutes=5)
74
+ send_email.schedule_at(future, "user@example.com", "Hello!")
75
+
76
+ # Parallel processing
77
+ send_email.map(["a@b.com", "c@d.com"], "Welcome!")
78
+ """
79
+
80
+ def decorator(func: Callable) -> Callable:
81
+ _queue = queue or Queue(rate_limit=rate_limit)
82
+ task_name = f"{func.__module__}.{func.__name__}"
83
+ sig = inspect.signature(func)
84
+
85
+ @functools.wraps(func)
86
+ def wrapper(*args, **kwargs) -> Any:
87
+ """Execute the task synchronously."""
88
+ try:
89
+ sig.bind(*args, **kwargs)
90
+
91
+ if timeout is not None:
92
+ from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError
93
+
94
+ with ThreadPoolExecutor(max_workers=1) as executor:
95
+ future = executor.submit(func, *args, **kwargs)
96
+ try:
97
+ result = future.result(timeout=timeout)
98
+ except FuturesTimeoutError:
99
+ raise TimeoutError(
100
+ f"Task {task_name} exceeded {timeout}s timeout"
101
+ )
102
+ else:
103
+ result = func(*args, **kwargs)
104
+
105
+ if on_success:
106
+ try:
107
+ on_success(result, task_name=task_name)
108
+ except Exception as e:
109
+ logger.error("on_success callback failed: %s", e)
110
+
111
+ return result
112
+
113
+ except Exception as e:
114
+ if on_failure:
115
+ try:
116
+ on_failure(e, task_name=task_name)
117
+ except Exception as cb_err:
118
+ logger.error("on_failure callback failed: %s", cb_err)
119
+ raise
120
+
121
+ def delay(*args, **kwargs) -> Dict[str, Any]:
122
+ """Enqueue the function for background processing.
123
+
124
+ Returns:
125
+ Dict with 'id' (job ID) and 'shard_id' (assigned shard).
126
+ """
127
+ try:
128
+ sig.bind(*args, **kwargs)
129
+ except TypeError as e:
130
+ raise TypeError(f"Invalid arguments for {task_name}: {e}")
131
+
132
+ for key, value in kwargs.items():
133
+ if not _is_serializable(value):
134
+ logger.warning(
135
+ "Non-serializable argument '%s' for task %s", key, task_name
136
+ )
137
+
138
+ payload = {
139
+ "task": task_name,
140
+ "version": version,
141
+ "args": args,
142
+ "kwargs": kwargs,
143
+ "timeout": timeout,
144
+ "retry_delay": retry_delay,
145
+ }
146
+ return _queue.enqueue(
147
+ payload=payload,
148
+ priority=priority,
149
+ max_retries=max_retries,
150
+ )
151
+
152
+ def schedule_at(when: Union[datetime, str], *args, **kwargs) -> Dict[str, Any]:
153
+ """Schedule task to run at a specific time.
154
+
155
+ Args:
156
+ when: datetime object or ISO 8601 string.
157
+ *args: Positional arguments for the task.
158
+ **kwargs: Keyword arguments for the task.
159
+
160
+ Returns:
161
+ Dict with job id and shard id.
162
+ """
163
+ if isinstance(when, str):
164
+ when = datetime.fromisoformat(when)
165
+ if when.tzinfo is None:
166
+ when = when.replace(tzinfo=timezone.utc)
167
+
168
+ now = datetime.now(timezone.utc)
169
+ if when <= now:
170
+ raise ValueError(f"Scheduled time {when} is in the past")
171
+
172
+ delay_seconds = (when - now).total_seconds()
173
+ return schedule_in(delay_seconds, *args, **kwargs)
174
+
175
+ def schedule_in(seconds: float, *args, **kwargs) -> Dict[str, Any]:
176
+ """Schedule task to run after N seconds.
177
+
178
+ Args:
179
+ seconds: Delay in seconds.
180
+ *args: Positional arguments for the task.
181
+ **kwargs: Keyword arguments for the task.
182
+
183
+ Returns:
184
+ Dict with job id and shard id.
185
+ """
186
+ scheduled = (datetime.now(timezone.utc) + timedelta(seconds=seconds)).isoformat()
187
+ payload = {
188
+ "task": task_name,
189
+ "version": version,
190
+ "args": args,
191
+ "kwargs": kwargs,
192
+ "scheduled_at": scheduled,
193
+ "timeout": timeout,
194
+ "retry_delay": retry_delay,
195
+ }
196
+ result = _queue.enqueue(
197
+ payload=payload,
198
+ priority=priority,
199
+ max_retries=max_retries,
200
+ )
201
+ logger.info("Task %s scheduled at %s (job %s)", task_name, scheduled, result["id"])
202
+ return result
203
+
204
+ def map(items: List[Any], *args, **kwargs) -> List[Dict[str, Any]]:
205
+ """Enqueue multiple items for parallel processing.
206
+
207
+ Each item in the list becomes the first positional argument.
208
+
209
+ Args:
210
+ items: List of items to process.
211
+ *args: Additional positional arguments.
212
+ **kwargs: Keyword arguments.
213
+
214
+ Returns:
215
+ List of job result dicts.
216
+ """
217
+ return [delay(item, *args, **kwargs) for item in items]
218
+
219
+ def bulk_delay(arg_list: List[Tuple[tuple, dict]]) -> List[Dict[str, Any]]:
220
+ """Enqueue multiple calls with different arguments.
221
+
222
+ Args:
223
+ arg_list: List of (args, kwargs) tuples.
224
+
225
+ Returns:
226
+ List of job result dicts.
227
+ """
228
+ return [delay(*a, **kw) for a, kw in arg_list]
229
+
230
+ def get_task_stats() -> Dict[str, Any]:
231
+ """Get statistics for this task."""
232
+ return {
233
+ "task_name": task_name,
234
+ "version": version,
235
+ "priority": priority,
236
+ "max_retries": max_retries,
237
+ "timeout": timeout,
238
+ "queue_stats": _queue.get_stats(),
239
+ }
240
+
241
+ # Attach methods
242
+ wrapper.delay = delay
243
+ wrapper.schedule_at = schedule_at
244
+ wrapper.schedule_in = schedule_in
245
+ wrapper.map = map
246
+ wrapper.bulk_delay = bulk_delay
247
+ wrapper.get_queue = lambda: _queue
248
+ wrapper.get_stats = get_task_stats
249
+
250
+ # Attach metadata
251
+ wrapper.task_name = task_name
252
+ wrapper.version = version
253
+ wrapper.queue = _queue
254
+ wrapper.priority = priority
255
+ wrapper.max_retries = max_retries
256
+
257
+ return wrapper
258
+
259
+ return decorator
260
+
261
+ def periodic_task(interval: int, **task_kwargs) -> Callable:
262
+ """Decorator for tasks that run periodically.
263
+
264
+ Args:
265
+ interval: Seconds between automatic executions.
266
+ **task_kwargs: Arguments passed through to @task.
267
+
268
+ Returns:
269
+ Decorated function with .start_scheduler() method.
270
+
271
+ Example:
272
+ @periodic_task(interval=3600, priority=1)
273
+ def cleanup():
274
+ print("Cleaning up...")
275
+
276
+ cleanup.start_scheduler()
277
+ """
278
+ def decorator(func: Callable) -> Callable:
279
+ decorated = task(**task_kwargs)(func)
280
+
281
+ def start_scheduler() -> None:
282
+ import threading
283
+ import time as _time
284
+
285
+ def _run():
286
+ while True:
287
+ try:
288
+ decorated.delay()
289
+ except Exception as e:
290
+ logger.error("Periodic task %s failed: %s", func.__name__, e)
291
+ _time.sleep(interval)
292
+
293
+ thread = threading.Thread(target=_run, daemon=True)
294
+ thread.start()
295
+
296
+ decorated.start_scheduler = start_scheduler
297
+ return decorated
298
+
299
+ return decorator
300
+
301
+ def retryable_task(
302
+ max_retries: int = 5,
303
+ retry_on: Optional[List[Exception]] = None,
304
+ **task_kwargs,
305
+ ) -> Callable:
306
+ """Decorator for tasks that should retry synchronously on specific exceptions.
307
+
308
+ Args:
309
+ max_retries: Max retry attempts.
310
+ retry_on: List of exception types to retry on (default: all).
311
+ **task_kwargs: Arguments passed through to @task.
312
+
313
+ Returns:
314
+ Decorated function with .delay() and synchronous retry logic.
315
+ """
316
+ def decorator(func: Callable) -> Callable:
317
+ task_kwargs.setdefault("max_retries", max_retries)
318
+ decorated = task(**task_kwargs)(func)
319
+
320
+ @functools.wraps(func)
321
+ def wrapper(*args, **kwargs):
322
+ retry_on_exceptions = retry_on or (Exception,)
323
+ attempt = 0
324
+ while attempt <= max_retries:
325
+ try:
326
+ return func(*args, **kwargs)
327
+ except retry_on_exceptions as e:
328
+ attempt += 1
329
+ if attempt > max_retries:
330
+ raise
331
+ import time as _time
332
+ delay = task_kwargs.get("retry_delay", 60) * (2 ** (attempt - 1))
333
+ logger.warning(
334
+ "Retry %d/%d for %s in %.1fs: %s",
335
+ attempt, max_retries, func.__name__, delay, e,
336
+ )
337
+ _time.sleep(delay)
338
+
339
+ wrapper.delay = decorated.delay
340
+ wrapper.schedule_at = decorated.schedule_at
341
+ wrapper.schedule_in = decorated.schedule_in
342
+ wrapper.map = decorated.map
343
+ wrapper.task_name = decorated.task_name
344
+ return wrapper
345
+
346
+ return decorator