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,224 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from collections.abc import Callable
5
+ from typing import Any
6
+
7
+ import redis.asyncio as redis
8
+
9
+ from taskmanager.config import settings
10
+ from taskmanager.core.broker import RedisBroker
11
+ from taskmanager.core.job import Job
12
+
13
+
14
+ class TaskRegistry:
15
+ """Registry holding all user-defined background task functions."""
16
+
17
+ def __init__(self):
18
+ self._tasks: dict[str, Task] = {}
19
+ self._default_broker: RedisBroker | None = None
20
+
21
+ def register(self, task: Task) -> None:
22
+ self._tasks[task.name] = task
23
+
24
+ def get(self, name: str) -> Task | None:
25
+ return self._tasks.get(name)
26
+
27
+ def list_tasks(self) -> list[str]:
28
+ return list(self._tasks.keys())
29
+
30
+ def get_broker(self) -> RedisBroker:
31
+ if self._default_broker is None:
32
+ client = redis.from_url(settings.redis_url, decode_responses=True)
33
+ self._default_broker = RedisBroker(client, prefix=settings.redis_prefix)
34
+ return self._default_broker
35
+
36
+ def set_broker(self, broker: RedisBroker) -> None:
37
+ self._default_broker = broker
38
+
39
+ def task(
40
+ self,
41
+ name: str | None = None,
42
+ queue: str = "default",
43
+ max_retries: int = 3,
44
+ retry_backoff: float = 2.0,
45
+ timeout: float | None = None,
46
+ ) -> Callable[..., Task]:
47
+ """Decorator to register a function directly to this registry."""
48
+ def decorator(func: Callable[..., Any]) -> Task:
49
+ t = Task(
50
+ func=func,
51
+ name=name,
52
+ queue=queue,
53
+ max_retries=max_retries,
54
+ retry_backoff=retry_backoff,
55
+ timeout=timeout,
56
+ broker=self._default_broker,
57
+ )
58
+ self.register(t)
59
+ return t
60
+
61
+ return decorator
62
+
63
+
64
+ registry = TaskRegistry()
65
+
66
+
67
+ class Task:
68
+ """Represents a background task wrapper around a Python callable or coroutine."""
69
+
70
+ def __init__(
71
+ self,
72
+ func: Callable[..., Any],
73
+ name: str | None = None,
74
+ queue: str = "default",
75
+ max_retries: int = 3,
76
+ retry_backoff: float = 2.0,
77
+ timeout: float | None = None,
78
+ broker: RedisBroker | None = None,
79
+ ):
80
+ self.func = func
81
+ self.name = name or f"{func.__module__}.{func.__name__}"
82
+ self.queue = queue
83
+ self.max_retries = max_retries
84
+ self.retry_backoff = retry_backoff
85
+ self.timeout = timeout
86
+ self._broker = broker
87
+ self.is_async = inspect.iscoroutinefunction(func)
88
+
89
+ def get_signature_info(self) -> dict[str, Any]:
90
+ """Extracts parameters, types, defaults, and generates a sample payload."""
91
+ try:
92
+ sig = inspect.signature(self.func)
93
+ params = []
94
+ sample_kwargs: dict[str, Any] = {}
95
+ for param in sig.parameters.values():
96
+ has_default = param.default is not inspect.Parameter.empty
97
+ default_val = param.default if has_default else None
98
+ ann_str = "Any" if param.annotation is inspect.Parameter.empty else str(param.annotation)
99
+
100
+ params.append(
101
+ {
102
+ "name": param.name,
103
+ "has_default": has_default,
104
+ "default": default_val,
105
+ "annotation": ann_str,
106
+ }
107
+ )
108
+
109
+ # Heuristics for realistic smart sample values
110
+ if has_default:
111
+ if default_val is not None:
112
+ sample_kwargs[param.name] = default_val
113
+ else:
114
+ ann_lower = ann_str.lower()
115
+ p_name = param.name.lower()
116
+ if "cwd" in p_name:
117
+ pass # Keep optional cwd omitted from sample
118
+ elif "int" in ann_lower or "year" in p_name or "month" in p_name or "count" in p_name:
119
+ sample_kwargs[param.name] = 2026 if "year" in p_name else (8 if "month" in p_name else 1)
120
+ elif "float" in ann_lower:
121
+ sample_kwargs[param.name] = 10.0
122
+ elif "bool" in ann_lower or "dry_run" in p_name or "force" in p_name:
123
+ sample_kwargs[param.name] = False
124
+ elif "list" in ann_lower:
125
+ sample_kwargs[param.name] = []
126
+ elif "dict" in ann_lower:
127
+ sample_kwargs[param.name] = {}
128
+ elif "email" in p_name:
129
+ sample_kwargs[param.name] = "cliente@exemplo.com"
130
+ elif "name" in p_name or "nome" in p_name:
131
+ sample_kwargs[param.name] = "Carlos Silva"
132
+ elif "order" in p_name or "id" in p_name:
133
+ sample_kwargs[param.name] = "PED-12345"
134
+ elif "command" in p_name:
135
+ sample_kwargs[param.name] = "python scripts/backup_database.py --compress"
136
+ elif "path" in p_name:
137
+ sample_kwargs[param.name] = "scripts/backup_database.py"
138
+ else:
139
+ sample_kwargs[param.name] = f"valor_{param.name}"
140
+
141
+ doc = inspect.getdoc(self.func) or ""
142
+ return {
143
+ "parameters": params,
144
+ "sample_kwargs": sample_kwargs,
145
+ "docstring": doc,
146
+ }
147
+ except Exception:
148
+ return {
149
+ "parameters": [],
150
+ "sample_kwargs": {},
151
+ "docstring": "",
152
+ }
153
+
154
+ @property
155
+ def broker(self) -> RedisBroker:
156
+ return self._broker or registry.get_broker()
157
+
158
+ def set_broker(self, broker: RedisBroker) -> None:
159
+ self._broker = broker
160
+
161
+ async def __call__(self, *args: Any, **kwargs: Any) -> Any:
162
+ """Direct call execution for testing or inline execution."""
163
+ if self.is_async:
164
+ return await self.func(*args, **kwargs)
165
+ return self.func(*args, **kwargs)
166
+
167
+ async def delay(self, *args: Any, **kwargs: Any) -> Job:
168
+ """Enqueues the task immediately into the default queue."""
169
+ return await self.apply_async(args=list(args), kwargs=kwargs)
170
+
171
+ async def apply_async(
172
+ self,
173
+ args: list[Any] | None = None,
174
+ kwargs: dict[str, Any] | None = None,
175
+ queue: str | None = None,
176
+ delay: float | None = None,
177
+ priority: int = 0,
178
+ max_retries: int | None = None,
179
+ retry_backoff: float | None = None,
180
+ timeout: float | None = None,
181
+ idempotency_key: str | None = None,
182
+ ) -> Job:
183
+ """Enqueues the task with custom execution parameters."""
184
+ job = Job(
185
+ task_name=self.name,
186
+ queue=queue or self.queue,
187
+ args=args or [],
188
+ kwargs=kwargs or {},
189
+ priority=priority,
190
+ max_retries=self.max_retries if max_retries is None else max_retries,
191
+ retry_backoff=self.retry_backoff if retry_backoff is None else retry_backoff,
192
+ timeout=self.timeout if timeout is None else timeout,
193
+ idempotency_key=idempotency_key,
194
+ )
195
+
196
+ if delay and delay > 0:
197
+ return await self.broker.schedule_delayed(job, delay_seconds=delay)
198
+ return await self.broker.enqueue(job)
199
+
200
+
201
+ def task(
202
+ name: str | None = None,
203
+ queue: str = "default",
204
+ max_retries: int = 3,
205
+ retry_backoff: float = 2.0,
206
+ timeout: float | None = None,
207
+ broker: RedisBroker | None = None,
208
+ ) -> Callable[[Callable[..., Any]], Task]:
209
+ """Decorator to register a function as a TaskManager background task."""
210
+
211
+ def decorator(fn: Callable[..., Any]) -> Task:
212
+ t = Task(
213
+ func=fn,
214
+ name=name or f"{fn.__module__}.{fn.__name__}",
215
+ queue=queue,
216
+ max_retries=max_retries,
217
+ retry_backoff=retry_backoff,
218
+ timeout=timeout,
219
+ broker=broker,
220
+ )
221
+ registry.register(t)
222
+ return t
223
+
224
+ return decorator
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ import uuid
5
+ from datetime import UTC, datetime
6
+ from enum import Enum
7
+ from typing import Any
8
+
9
+ from croniter import croniter
10
+ from pydantic import BaseModel, Field
11
+
12
+
13
+ class ScheduleType(str, Enum):
14
+ CRON = "cron"
15
+ INTERVAL = "interval"
16
+
17
+
18
+ class Schedule(BaseModel):
19
+ id: str = Field(default_factory=lambda: str(uuid.uuid4()))
20
+ name: str
21
+ task_name: str
22
+ queue: str = "default"
23
+ schedule_type: ScheduleType = ScheduleType.CRON
24
+ cron_expression: str | None = None # e.g. "*/5 * * * *"
25
+ interval_seconds: float | None = None
26
+ args: list[Any] = Field(default_factory=list)
27
+ kwargs: dict[str, Any] = Field(default_factory=dict)
28
+ enabled: bool = True
29
+ last_run: float | None = None
30
+ next_run: float | None = None
31
+ total_runs: int = 0
32
+ created_at: float = Field(default_factory=time.time)
33
+
34
+
35
+ def calculate_next_run(schedule: Schedule, from_timestamp: float | None = None) -> float:
36
+ """Calculates the next unix timestamp for a given Schedule."""
37
+ base_time = from_timestamp if from_timestamp is not None else time.time()
38
+
39
+ if schedule.schedule_type == ScheduleType.CRON:
40
+ if not schedule.cron_expression or not croniter.is_valid(schedule.cron_expression):
41
+ raise ValueError(f"Invalid cron expression: {schedule.cron_expression}")
42
+ base_dt = datetime.fromtimestamp(base_time, tz=UTC).astimezone()
43
+ iter_cron = croniter(schedule.cron_expression, base_dt)
44
+ next_dt = iter_cron.get_next(datetime)
45
+ return next_dt.timestamp()
46
+
47
+ elif schedule.schedule_type == ScheduleType.INTERVAL:
48
+ if not schedule.interval_seconds or schedule.interval_seconds <= 0:
49
+ raise ValueError(f"Invalid interval seconds: {schedule.interval_seconds}")
50
+ return base_time + schedule.interval_seconds
51
+
52
+ raise ValueError(f"Unknown schedule type: {schedule.schedule_type}")
@@ -0,0 +1,178 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import time
6
+
7
+ from taskmanager.config import settings
8
+ from taskmanager.core.broker import RedisBroker
9
+ from taskmanager.core.job import Job
10
+ from taskmanager.scheduler.cron import Schedule, calculate_next_run
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class Scheduler:
16
+ """Dynamic Cron and Interval scheduler utilizing Redis for persistence and distributed locking."""
17
+
18
+ def __init__(self, broker: RedisBroker):
19
+ self.broker = broker
20
+ self._running = False
21
+ self._task: asyncio.Task[None] | None = None
22
+ self._lock_id = f"scheduler-{time.time()}"
23
+
24
+ # --- Schedule CRUD Operations ---
25
+ async def add_schedule(self, schedule: Schedule) -> Schedule:
26
+ """Registers a new schedule and computes initial next_run."""
27
+ if schedule.next_run is None:
28
+ schedule.next_run = calculate_next_run(schedule)
29
+ key = self.broker._key_schedules()
30
+ await self.broker.redis.hset(key, schedule.id, schedule.model_dump_json())
31
+ await self.broker.publish_event("schedule:created", schedule.model_dump())
32
+ return schedule
33
+
34
+ async def get_schedule(self, schedule_id: str) -> Schedule | None:
35
+ """Fetches a schedule by ID."""
36
+ key = self.broker._key_schedules()
37
+ raw = await self.broker.redis.hget(key, schedule_id)
38
+ if not raw:
39
+ return None
40
+ return Schedule.model_validate_json(raw)
41
+
42
+ async def list_schedules(self) -> list[Schedule]:
43
+ """Lists all registered schedules."""
44
+ key = self.broker._key_schedules()
45
+ all_raw = await self.broker.redis.hgetall(key)
46
+ schedules: list[Schedule] = []
47
+ for raw in all_raw.values():
48
+ try:
49
+ schedules.append(Schedule.model_validate_json(raw))
50
+ except Exception:
51
+ pass
52
+ return sorted(schedules, key=lambda s: s.created_at)
53
+
54
+ async def update_schedule(self, schedule: Schedule) -> Schedule:
55
+ """Updates an existing schedule."""
56
+ if schedule.enabled:
57
+ schedule.next_run = calculate_next_run(schedule)
58
+ key = self.broker._key_schedules()
59
+ await self.broker.redis.hset(key, schedule.id, schedule.model_dump_json())
60
+ await self.broker.publish_event("schedule:updated", schedule.model_dump())
61
+ return schedule
62
+
63
+ async def delete_schedule(self, schedule_id: str) -> bool:
64
+ """Deletes a schedule."""
65
+ key = self.broker._key_schedules()
66
+ res = await self.broker.redis.hdel(key, schedule_id)
67
+ if res > 0:
68
+ await self.broker.publish_event("schedule:deleted", {"schedule_id": schedule_id})
69
+ return True
70
+ return False
71
+
72
+ async def toggle_schedule(self, schedule_id: str, enabled: bool) -> Schedule | None:
73
+ """Enables or disables a schedule."""
74
+ schedule = await self.get_schedule(schedule_id)
75
+ if not schedule:
76
+ return None
77
+ schedule.enabled = enabled
78
+ if enabled:
79
+ schedule.next_run = calculate_next_run(schedule)
80
+ else:
81
+ schedule.next_run = None
82
+ return await self.update_schedule(schedule)
83
+
84
+ async def trigger_now(self, schedule_id: str) -> Job | None:
85
+ """Manually triggers a schedule immediately."""
86
+ schedule = await self.get_schedule(schedule_id)
87
+ if not schedule:
88
+ return None
89
+
90
+ job = Job(
91
+ task_name=schedule.task_name,
92
+ queue=schedule.queue,
93
+ args=schedule.args,
94
+ kwargs=schedule.kwargs,
95
+ )
96
+ enqueued_job = await self.broker.enqueue(job)
97
+
98
+ schedule.last_run = time.time()
99
+ schedule.total_runs += 1
100
+ key = self.broker._key_schedules()
101
+ await self.broker.redis.hset(key, schedule.id, schedule.model_dump_json())
102
+ await self.broker.publish_event(
103
+ "schedule:triggered",
104
+ {
105
+ "schedule_id": schedule.id,
106
+ "job_id": enqueued_job.id,
107
+ "manual": True,
108
+ },
109
+ )
110
+ return enqueued_job
111
+
112
+ # --- Scheduler Daemon Loop ---
113
+ async def start(self) -> None:
114
+ """Starts the scheduler polling loop."""
115
+ self._running = True
116
+ logger.info("Scheduler daemon started.")
117
+ try:
118
+ while self._running:
119
+ await self._tick()
120
+ await asyncio.sleep(settings.scheduler_poll_interval)
121
+ except asyncio.CancelledError:
122
+ pass
123
+ finally:
124
+ self._running = False
125
+ logger.info("Scheduler daemon stopped.")
126
+
127
+ async def stop(self) -> None:
128
+ self._running = False
129
+
130
+ async def _tick(self) -> None:
131
+ """Evaluates schedules and enqueues due jobs using distributed locking."""
132
+ lock_key = self.broker._key_lock("scheduler")
133
+ # Attempt to acquire leader lock with 3-second TTL
134
+ acquired = await self.broker.redis.set(lock_key, self._lock_id, nx=True, ex=3)
135
+ if not acquired:
136
+ # Check if this instance already holds the lock
137
+ current_holder = await self.broker.redis.get(lock_key)
138
+ if current_holder != self._lock_id:
139
+ return # Another scheduler instance is the active leader
140
+ # Refresh lock
141
+ await self.broker.redis.expire(lock_key, 3)
142
+
143
+ now = time.time()
144
+ schedules = await self.list_schedules()
145
+
146
+ for schedule in schedules:
147
+ if not schedule.enabled or schedule.next_run is None:
148
+ continue
149
+
150
+ if now >= schedule.next_run:
151
+ # Enqueue the job
152
+ job = Job(
153
+ task_name=schedule.task_name,
154
+ queue=schedule.queue,
155
+ args=schedule.args,
156
+ kwargs=schedule.kwargs,
157
+ )
158
+ await self.broker.enqueue(job)
159
+
160
+ # Advance next run
161
+ schedule.last_run = now
162
+ schedule.total_runs += 1
163
+ try:
164
+ schedule.next_run = calculate_next_run(schedule, from_timestamp=now)
165
+ except Exception as err:
166
+ logger.error(f"Error calculating next run for schedule {schedule.id}: {err}")
167
+ schedule.next_run = now + 60
168
+
169
+ key = self.broker._key_schedules()
170
+ await self.broker.redis.hset(key, schedule.id, schedule.model_dump_json())
171
+ await self.broker.publish_event(
172
+ "schedule:triggered",
173
+ {
174
+ "schedule_id": schedule.id,
175
+ "job_id": job.id,
176
+ "manual": False,
177
+ },
178
+ )