taskmanager-engine 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.
- taskmanager/__init__.py +150 -0
- taskmanager/api/app.py +488 -0
- taskmanager/api/events.py +68 -0
- taskmanager/cli.py +291 -0
- taskmanager/config.py +19 -0
- taskmanager/contrib/__init__.py +3 -0
- taskmanager/contrib/django/__init__.py +6 -0
- taskmanager/contrib/django/apps.py +46 -0
- taskmanager/contrib/django/management/__init__.py +1 -0
- taskmanager/contrib/django/management/commands/__init__.py +1 -0
- taskmanager/contrib/django/management/commands/run_scheduler.py +44 -0
- taskmanager/contrib/django/management/commands/run_worker.py +72 -0
- taskmanager/contrib/django/urls.py +30 -0
- taskmanager/contrib/fastapi.py +31 -0
- taskmanager/core/broker.py +541 -0
- taskmanager/core/builtin_tasks.py +59 -0
- taskmanager/core/job.py +50 -0
- taskmanager/core/task.py +224 -0
- taskmanager/scheduler/cron.py +52 -0
- taskmanager/scheduler/scheduler.py +178 -0
- taskmanager/ui/app.js +1390 -0
- taskmanager/ui/index.html +681 -0
- taskmanager/ui/styles.css +1048 -0
- taskmanager/worker/heartbeat.py +150 -0
- taskmanager/worker/worker.py +207 -0
- taskmanager_engine-0.1.0.dist-info/METADATA +284 -0
- taskmanager_engine-0.1.0.dist-info/RECORD +30 -0
- taskmanager_engine-0.1.0.dist-info/WHEEL +5 -0
- taskmanager_engine-0.1.0.dist-info/entry_points.txt +2 -0
- taskmanager_engine-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from fastapi import WebSocket
|
|
8
|
+
|
|
9
|
+
from taskmanager.core.broker import RedisBroker
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class WebSocketEventManager:
|
|
15
|
+
"""Manages active browser WebSocket clients and relays Redis events in real time."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, broker: RedisBroker):
|
|
18
|
+
self.broker = broker
|
|
19
|
+
self.active_connections: set[WebSocket] = set()
|
|
20
|
+
self._listener_task: asyncio.Task[None] | None = None
|
|
21
|
+
self._running = False
|
|
22
|
+
|
|
23
|
+
async def connect(self, websocket: WebSocket) -> None:
|
|
24
|
+
await websocket.accept()
|
|
25
|
+
self.active_connections.add(websocket)
|
|
26
|
+
|
|
27
|
+
def disconnect(self, websocket: WebSocket) -> None:
|
|
28
|
+
self.active_connections.discard(websocket)
|
|
29
|
+
|
|
30
|
+
async def broadcast(self, message: dict[str, Any]) -> None:
|
|
31
|
+
"""Sends a JSON message to all connected clients."""
|
|
32
|
+
if not self.active_connections:
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
dead_connections: set[WebSocket] = set()
|
|
36
|
+
for connection in list(self.active_connections):
|
|
37
|
+
try:
|
|
38
|
+
await connection.send_json(message)
|
|
39
|
+
except Exception:
|
|
40
|
+
dead_connections.add(connection)
|
|
41
|
+
|
|
42
|
+
for dead in dead_connections:
|
|
43
|
+
self.active_connections.discard(dead)
|
|
44
|
+
|
|
45
|
+
async def start_listener(self) -> None:
|
|
46
|
+
"""Subscribes to Redis Pub/Sub and relays events to connected WebSockets."""
|
|
47
|
+
self._running = True
|
|
48
|
+
self._listener_task = asyncio.create_task(self._listen_loop())
|
|
49
|
+
|
|
50
|
+
async def stop_listener(self) -> None:
|
|
51
|
+
self._running = False
|
|
52
|
+
if self._listener_task and not self._listener_task.done():
|
|
53
|
+
self._listener_task.cancel()
|
|
54
|
+
try:
|
|
55
|
+
await self._listener_task
|
|
56
|
+
except asyncio.CancelledError:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
async def _listen_loop(self) -> None:
|
|
60
|
+
try:
|
|
61
|
+
async for event in self.broker.subscribe_events():
|
|
62
|
+
if not self._running:
|
|
63
|
+
break
|
|
64
|
+
await self.broadcast(event)
|
|
65
|
+
except asyncio.CancelledError:
|
|
66
|
+
pass
|
|
67
|
+
except Exception as err:
|
|
68
|
+
logger.warning(f"WebSocket listener encountered error: {err}")
|
taskmanager/cli.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import importlib
|
|
6
|
+
import importlib.util
|
|
7
|
+
import logging
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import fakeredis.aioredis
|
|
12
|
+
import redis.asyncio as redis
|
|
13
|
+
import uvicorn
|
|
14
|
+
|
|
15
|
+
from taskmanager.api.app import create_app
|
|
16
|
+
from taskmanager.config import settings
|
|
17
|
+
from taskmanager.core.broker import RedisBroker
|
|
18
|
+
from taskmanager.core.task import registry
|
|
19
|
+
from taskmanager.scheduler.scheduler import Scheduler
|
|
20
|
+
from taskmanager.worker.worker import Worker
|
|
21
|
+
|
|
22
|
+
logging.basicConfig(
|
|
23
|
+
level=logging.INFO,
|
|
24
|
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
25
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
26
|
+
)
|
|
27
|
+
logger = logging.getLogger("taskmanager.cli")
|
|
28
|
+
|
|
29
|
+
_shared_fake_server = fakeredis.FakeServer()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def get_redis_client(redis_url: str | None = None, force_in_memory: bool = False) -> redis.Redis:
|
|
33
|
+
"""Provides a connected Redis client with graceful fallback to in-memory FakeRedis."""
|
|
34
|
+
url = redis_url or settings.redis_url
|
|
35
|
+
if force_in_memory or (url and url.startswith("memory://")):
|
|
36
|
+
logger.info("🧠 Usando Redis In-Memory integrado (fakeredis).")
|
|
37
|
+
return fakeredis.aioredis.FakeRedis(server=_shared_fake_server, decode_responses=True)
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
client = redis.from_url(url, decode_responses=True)
|
|
41
|
+
# Test connection vitality
|
|
42
|
+
await asyncio.wait_for(client.ping(), timeout=1.5)
|
|
43
|
+
return client
|
|
44
|
+
except Exception:
|
|
45
|
+
logger.warning(
|
|
46
|
+
f"⚠️ Servidor Redis em '{url}' não está acessível. "
|
|
47
|
+
"Iniciando automaticamente com Redis In-Memory integrado para desenvolvimento!"
|
|
48
|
+
)
|
|
49
|
+
logger.info("💡 Dica: Para conectar a um Redis real, execute: docker run -d -p 6379:6379 redis:alpine")
|
|
50
|
+
return fakeredis.aioredis.FakeRedis(server=_shared_fake_server, decode_responses=True)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def auto_discover_tasks(modules: list[str]) -> None:
|
|
54
|
+
"""Imports user modules or file paths to register decorated @task functions."""
|
|
55
|
+
cwd = str(Path.cwd().resolve())
|
|
56
|
+
if cwd not in sys.path:
|
|
57
|
+
sys.path.insert(0, cwd)
|
|
58
|
+
|
|
59
|
+
for mod in modules:
|
|
60
|
+
try:
|
|
61
|
+
# If path to a python file was passed directly (e.g. 'example_tasks.py')
|
|
62
|
+
if mod.endswith(".py") or "/" in mod or "\\" in mod:
|
|
63
|
+
path = Path(mod).resolve()
|
|
64
|
+
if path.exists():
|
|
65
|
+
mod_name = path.stem
|
|
66
|
+
spec = importlib.util.spec_from_file_location(mod_name, str(path))
|
|
67
|
+
if spec and spec.loader:
|
|
68
|
+
module = importlib.util.module_from_spec(spec)
|
|
69
|
+
sys.modules[mod_name] = module
|
|
70
|
+
spec.loader.exec_module(module)
|
|
71
|
+
logger.info(f"Loaded tasks from file: {mod}")
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
# Standard python module import (e.g. 'example_tasks')
|
|
75
|
+
importlib.import_module(mod)
|
|
76
|
+
logger.info(f"Loaded tasks from module: {mod}")
|
|
77
|
+
except Exception as err:
|
|
78
|
+
logger.warning(f"Could not import module '{mod}': {err}")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async def run_worker(
|
|
82
|
+
queues: list[str],
|
|
83
|
+
concurrency: int,
|
|
84
|
+
name: str | None,
|
|
85
|
+
max_memory_mb: float | None = None,
|
|
86
|
+
max_cpu_percent: float | None = None,
|
|
87
|
+
redis_url: str | None = None,
|
|
88
|
+
in_memory: bool = False,
|
|
89
|
+
) -> None:
|
|
90
|
+
client = await get_redis_client(redis_url, force_in_memory=in_memory)
|
|
91
|
+
broker = RedisBroker(client, prefix=settings.redis_prefix)
|
|
92
|
+
registry.set_broker(broker)
|
|
93
|
+
worker = Worker(
|
|
94
|
+
queues=queues,
|
|
95
|
+
concurrency=concurrency,
|
|
96
|
+
name=name,
|
|
97
|
+
max_memory_mb=max_memory_mb,
|
|
98
|
+
max_cpu_percent=max_cpu_percent,
|
|
99
|
+
broker=broker,
|
|
100
|
+
task_registry=registry,
|
|
101
|
+
)
|
|
102
|
+
try:
|
|
103
|
+
await worker.start()
|
|
104
|
+
except KeyboardInterrupt:
|
|
105
|
+
await worker.stop()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def run_scheduler(redis_url: str | None = None, in_memory: bool = False) -> None:
|
|
109
|
+
client = await get_redis_client(redis_url, force_in_memory=in_memory)
|
|
110
|
+
broker = RedisBroker(client, prefix=settings.redis_prefix)
|
|
111
|
+
sched = Scheduler(broker)
|
|
112
|
+
try:
|
|
113
|
+
await sched.start()
|
|
114
|
+
except KeyboardInterrupt:
|
|
115
|
+
await sched.stop()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
async def run_server(
|
|
119
|
+
host: str,
|
|
120
|
+
port: int,
|
|
121
|
+
redis_url: str | None = None,
|
|
122
|
+
in_memory: bool = False,
|
|
123
|
+
) -> None:
|
|
124
|
+
client = await get_redis_client(redis_url, force_in_memory=in_memory)
|
|
125
|
+
broker = RedisBroker(client, prefix=settings.redis_prefix)
|
|
126
|
+
registry.set_broker(broker)
|
|
127
|
+
app = create_app(broker=broker, task_reg=registry)
|
|
128
|
+
config = uvicorn.Config(app=app, host=host, port=port, log_level="info")
|
|
129
|
+
server = uvicorn.Server(config)
|
|
130
|
+
await server.serve()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def run_dev(
|
|
134
|
+
host: str,
|
|
135
|
+
port: int,
|
|
136
|
+
queues: list[str],
|
|
137
|
+
concurrency: int,
|
|
138
|
+
max_memory_mb: float | None = None,
|
|
139
|
+
max_cpu_percent: float | None = None,
|
|
140
|
+
redis_url: str | None = None,
|
|
141
|
+
in_memory: bool = False,
|
|
142
|
+
) -> None:
|
|
143
|
+
"""Runs API Server, Worker, and Scheduler in a single process with shared broker."""
|
|
144
|
+
client = await get_redis_client(redis_url, force_in_memory=in_memory)
|
|
145
|
+
broker = RedisBroker(client, prefix=settings.redis_prefix)
|
|
146
|
+
registry.set_broker(broker)
|
|
147
|
+
|
|
148
|
+
# In dev mode, auto-listen to all registered queues if default was selected
|
|
149
|
+
active_queues = list(queues)
|
|
150
|
+
if queues == ["default"]:
|
|
151
|
+
for t_name in registry.list_tasks():
|
|
152
|
+
t_obj = registry.get(t_name)
|
|
153
|
+
if t_obj and t_obj.queue not in active_queues:
|
|
154
|
+
active_queues.append(t_obj.queue)
|
|
155
|
+
|
|
156
|
+
worker = Worker(
|
|
157
|
+
queues=active_queues,
|
|
158
|
+
concurrency=concurrency,
|
|
159
|
+
name="dev-worker",
|
|
160
|
+
max_memory_mb=max_memory_mb,
|
|
161
|
+
max_cpu_percent=max_cpu_percent,
|
|
162
|
+
broker=broker,
|
|
163
|
+
task_registry=registry,
|
|
164
|
+
)
|
|
165
|
+
app = create_app(broker=broker, task_reg=registry)
|
|
166
|
+
|
|
167
|
+
config = uvicorn.Config(app=app, host=host, port=port, log_level="info")
|
|
168
|
+
server = uvicorn.Server(config)
|
|
169
|
+
|
|
170
|
+
logger.info(f"🚀 TaskManager Dev Environment running at http://{host}:{port}")
|
|
171
|
+
|
|
172
|
+
await asyncio.gather(
|
|
173
|
+
server.serve(),
|
|
174
|
+
worker.start(),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def main() -> None:
|
|
179
|
+
parser = argparse.ArgumentParser(
|
|
180
|
+
prog="taskmanager",
|
|
181
|
+
description="TaskManager: Celery/BullMQ-like background task manager and Linear dark SPA dashboard",
|
|
182
|
+
)
|
|
183
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
184
|
+
|
|
185
|
+
# 1. Server Command
|
|
186
|
+
server_parser = subparsers.add_parser("server", help="Start the FastAPI dashboard & API server")
|
|
187
|
+
server_parser.add_argument("--host", default="0.0.0.0", help="Host address (default: 0.0.0.0)")
|
|
188
|
+
server_parser.add_argument("--port", type=int, default=8000, help="Port (default: 8000)")
|
|
189
|
+
server_parser.add_argument("--redis-url", default=None, help="Redis connection URL")
|
|
190
|
+
server_parser.add_argument("--in-memory", action="store_true", help="Force in-memory Redis")
|
|
191
|
+
server_parser.add_argument(
|
|
192
|
+
"--app-module", nargs="*", default=[], help="Python modules containing @task definitions"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# 2. Worker Command
|
|
196
|
+
worker_parser = subparsers.add_parser("worker", help="Start an async worker process")
|
|
197
|
+
worker_parser.add_argument(
|
|
198
|
+
"-q", "--queues", default="default", help="Comma-separated queue names (default: default)"
|
|
199
|
+
)
|
|
200
|
+
worker_parser.add_argument(
|
|
201
|
+
"-c", "--concurrency", type=int, default=5, help="Worker concurrency (default: 5)"
|
|
202
|
+
)
|
|
203
|
+
worker_parser.add_argument("-n", "--name", default=None, help="Custom worker name")
|
|
204
|
+
worker_parser.add_argument("--max-memory-mb", type=float, default=None, help="Max RSS memory (MB) before backpressure")
|
|
205
|
+
worker_parser.add_argument("--max-cpu-percent", type=float, default=None, help="Max CPU percentage before backpressure")
|
|
206
|
+
worker_parser.add_argument("--redis-url", default=None, help="Redis connection URL")
|
|
207
|
+
worker_parser.add_argument("--in-memory", action="store_true", help="Force in-memory Redis")
|
|
208
|
+
worker_parser.add_argument(
|
|
209
|
+
"-m", "--modules", nargs="*", default=[], help="Python modules to import for tasks"
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# 3. Scheduler Command
|
|
213
|
+
sched_parser = subparsers.add_parser("scheduler", help="Start the dynamic cron scheduler")
|
|
214
|
+
sched_parser.add_argument("--redis-url", default=None, help="Redis connection URL")
|
|
215
|
+
sched_parser.add_argument("--in-memory", action="store_true", help="Force in-memory Redis")
|
|
216
|
+
sched_parser.add_argument(
|
|
217
|
+
"-m", "--modules", nargs="*", default=[], help="Python modules to import for tasks"
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
# 4. Dev Command (All-in-one)
|
|
221
|
+
dev_parser = subparsers.add_parser(
|
|
222
|
+
"dev", help="Start Server, Worker, and Scheduler in one command"
|
|
223
|
+
)
|
|
224
|
+
dev_parser.add_argument("--host", default="0.0.0.0", help="Host address (default: 0.0.0.0)")
|
|
225
|
+
dev_parser.add_argument("--port", type=int, default=8000, help="Port (default: 8000)")
|
|
226
|
+
dev_parser.add_argument("-q", "--queues", default="default", help="Comma-separated queue names")
|
|
227
|
+
dev_parser.add_argument("-c", "--concurrency", type=int, default=5, help="Worker concurrency")
|
|
228
|
+
dev_parser.add_argument("--max-memory-mb", type=float, default=None, help="Max RSS memory (MB) before backpressure")
|
|
229
|
+
dev_parser.add_argument("--max-cpu-percent", type=float, default=None, help="Max CPU percentage before backpressure")
|
|
230
|
+
dev_parser.add_argument("--redis-url", default=None, help="Redis connection URL")
|
|
231
|
+
dev_parser.add_argument("--in-memory", action="store_true", help="Force in-memory Redis")
|
|
232
|
+
dev_parser.add_argument(
|
|
233
|
+
"-m", "--modules", nargs="*", default=[], help="Python modules to import"
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
args = parser.parse_args()
|
|
237
|
+
|
|
238
|
+
if args.command == "server":
|
|
239
|
+
auto_discover_tasks(args.app_module)
|
|
240
|
+
asyncio.run(
|
|
241
|
+
run_server(
|
|
242
|
+
host=args.host,
|
|
243
|
+
port=args.port,
|
|
244
|
+
redis_url=args.redis_url,
|
|
245
|
+
in_memory=args.in_memory,
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
elif args.command == "worker":
|
|
250
|
+
auto_discover_tasks(args.modules)
|
|
251
|
+
queues = [q.strip() for q in args.queues.split(",") if q.strip()]
|
|
252
|
+
asyncio.run(
|
|
253
|
+
run_worker(
|
|
254
|
+
queues=queues,
|
|
255
|
+
concurrency=args.concurrency,
|
|
256
|
+
name=args.name,
|
|
257
|
+
max_memory_mb=args.max_memory_mb,
|
|
258
|
+
max_cpu_percent=args.max_cpu_percent,
|
|
259
|
+
redis_url=args.redis_url,
|
|
260
|
+
in_memory=args.in_memory,
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
elif args.command == "scheduler":
|
|
265
|
+
auto_discover_tasks(args.modules)
|
|
266
|
+
asyncio.run(
|
|
267
|
+
run_scheduler(
|
|
268
|
+
redis_url=args.redis_url,
|
|
269
|
+
in_memory=args.in_memory,
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
elif args.command == "dev":
|
|
274
|
+
auto_discover_tasks(args.modules)
|
|
275
|
+
queues = [q.strip() for q in args.queues.split(",") if q.strip()]
|
|
276
|
+
asyncio.run(
|
|
277
|
+
run_dev(
|
|
278
|
+
host=args.host,
|
|
279
|
+
port=args.port,
|
|
280
|
+
queues=queues,
|
|
281
|
+
concurrency=args.concurrency,
|
|
282
|
+
max_memory_mb=args.max_memory_mb,
|
|
283
|
+
max_cpu_percent=args.max_cpu_percent,
|
|
284
|
+
redis_url=args.redis_url,
|
|
285
|
+
in_memory=args.in_memory,
|
|
286
|
+
)
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
if __name__ == "__main__":
|
|
291
|
+
main()
|
taskmanager/config.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Settings(BaseModel):
|
|
9
|
+
redis_url: str = Field(
|
|
10
|
+
default_factory=lambda: os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
|
11
|
+
)
|
|
12
|
+
redis_prefix: str = Field(default_factory=lambda: os.getenv("REDIS_PREFIX", "tm"))
|
|
13
|
+
default_queue: str = Field(default_factory=lambda: os.getenv("DEFAULT_QUEUE", "default"))
|
|
14
|
+
worker_heartbeat_interval: float = 3.0
|
|
15
|
+
worker_heartbeat_ttl: float = 10.0
|
|
16
|
+
scheduler_poll_interval: float = 1.0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
settings = Settings()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
from django.apps import AppConfig
|
|
8
|
+
from django.conf import settings
|
|
9
|
+
DJANGO_AVAILABLE = True
|
|
10
|
+
except ImportError:
|
|
11
|
+
AppConfig = object # type: ignore
|
|
12
|
+
settings = None # type: ignore
|
|
13
|
+
DJANGO_AVAILABLE = False
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("taskmanager.contrib.django")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def autodiscover_tasks() -> list[str]:
|
|
19
|
+
"""Scans all INSTALLED_APPS for <app>.tasks modules and imports them."""
|
|
20
|
+
if not DJANGO_AVAILABLE or not settings:
|
|
21
|
+
return []
|
|
22
|
+
|
|
23
|
+
discovered: list[str] = []
|
|
24
|
+
installed_apps = getattr(settings, "INSTALLED_APPS", [])
|
|
25
|
+
|
|
26
|
+
for app_path in installed_apps:
|
|
27
|
+
if app_path.startswith("taskmanager") or app_path.startswith("django."):
|
|
28
|
+
continue
|
|
29
|
+
try:
|
|
30
|
+
mod_name = f"{app_path}.tasks"
|
|
31
|
+
importlib.import_module(mod_name)
|
|
32
|
+
discovered.append(mod_name)
|
|
33
|
+
logger.debug("Discovered tasks module: %s", mod_name)
|
|
34
|
+
except (ImportError, AttributeError):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
return discovered
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TaskManagerConfig(AppConfig): # type: ignore
|
|
41
|
+
name = "taskmanager.contrib.django"
|
|
42
|
+
verbose_name = "TaskManager Background Engine"
|
|
43
|
+
|
|
44
|
+
def ready(self) -> None:
|
|
45
|
+
if DJANGO_AVAILABLE:
|
|
46
|
+
autodiscover_tasks()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Management module for Django integration."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Management commands for TaskManager Django integration."""
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
from django.conf import settings
|
|
8
|
+
from django.core.management.base import BaseCommand
|
|
9
|
+
DJANGO_AVAILABLE = True
|
|
10
|
+
except ImportError:
|
|
11
|
+
BaseCommand = object # type: ignore
|
|
12
|
+
settings = None # type: ignore
|
|
13
|
+
DJANGO_AVAILABLE = False
|
|
14
|
+
|
|
15
|
+
from taskmanager.cli import get_redis_client
|
|
16
|
+
from taskmanager.core.broker import RedisBroker
|
|
17
|
+
from taskmanager.scheduler.scheduler import Scheduler
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Command(BaseCommand): # type: ignore
|
|
21
|
+
help = "Starts the TaskManager dynamic cron and scheduled job runner."
|
|
22
|
+
|
|
23
|
+
def add_arguments(self, parser):
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"--redis-url",
|
|
26
|
+
type=str,
|
|
27
|
+
default=None,
|
|
28
|
+
help="Redis connection URI (defaults to TASKMANAGER_REDIS_URL in settings or env).",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def handle(self, *args, **options):
|
|
32
|
+
if not DJANGO_AVAILABLE:
|
|
33
|
+
sys.stderr.write("Django is required to run this command.\n")
|
|
34
|
+
sys.exit(1)
|
|
35
|
+
|
|
36
|
+
redis_url = options.get("redis_url") or getattr(settings, "TASKMANAGER_REDIS_URL", None)
|
|
37
|
+
|
|
38
|
+
async def _run():
|
|
39
|
+
client = await get_redis_client(redis_url)
|
|
40
|
+
broker = RedisBroker(client)
|
|
41
|
+
scheduler = Scheduler(broker)
|
|
42
|
+
await scheduler.start()
|
|
43
|
+
|
|
44
|
+
asyncio.run(_run())
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
from django.conf import settings
|
|
8
|
+
from django.core.management.base import BaseCommand
|
|
9
|
+
DJANGO_AVAILABLE = True
|
|
10
|
+
except ImportError:
|
|
11
|
+
BaseCommand = object # type: ignore
|
|
12
|
+
settings = None # type: ignore
|
|
13
|
+
DJANGO_AVAILABLE = False
|
|
14
|
+
|
|
15
|
+
from taskmanager.cli import get_redis_client
|
|
16
|
+
from taskmanager.core.broker import RedisBroker
|
|
17
|
+
from taskmanager.core.task import registry
|
|
18
|
+
from taskmanager.worker.worker import Worker
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Command(BaseCommand): # type: ignore
|
|
22
|
+
help = "Starts a TaskManager background worker instance consuming from designated queues."
|
|
23
|
+
|
|
24
|
+
def add_arguments(self, parser):
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--queues",
|
|
27
|
+
type=str,
|
|
28
|
+
default="default",
|
|
29
|
+
help="Comma-separated list of queue names to consume (default: 'default').",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--concurrency",
|
|
33
|
+
type=int,
|
|
34
|
+
default=5,
|
|
35
|
+
help="Number of concurrent task execution slots (default: 5).",
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"--name",
|
|
39
|
+
type=str,
|
|
40
|
+
default=None,
|
|
41
|
+
help="Worker identification name (defaults to hostname-worker-<uuid>).",
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--redis-url",
|
|
45
|
+
type=str,
|
|
46
|
+
default=None,
|
|
47
|
+
help="Redis connection URI (defaults to TASKMANAGER_REDIS_URL in settings or env).",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def handle(self, *args, **options):
|
|
51
|
+
if not DJANGO_AVAILABLE:
|
|
52
|
+
sys.stderr.write("Django is required to run this command.\n")
|
|
53
|
+
sys.exit(1)
|
|
54
|
+
|
|
55
|
+
queues = [q.strip() for q in options["queues"].split(",") if q.strip()]
|
|
56
|
+
concurrency = options["concurrency"]
|
|
57
|
+
name = options["name"]
|
|
58
|
+
redis_url = options.get("redis_url") or getattr(settings, "TASKMANAGER_REDIS_URL", None)
|
|
59
|
+
|
|
60
|
+
async def _run():
|
|
61
|
+
client = await get_redis_client(redis_url)
|
|
62
|
+
broker = RedisBroker(client)
|
|
63
|
+
worker = Worker(
|
|
64
|
+
broker=broker,
|
|
65
|
+
registry=registry,
|
|
66
|
+
queues=queues,
|
|
67
|
+
concurrency=concurrency,
|
|
68
|
+
name=name,
|
|
69
|
+
)
|
|
70
|
+
await worker.start()
|
|
71
|
+
|
|
72
|
+
asyncio.run(_run())
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
from django.http import FileResponse, HttpResponse
|
|
7
|
+
from django.urls import path
|
|
8
|
+
DJANGO_AVAILABLE = True
|
|
9
|
+
except ImportError:
|
|
10
|
+
DJANGO_AVAILABLE = False
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
app_name = "taskmanager"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def dashboard_view(request):
|
|
17
|
+
"""Serves the TaskManager SPA dashboard in standard Django setups."""
|
|
18
|
+
ui_dir = Path(__file__).resolve().parent.parent.parent / "ui"
|
|
19
|
+
index_file = ui_dir / "index.html"
|
|
20
|
+
if index_file.exists():
|
|
21
|
+
return FileResponse(open(index_file, "rb"), content_type="text/html")
|
|
22
|
+
return HttpResponse("TaskManager Dashboard UI not found.", status=404)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
if DJANGO_AVAILABLE:
|
|
26
|
+
urlpatterns = [
|
|
27
|
+
path("", dashboard_view, name="dashboard"),
|
|
28
|
+
]
|
|
29
|
+
else:
|
|
30
|
+
urlpatterns = []
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fastapi import FastAPI
|
|
4
|
+
|
|
5
|
+
from taskmanager.api.app import create_app
|
|
6
|
+
from taskmanager.core.broker import RedisBroker
|
|
7
|
+
from taskmanager.core.task import TaskRegistry, registry
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def mount_taskmanager(
|
|
11
|
+
parent_app: FastAPI,
|
|
12
|
+
path: str = "/tasks",
|
|
13
|
+
broker: RedisBroker | None = None,
|
|
14
|
+
task_reg: TaskRegistry | None = None,
|
|
15
|
+
redis_url: str | None = None,
|
|
16
|
+
prefix: str | None = None,
|
|
17
|
+
) -> FastAPI:
|
|
18
|
+
"""
|
|
19
|
+
Mounts the TaskManager dashboard and REST API onto an existing FastAPI/Starlette application.
|
|
20
|
+
|
|
21
|
+
Example:
|
|
22
|
+
app = FastAPI()
|
|
23
|
+
mount_taskmanager(app, path="/tasks")
|
|
24
|
+
"""
|
|
25
|
+
sub_app = create_app(
|
|
26
|
+
broker=broker,
|
|
27
|
+
task_reg=task_reg or registry,
|
|
28
|
+
root_path=path,
|
|
29
|
+
)
|
|
30
|
+
parent_app.mount(path, sub_app)
|
|
31
|
+
return sub_app
|