agenthandler 0.92.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.

Potentially problematic release.


This version of agenthandler might be problematic. Click here for more details.

@@ -0,0 +1,471 @@
1
+ """Triggers and scheduling — run pipelines on schedule, webhook, or data event.
2
+
3
+ Three trigger types:
4
+ - Schedule: cron expression or fixed interval
5
+ - Webhook: fires when an HTTP endpoint is called
6
+ - Database watch: polls a SQL query and fires when the result changes or
7
+ crosses a threshold
8
+
9
+ Usage:
10
+ from agenthandler.triggers import Scheduler, WebhookTrigger, DbWatchTrigger
11
+
12
+ scheduler = Scheduler()
13
+
14
+ # Run every 15 minutes
15
+ scheduler.add_interval("check-alerts", pipeline_fn, minutes=15)
16
+
17
+ # Run on cron schedule
18
+ scheduler.add_cron("morning-report", pipeline_fn, cron="0 9 * * *")
19
+
20
+ # Run when webhook is called
21
+ webhook = WebhookTrigger("deploy-notify", pipeline_fn)
22
+
23
+ # Run when query result crosses threshold
24
+ db_watch = DbWatchTrigger(
25
+ "alert-monitor", pipeline_fn, sql_connector,
26
+ query="SELECT count(*) as cnt FROM alerts WHERE seen=false",
27
+ condition=lambda result: result["rows"][0]["cnt"] > 0,
28
+ poll_seconds=60,
29
+ )
30
+
31
+ scheduler.start() # background thread
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import asyncio
37
+ import threading
38
+ import time
39
+ from dataclasses import dataclass
40
+ from datetime import datetime, timezone
41
+ from typing import Any, Callable, Coroutine, Dict, List, Optional
42
+
43
+
44
+ @dataclass
45
+ class TriggerRecord:
46
+ """Record of a trigger firing."""
47
+
48
+ trigger_name: str
49
+ trigger_type: str # "interval", "cron", "webhook", "db_watch"
50
+ fired_at: str = ""
51
+ result: Optional[Dict[str, Any]] = None
52
+ error: Optional[str] = None
53
+
54
+ def __post_init__(self) -> None:
55
+ if not self.fired_at:
56
+ self.fired_at = datetime.now(timezone.utc).isoformat()
57
+
58
+ def to_dict(self) -> Dict[str, Any]:
59
+ d: Dict[str, Any] = {
60
+ "trigger_name": self.trigger_name,
61
+ "trigger_type": self.trigger_type,
62
+ "fired_at": self.fired_at,
63
+ }
64
+ if self.error:
65
+ d["error"] = self.error
66
+ if self.result:
67
+ d["result"] = self.result
68
+ return d
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Cron parser (minimal — handles standard 5-field cron expressions)
73
+ # ---------------------------------------------------------------------------
74
+
75
+
76
+ def _cron_matches(cron_expr: str, dt: datetime) -> bool:
77
+ """Check if a datetime matches a cron expression (minute hour dom month dow)."""
78
+ parts = cron_expr.strip().split()
79
+ if len(parts) != 5:
80
+ raise ValueError(f"Invalid cron expression: {cron_expr} (need 5 fields)")
81
+
82
+ fields = [dt.minute, dt.hour, dt.day, dt.month, dt.weekday()]
83
+ ranges = [
84
+ (0, 59), # minute
85
+ (0, 23), # hour
86
+ (1, 31), # day of month
87
+ (1, 12), # month
88
+ (0, 6), # day of week (0=Monday)
89
+ ]
90
+
91
+ for part, val, (lo, hi) in zip(parts, fields, ranges):
92
+ if not _cron_field_matches(part, val, lo, hi):
93
+ return False
94
+ return True
95
+
96
+
97
+ def _cron_field_matches(field_expr: str, value: int, lo: int, hi: int) -> bool:
98
+ """Check if a single cron field matches a value."""
99
+ if field_expr == "*":
100
+ return True
101
+
102
+ for item in field_expr.split(","):
103
+ # Handle */N
104
+ if item.startswith("*/"):
105
+ step = int(item[2:])
106
+ if value % step == 0:
107
+ return True
108
+ # Handle N-M
109
+ elif "-" in item:
110
+ start, end = item.split("-", 1)
111
+ if int(start) <= value <= int(end):
112
+ return True
113
+ # Handle plain N
114
+ else:
115
+ if int(item) == value:
116
+ return True
117
+
118
+ return False
119
+
120
+
121
+ # ---------------------------------------------------------------------------
122
+ # Trigger entries
123
+ # ---------------------------------------------------------------------------
124
+
125
+
126
+ @dataclass
127
+ class IntervalEntry:
128
+ name: str
129
+ fn: Callable[[], Coroutine[Any, Any, Any]]
130
+ seconds: float
131
+ last_run: float = 0.0
132
+ enabled: bool = True
133
+
134
+
135
+ @dataclass
136
+ class CronEntry:
137
+ name: str
138
+ fn: Callable[[], Coroutine[Any, Any, Any]]
139
+ cron: str
140
+ last_run_minute: int = -1 # track minute to avoid double-firing
141
+ enabled: bool = True
142
+
143
+
144
+ @dataclass
145
+ class DbWatchEntry:
146
+ name: str
147
+ fn: Callable[[], Coroutine[Any, Any, Any]]
148
+ connector: Any # SqlConnector
149
+ query: str
150
+ condition: Callable[[Dict[str, Any]], bool]
151
+ poll_seconds: float = 60.0
152
+ last_poll: float = 0.0
153
+ last_result: Optional[Dict[str, Any]] = None
154
+ enabled: bool = True
155
+
156
+
157
+ # ---------------------------------------------------------------------------
158
+ # Scheduler
159
+ # ---------------------------------------------------------------------------
160
+
161
+
162
+ class Scheduler:
163
+ """Background scheduler for interval and cron triggers.
164
+
165
+ Runs in a daemon thread. Non-blocking.
166
+ """
167
+
168
+ def __init__(self) -> None:
169
+ self._intervals: Dict[str, IntervalEntry] = {}
170
+ self._crons: Dict[str, CronEntry] = {}
171
+ self._db_watches: Dict[str, DbWatchEntry] = {}
172
+ self._history: List[TriggerRecord] = []
173
+ self._lock = threading.Lock()
174
+ self._running = False
175
+ self._thread: Optional[threading.Thread] = None
176
+ self._loop: Optional[asyncio.AbstractEventLoop] = None
177
+
178
+ def add_interval(
179
+ self,
180
+ name: str,
181
+ fn: Callable[[], Coroutine[Any, Any, Any]],
182
+ seconds: float = 0,
183
+ minutes: float = 0,
184
+ hours: float = 0,
185
+ ) -> None:
186
+ """Add an interval trigger.
187
+
188
+ Args:
189
+ name: Unique trigger name.
190
+ fn: Async callable to run (e.g., pipeline.run).
191
+ seconds/minutes/hours: Interval (combined).
192
+ """
193
+ total = seconds + minutes * 60 + hours * 3600
194
+ if total <= 0:
195
+ raise ValueError("Interval must be > 0")
196
+ with self._lock:
197
+ self._intervals[name] = IntervalEntry(name=name, fn=fn, seconds=total)
198
+
199
+ def add_cron(
200
+ self,
201
+ name: str,
202
+ fn: Callable[[], Coroutine[Any, Any, Any]],
203
+ cron: str,
204
+ ) -> None:
205
+ """Add a cron trigger.
206
+
207
+ Args:
208
+ name: Unique trigger name.
209
+ fn: Async callable to run.
210
+ cron: 5-field cron expression (minute hour dom month dow).
211
+ """
212
+ # Validate
213
+ _cron_matches(cron, datetime.now(timezone.utc))
214
+ with self._lock:
215
+ self._crons[name] = CronEntry(name=name, fn=fn, cron=cron)
216
+
217
+ def add_db_watch(
218
+ self,
219
+ name: str,
220
+ fn: Callable[[], Coroutine[Any, Any, Any]],
221
+ connector: Any,
222
+ query: str,
223
+ condition: Callable[[Dict[str, Any]], bool],
224
+ poll_seconds: float = 60.0,
225
+ ) -> None:
226
+ """Add a database watch trigger.
227
+
228
+ Polls the query at poll_seconds interval. Fires fn when
229
+ condition(query_result) returns True.
230
+
231
+ Args:
232
+ name: Unique trigger name.
233
+ fn: Async callable to run when condition fires.
234
+ connector: SqlConnector instance.
235
+ query: SQL query to poll.
236
+ condition: Function that takes query result dict and returns bool.
237
+ poll_seconds: How often to poll.
238
+ """
239
+ with self._lock:
240
+ self._db_watches[name] = DbWatchEntry(
241
+ name=name,
242
+ fn=fn,
243
+ connector=connector,
244
+ query=query,
245
+ condition=condition,
246
+ poll_seconds=poll_seconds,
247
+ )
248
+
249
+ def remove(self, name: str) -> bool:
250
+ """Remove a trigger by name."""
251
+ with self._lock:
252
+ found = False
253
+ if name in self._intervals:
254
+ del self._intervals[name]
255
+ found = True
256
+ if name in self._crons:
257
+ del self._crons[name]
258
+ found = True
259
+ if name in self._db_watches:
260
+ del self._db_watches[name]
261
+ found = True
262
+ return found
263
+
264
+ def enable(self, name: str) -> None:
265
+ """Enable a trigger."""
266
+ with self._lock:
267
+ for registry in (self._intervals, self._crons, self._db_watches):
268
+ if name in registry:
269
+ registry[name].enabled = True
270
+
271
+ def disable(self, name: str) -> None:
272
+ """Disable a trigger without removing it."""
273
+ with self._lock:
274
+ for registry in (self._intervals, self._crons, self._db_watches):
275
+ if name in registry:
276
+ registry[name].enabled = False
277
+
278
+ def list_triggers(self) -> List[Dict[str, Any]]:
279
+ """List all registered triggers."""
280
+ with self._lock:
281
+ result: List[Dict[str, Any]] = []
282
+ for ie in self._intervals.values():
283
+ result.append(
284
+ {
285
+ "name": ie.name,
286
+ "type": "interval",
287
+ "seconds": ie.seconds,
288
+ "enabled": ie.enabled,
289
+ }
290
+ )
291
+ for ce in self._crons.values():
292
+ result.append(
293
+ {
294
+ "name": ce.name,
295
+ "type": "cron",
296
+ "cron": ce.cron,
297
+ "enabled": ce.enabled,
298
+ }
299
+ )
300
+ for de in self._db_watches.values():
301
+ result.append(
302
+ {
303
+ "name": de.name,
304
+ "type": "db_watch",
305
+ "query": de.query,
306
+ "poll_seconds": de.poll_seconds,
307
+ "enabled": de.enabled,
308
+ }
309
+ )
310
+ return result
311
+
312
+ def history(self, limit: int = 50) -> List[Dict[str, Any]]:
313
+ """Get recent trigger firing history."""
314
+ with self._lock:
315
+ return [r.to_dict() for r in self._history[-limit:]]
316
+
317
+ def start(self) -> None:
318
+ """Start the scheduler in a background thread."""
319
+ if self._running:
320
+ return
321
+ self._running = True
322
+ self._loop = asyncio.new_event_loop()
323
+ self._thread = threading.Thread(
324
+ target=self._run_loop, daemon=True, name="agenthandler-scheduler"
325
+ )
326
+ self._thread.start()
327
+
328
+ def stop(self) -> None:
329
+ """Stop the scheduler."""
330
+ self._running = False
331
+ if self._loop:
332
+ self._loop.call_soon_threadsafe(self._loop.stop)
333
+ if self._thread:
334
+ self._thread.join(timeout=5)
335
+ self._thread = None
336
+ self._loop = None
337
+
338
+ def _run_loop(self) -> None:
339
+ """Main scheduler loop."""
340
+ assert self._loop is not None
341
+ asyncio.set_event_loop(self._loop)
342
+ while self._running:
343
+ self._loop.run_until_complete(self._tick())
344
+ time.sleep(1) # 1-second resolution
345
+
346
+ async def _tick(self) -> None:
347
+ """Check all triggers and fire any that are due."""
348
+ now = time.monotonic()
349
+ now_dt = datetime.now(timezone.utc)
350
+
351
+ with self._lock:
352
+ intervals = list(self._intervals.values())
353
+ crons = list(self._crons.values())
354
+ db_watches = list(self._db_watches.values())
355
+
356
+ # Interval triggers
357
+ for ie in intervals:
358
+ if not ie.enabled:
359
+ continue
360
+ if now - ie.last_run >= ie.seconds:
361
+ ie.last_run = now
362
+ await self._fire(ie.name, "interval", ie.fn)
363
+
364
+ # Cron triggers
365
+ current_minute = now_dt.hour * 60 + now_dt.minute
366
+ for ce in crons:
367
+ if not ce.enabled:
368
+ continue
369
+ if _cron_matches(ce.cron, now_dt) and ce.last_run_minute != current_minute:
370
+ ce.last_run_minute = current_minute
371
+ await self._fire(ce.name, "cron", ce.fn)
372
+
373
+ # DB watch triggers
374
+ for de in db_watches:
375
+ if not de.enabled:
376
+ continue
377
+ if now - de.last_poll >= de.poll_seconds:
378
+ de.last_poll = now
379
+ try:
380
+ result = await de.connector.query(sql=de.query)
381
+ if de.condition(result):
382
+ de.last_result = result
383
+ await self._fire(de.name, "db_watch", de.fn)
384
+ except Exception as e:
385
+ self._record(de.name, "db_watch", error=str(e))
386
+
387
+ async def _fire(self, name: str, trigger_type: str, fn: Callable[..., Any]) -> None:
388
+ """Fire a trigger and record the result."""
389
+ try:
390
+ result = await fn()
391
+ result_dict = None
392
+ if hasattr(result, "completed"):
393
+ # PipelineResult
394
+ result_dict = {
395
+ "completed": result.completed,
396
+ "steps": len(result.steps) if hasattr(result, "steps") else 0,
397
+ }
398
+ self._record(name, trigger_type, result=result_dict)
399
+ except Exception as e:
400
+ self._record(name, trigger_type, error=str(e))
401
+
402
+ def _record(
403
+ self,
404
+ name: str,
405
+ trigger_type: str,
406
+ result: Optional[Dict[str, Any]] = None,
407
+ error: Optional[str] = None,
408
+ ) -> None:
409
+ """Record a trigger firing in history."""
410
+ record = TriggerRecord(
411
+ trigger_name=name,
412
+ trigger_type=trigger_type,
413
+ result=result,
414
+ error=error,
415
+ )
416
+ with self._lock:
417
+ self._history.append(record)
418
+ if len(self._history) > 1000:
419
+ self._history = self._history[-500:]
420
+
421
+
422
+ # ---------------------------------------------------------------------------
423
+ # WebhookTrigger — fires when an HTTP endpoint is called
424
+ # ---------------------------------------------------------------------------
425
+
426
+
427
+ class WebhookTrigger:
428
+ """A trigger that fires when its HTTP endpoint is called.
429
+
430
+ Register with the REST server via Scheduler or directly.
431
+
432
+ Usage:
433
+ trigger = WebhookTrigger("deploy", pipeline.run)
434
+ # POST /triggers/deploy → fires pipeline.run()
435
+ """
436
+
437
+ def __init__(
438
+ self,
439
+ name: str,
440
+ fn: Callable[[], Coroutine[Any, Any, Any]],
441
+ ):
442
+ self.name = name
443
+ self.fn = fn
444
+ self._history: List[TriggerRecord] = []
445
+
446
+ async def fire(self, payload: Optional[Dict[str, Any]] = None) -> TriggerRecord:
447
+ """Fire the trigger. Called by the REST endpoint handler."""
448
+ try:
449
+ result = await self.fn()
450
+ result_dict = None
451
+ if hasattr(result, "completed"):
452
+ result_dict = {
453
+ "completed": result.completed,
454
+ "steps": len(result.steps) if hasattr(result, "steps") else 0,
455
+ }
456
+ record = TriggerRecord(
457
+ trigger_name=self.name,
458
+ trigger_type="webhook",
459
+ result=result_dict,
460
+ )
461
+ except Exception as e:
462
+ record = TriggerRecord(
463
+ trigger_name=self.name,
464
+ trigger_type="webhook",
465
+ error=str(e),
466
+ )
467
+ self._history.append(record)
468
+ return record
469
+
470
+ def history(self, limit: int = 50) -> List[Dict[str, Any]]:
471
+ return [r.to_dict() for r in self._history[-limit:]]