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.
@@ -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,3 @@
1
+ """
2
+ Framework integrations and contrib modules for TaskManager (FastAPI, Django, Flask).
3
+ """
@@ -0,0 +1,6 @@
1
+ """
2
+ Django Contrib Package for TaskManager.
3
+ Provides automatic task discovery, management commands, and dashboard integration.
4
+ """
5
+
6
+ default_app_config = "taskmanager.contrib.django.apps.TaskManagerConfig"
@@ -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