shelldeck 0.0.1__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.
Files changed (45) hide show
  1. shelldeck/__init__.py +0 -0
  2. shelldeck/__main__.py +4 -0
  3. shelldeck/addons/__init__.py +1 -0
  4. shelldeck/addons/fast_context.py +1346 -0
  5. shelldeck/auth.py +207 -0
  6. shelldeck/cli.py +587 -0
  7. shelldeck/db.py +794 -0
  8. shelldeck/gitgraph.py +150 -0
  9. shelldeck/integration/bash.sh +27 -0
  10. shelldeck/integration/shelldeck.fish +5 -0
  11. shelldeck/integration/shelldeck.ps1 +35 -0
  12. shelldeck/integration/zsh/.zshenv +6 -0
  13. shelldeck/integration/zsh/.zshrc +10 -0
  14. shelldeck/pty.py +352 -0
  15. shelldeck/runner.py +75 -0
  16. shelldeck/scheduler.py +283 -0
  17. shelldeck/server.py +1216 -0
  18. shelldeck/shells.py +174 -0
  19. shelldeck/static/app.css +2288 -0
  20. shelldeck/static/app.js +1863 -0
  21. shelldeck/static/gitgraph.js +142 -0
  22. shelldeck/static/history.js +172 -0
  23. shelldeck/static/icon-192.png +0 -0
  24. shelldeck/static/icon-32.png +0 -0
  25. shelldeck/static/icon-512.png +0 -0
  26. shelldeck/static/icon.svg +1 -0
  27. shelldeck/static/index.html +102 -0
  28. shelldeck/static/manifest.webmanifest +1 -0
  29. shelldeck/static/monitor.js +143 -0
  30. shelldeck/static/ui.js +316 -0
  31. shelldeck/static/vendor/LICENSE-xterm.txt +21 -0
  32. shelldeck/static/vendor/addon-fit.js +2 -0
  33. shelldeck/static/vendor/addon-search.js +2 -0
  34. shelldeck/static/vendor/addon-serialize.js +2 -0
  35. shelldeck/static/vendor/addon-web-links.js +2 -0
  36. shelldeck/static/vendor/addon-webgl.js +2 -0
  37. shelldeck/static/vendor/xterm.css +218 -0
  38. shelldeck/static/vendor/xterm.js +2 -0
  39. shelldeck/static/views.js +658 -0
  40. shelldeck/stats.py +128 -0
  41. shelldeck-0.0.1.dist-info/METADATA +254 -0
  42. shelldeck-0.0.1.dist-info/RECORD +45 -0
  43. shelldeck-0.0.1.dist-info/WHEEL +4 -0
  44. shelldeck-0.0.1.dist-info/entry_points.txt +3 -0
  45. shelldeck-0.0.1.dist-info/licenses/LICENSE +21 -0
shelldeck/scheduler.py ADDED
@@ -0,0 +1,283 @@
1
+ import asyncio
2
+ import traceback
3
+ from datetime import UTC, datetime, timedelta, timezone
4
+ from zoneinfo import ZoneInfo
5
+
6
+ from apscheduler.schedulers.asyncio import AsyncIOScheduler
7
+ from apscheduler.triggers.cron import CronTrigger
8
+ from apscheduler.triggers.date import DateTrigger
9
+
10
+ from . import db, runner
11
+
12
+ scheduler = AsyncIOScheduler()
13
+ _alarm_callback = None
14
+
15
+
16
+ def set_alarm_callback(cb):
17
+ global _alarm_callback
18
+ _alarm_callback = cb
19
+
20
+
21
+ def _now() -> str:
22
+ return datetime.now(timezone.utc).isoformat()
23
+
24
+
25
+ def _iso(dt: datetime | None) -> str | None:
26
+ return dt.isoformat() if dt else None
27
+
28
+
29
+ async def _broadcast_alarm(task: dict) -> None:
30
+ if _alarm_callback is None:
31
+ return
32
+ try:
33
+ await _alarm_callback(task)
34
+ except Exception:
35
+ pass
36
+
37
+
38
+ async def execute_reminder(task_id: str) -> None:
39
+ task = await asyncio.to_thread(db.get_task, task_id)
40
+ if not task:
41
+ return
42
+ if task.get("board_column") == "done":
43
+ return
44
+ if not task.get("reminder_at"):
45
+ return
46
+ if task.get("reminder_acknowledged"):
47
+ return
48
+ try:
49
+ reminder_dt = datetime.fromisoformat(task["reminder_at"]).astimezone(
50
+ timezone.utc
51
+ )
52
+ except Exception:
53
+ return
54
+ # Allow a small tolerance for scheduler jitter
55
+ if reminder_dt > datetime.now(timezone.utc) + timedelta(seconds=5):
56
+ await ReminderService(scheduler).sync_task(task_id)
57
+ return
58
+ await _broadcast_alarm(task)
59
+
60
+
61
+ class ReminderService:
62
+ def __init__(self, scheduler):
63
+ self.scheduler = scheduler
64
+
65
+ def _job_id(self, task_id: str) -> str:
66
+ return f"shelldeck_reminder_{task_id}"
67
+
68
+ def remove_task(self, task_id: str) -> None:
69
+ try:
70
+ self.scheduler.remove_job(self._job_id(task_id))
71
+ except Exception:
72
+ pass
73
+
74
+ def sync_task(self, task_id: str) -> None:
75
+ self.remove_task(task_id)
76
+ task = db.get_task(task_id)
77
+ if not task or task.get("board_column") == "done":
78
+ return
79
+ reminder_at = task.get("reminder_at")
80
+ if not reminder_at:
81
+ return
82
+ if task.get("reminder_acknowledged"):
83
+ return
84
+ try:
85
+ run_date = datetime.fromisoformat(reminder_at).astimezone(timezone.utc)
86
+ except Exception:
87
+ return
88
+ self.scheduler.add_job(
89
+ execute_reminder,
90
+ trigger=DateTrigger(run_date=run_date, timezone=UTC),
91
+ id=self._job_id(task_id),
92
+ replace_existing=True,
93
+ misfire_grace_time=86400,
94
+ kwargs={"task_id": task_id},
95
+ )
96
+
97
+ async def sync_all(self) -> None:
98
+ for job in list(self.scheduler.get_jobs()):
99
+ if job.id.startswith("shelldeck_reminder_"):
100
+ self.scheduler.remove_job(job.id)
101
+ tasks = await asyncio.to_thread(db.list_tasks)
102
+ for task in tasks:
103
+ self.sync_task(task["id"])
104
+
105
+
106
+ reminder_service = ReminderService(scheduler)
107
+
108
+
109
+ def validate_cron(expression: str) -> CronTrigger:
110
+ parts = expression.split()
111
+ if len(parts) != 5:
112
+ raise ValueError("Cron expression must contain five fields.")
113
+ minute, hour, day, month, weekday = parts
114
+ return CronTrigger(
115
+ minute=minute,
116
+ hour=hour,
117
+ day=day,
118
+ month=month,
119
+ day_of_week=weekday,
120
+ timezone=UTC,
121
+ )
122
+
123
+
124
+ def cron_trigger(cron: str, tz: str = "UTC") -> CronTrigger:
125
+ parts = cron.split()
126
+ if len(parts) != 5:
127
+ raise ValueError("Cron expression must contain five fields.")
128
+ try:
129
+ tzinfo = ZoneInfo(tz)
130
+ except Exception:
131
+ tzinfo = UTC
132
+ minute, hour, day, month, weekday = parts
133
+ return CronTrigger(
134
+ minute=minute,
135
+ hour=hour,
136
+ day=day,
137
+ month=month,
138
+ day_of_week=weekday,
139
+ timezone=tzinfo,
140
+ )
141
+
142
+
143
+ async def execute_job(job_id: str):
144
+ job = await asyncio.to_thread(db.get_job, job_id)
145
+ if not job or not job.get("enabled"):
146
+ return
147
+
148
+ run_at = _now()
149
+ await asyncio.to_thread(
150
+ db.update_job,
151
+ job_id,
152
+ last_status="running",
153
+ last_run_on=run_at,
154
+ )
155
+
156
+ try:
157
+ result = await asyncio.to_thread(
158
+ runner.run_command,
159
+ job["project_id"],
160
+ job["command"],
161
+ None,
162
+ job.get("timeout_seconds", 60),
163
+ )
164
+ except Exception:
165
+ result = {
166
+ "status": "failed",
167
+ "exit_code": None,
168
+ "output": "",
169
+ "error": traceback.format_exc(),
170
+ "duration_ms": 0,
171
+ }
172
+
173
+ status = result["status"]
174
+ duration_ms = result["duration_ms"]
175
+ exit_code = result["exit_code"]
176
+ output = result["output"]
177
+ error = result["error"]
178
+ successful = status == "success"
179
+
180
+ try:
181
+ last_run_dt = datetime.fromisoformat(run_at)
182
+ trigger = cron_trigger(job["cron"], job.get("timezone", "UTC"))
183
+ next_run_dt = trigger.get_next_fire_time(last_run_dt, last_run_dt)
184
+ next_run = _iso(next_run_dt)
185
+ except Exception:
186
+ next_run = _iso(_next_run_time(job_id))
187
+
188
+ await asyncio.to_thread(
189
+ db.update_job_after_run,
190
+ job_id,
191
+ status,
192
+ run_at,
193
+ duration_ms,
194
+ error,
195
+ next_run,
196
+ successful,
197
+ )
198
+ await asyncio.to_thread(
199
+ db.add_job_run,
200
+ job_id,
201
+ run_at,
202
+ status,
203
+ exit_code,
204
+ output,
205
+ error,
206
+ duration_ms,
207
+ )
208
+
209
+
210
+ class SchedulerService:
211
+ def __init__(self):
212
+ self.scheduler = scheduler
213
+
214
+ def aps_job_id(self, db_job_id: str) -> str:
215
+ return f"shelldeck_job_{db_job_id}"
216
+
217
+ def remove(self, db_job_id: str) -> None:
218
+ job_id = self.aps_job_id(db_job_id)
219
+ if self.scheduler.get_job(job_id):
220
+ self.scheduler.remove_job(job_id)
221
+
222
+ def next_run(self, db_job_id: str) -> datetime | None:
223
+ job = self.scheduler.get_job(self.aps_job_id(db_job_id))
224
+ return job.next_run_time if job else None
225
+
226
+ async def sync_job(self, job: dict) -> None:
227
+ db_id = job["id"]
228
+ self.remove(db_id)
229
+
230
+ if not job.get("enabled"):
231
+ await asyncio.to_thread(
232
+ db.update_job,
233
+ db_id,
234
+ next_run_on=None,
235
+ )
236
+ return
237
+
238
+ trigger = cron_trigger(job["cron"], job.get("timezone", "UTC"))
239
+
240
+ self.scheduler.add_job(
241
+ execute_job,
242
+ trigger=trigger,
243
+ id=self.aps_job_id(db_id),
244
+ replace_existing=True,
245
+ kwargs={"job_id": db_id},
246
+ misfire_grace_time=60,
247
+ )
248
+
249
+ now = datetime.now(timezone.utc)
250
+ next_run_dt = trigger.get_next_fire_time(None, now)
251
+ next_run = _iso(next_run_dt)
252
+ await asyncio.to_thread(
253
+ db.update_job,
254
+ db_id,
255
+ next_run_on=next_run,
256
+ )
257
+
258
+ async def sync_all(self) -> None:
259
+ jobs = await asyncio.to_thread(db.list_jobs)
260
+ for job in jobs:
261
+ await self.sync_job(job)
262
+
263
+ def run_now(self, db_job_id: str) -> None:
264
+ self.scheduler.add_job(
265
+ execute_job,
266
+ kwargs={"job_id": db_job_id},
267
+ )
268
+
269
+
270
+ scheduler_service = SchedulerService()
271
+
272
+
273
+ def _next_run_time(db_job_id: str) -> datetime | None:
274
+ return scheduler_service.next_run(db_job_id)
275
+
276
+
277
+ async def start() -> None:
278
+ scheduler.start()
279
+ await scheduler_service.sync_all()
280
+
281
+
282
+ def shutdown() -> None:
283
+ scheduler.shutdown(wait=False)