stage-cli 1.0.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.
Files changed (170) hide show
  1. stage/__init__.py +1 -0
  2. stage/__main__.py +8 -0
  3. stage/banner.py +32 -0
  4. stage/bootstrap/__init__.py +0 -0
  5. stage/bootstrap/openjobs.py +392 -0
  6. stage/classify/__init__.py +29 -0
  7. stage/classify/eligibility.py +115 -0
  8. stage/classify/internship.py +64 -0
  9. stage/classify/role.py +91 -0
  10. stage/classify/scope.py +47 -0
  11. stage/cli/__init__.py +0 -0
  12. stage/cli/app.py +4 -0
  13. stage/cli/commands/__init__.py +8 -0
  14. stage/cli/commands/discovery.py +294 -0
  15. stage/cli/commands/insight.py +494 -0
  16. stage/cli/commands/pipeline.py +337 -0
  17. stage/cli/commands/postings.py +473 -0
  18. stage/cli/commands/schedule.py +171 -0
  19. stage/cli/housekeeping.py +64 -0
  20. stage/cli/logfile.py +56 -0
  21. stage/cli/notify.py +170 -0
  22. stage/cli/options.py +678 -0
  23. stage/cli/render.py +1398 -0
  24. stage/cli/runlock.py +74 -0
  25. stage/cli/schedule.py +702 -0
  26. stage/cli/schedule_state.py +363 -0
  27. stage/cli/selection.py +83 -0
  28. stage/cli/serialize.py +196 -0
  29. stage/companies.py +542 -0
  30. stage/data/companies/a.yaml +1289 -0
  31. stage/data/companies/b.yaml +900 -0
  32. stage/data/companies/c.yaml +1377 -0
  33. stage/data/companies/d.yaml +497 -0
  34. stage/data/companies/e.yaml +519 -0
  35. stage/data/companies/f.yaml +454 -0
  36. stage/data/companies/g.yaml +601 -0
  37. stage/data/companies/h.yaml +446 -0
  38. stage/data/companies/i.yaml +503 -0
  39. stage/data/companies/j.yaml +138 -0
  40. stage/data/companies/k.yaml +278 -0
  41. stage/data/companies/l.yaml +402 -0
  42. stage/data/companies/m.yaml +937 -0
  43. stage/data/companies/n.yaml +549 -0
  44. stage/data/companies/o.yaml +371 -0
  45. stage/data/companies/other.yaml +58 -0
  46. stage/data/companies/p.yaml +825 -0
  47. stage/data/companies/q.yaml +121 -0
  48. stage/data/companies/r.yaml +583 -0
  49. stage/data/companies/s.yaml +1140 -0
  50. stage/data/companies/t.yaml +817 -0
  51. stage/data/companies/u.yaml +196 -0
  52. stage/data/companies/v.yaml +325 -0
  53. stage/data/companies/w.yaml +353 -0
  54. stage/data/companies/x.yaml +67 -0
  55. stage/data/companies/y.yaml +36 -0
  56. stage/data/companies/z.yaml +146 -0
  57. stage/data/fonts/DejaVuSans.LICENSE.txt +99 -0
  58. stage/data/fonts/DejaVuSans.ttf +0 -0
  59. stage/data/lexicon/company_tokens.yaml +228 -0
  60. stage/data/lexicon/eligibility.yaml +455 -0
  61. stage/data/lexicon/inclusive_suffixes.yaml +37 -0
  62. stage/data/lexicon/internship.yaml +187 -0
  63. stage/data/lexicon/language.yaml +226 -0
  64. stage/data/lexicon/locations.yaml +1159 -0
  65. stage/data/lexicon/roles.yaml +2012 -0
  66. stage/data/lexicon/terms.yaml +76 -0
  67. stage/data/lexicon/workday_facets.yaml +27 -0
  68. stage/data/seed_companies.yaml +198 -0
  69. stage/dedup/__init__.py +19 -0
  70. stage/dedup/identity.py +113 -0
  71. stage/dedup/resolve.py +97 -0
  72. stage/domain/__init__.py +244 -0
  73. stage/domain/company.py +49 -0
  74. stage/domain/coverage.py +86 -0
  75. stage/domain/custom_board.py +92 -0
  76. stage/domain/discovery.py +94 -0
  77. stage/domain/enums.py +114 -0
  78. stage/domain/events.py +204 -0
  79. stage/domain/filters.py +27 -0
  80. stage/domain/health.py +169 -0
  81. stage/domain/ids.py +48 -0
  82. stage/domain/job.py +47 -0
  83. stage/domain/matching.py +15 -0
  84. stage/domain/priority.py +34 -0
  85. stage/domain/quarantine.py +39 -0
  86. stage/domain/rate_state.py +78 -0
  87. stage/domain/retention.py +20 -0
  88. stage/domain/rotation.py +46 -0
  89. stage/domain/signals.py +12 -0
  90. stage/domain/sync_run.py +35 -0
  91. stage/domain/text.py +113 -0
  92. stage/domain/validator.py +14 -0
  93. stage/domain/visits.py +60 -0
  94. stage/domain/workday.py +38 -0
  95. stage/http/__init__.py +58 -0
  96. stage/http/breaker.py +53 -0
  97. stage/http/cache.py +44 -0
  98. stage/http/client.py +725 -0
  99. stage/http/profiles.py +101 -0
  100. stage/lexicon.py +370 -0
  101. stage/normalize/__init__.py +16 -0
  102. stage/normalize/language.py +47 -0
  103. stage/normalize/location.py +271 -0
  104. stage/normalize/terms.py +153 -0
  105. stage/normalize/urls.py +122 -0
  106. stage/paths.py +86 -0
  107. stage/py.typed +0 -0
  108. stage/services/__init__.py +0 -0
  109. stage/services/canary.py +120 -0
  110. stage/services/coverage.py +231 -0
  111. stage/services/discover.py +747 -0
  112. stage/services/export.py +274 -0
  113. stage/services/health.py +237 -0
  114. stage/services/maintenance.py +225 -0
  115. stage/services/quarantine.py +20 -0
  116. stage/services/query.py +86 -0
  117. stage/services/sync.py +1257 -0
  118. stage/sources/__init__.py +82 -0
  119. stage/sources/_text.py +79 -0
  120. stage/sources/ashby.py +93 -0
  121. stage/sources/bamboohr.py +80 -0
  122. stage/sources/base.py +225 -0
  123. stage/sources/breezy.py +90 -0
  124. stage/sources/collage.py +60 -0
  125. stage/sources/community_feeds.py +142 -0
  126. stage/sources/curated_markdown.py +289 -0
  127. stage/sources/custom_json.py +610 -0
  128. stage/sources/espresso.py +154 -0
  129. stage/sources/feed.py +44 -0
  130. stage/sources/greenhouse.py +104 -0
  131. stage/sources/jobbank.py +147 -0
  132. stage/sources/jobvite.py +133 -0
  133. stage/sources/lever.py +76 -0
  134. stage/sources/oracle_cloud.py +187 -0
  135. stage/sources/platforms.py +609 -0
  136. stage/sources/quebec_emploi.py +146 -0
  137. stage/sources/recruitee.py +96 -0
  138. stage/sources/simplify.py +110 -0
  139. stage/sources/smartrecruiters.py +216 -0
  140. stage/sources/speedyapply.py +200 -0
  141. stage/sources/themuse.py +157 -0
  142. stage/sources/workable.py +83 -0
  143. stage/sources/workday.py +524 -0
  144. stage/sources/zshah.py +99 -0
  145. stage/storage/__init__.py +29 -0
  146. stage/storage/migrations/0001_initial.sql +239 -0
  147. stage/storage/migrations/__init__.py +135 -0
  148. stage/storage/repository.py +213 -0
  149. stage/storage/search.py +28 -0
  150. stage/storage/sqlite_repo.py +1586 -0
  151. stage/storage/writer.py +249 -0
  152. stage/tui/__init__.py +0 -0
  153. stage/tui/app.py +82 -0
  154. stage/tui/help.py +26 -0
  155. stage/tui/safe.py +21 -0
  156. stage/tui/screens/__init__.py +0 -0
  157. stage/tui/screens/boards.py +186 -0
  158. stage/tui/screens/postings.py +509 -0
  159. stage/tui/screens/review.py +209 -0
  160. stage/tui/screens/splash.py +37 -0
  161. stage/tui/screens/stats.py +124 -0
  162. stage/tui/screens/sync.py +194 -0
  163. stage/tui/state.py +160 -0
  164. stage/tui/theme.tcss +205 -0
  165. stage/tui/widgets/__init__.py +0 -0
  166. stage_cli-1.0.0.dist-info/METADATA +379 -0
  167. stage_cli-1.0.0.dist-info/RECORD +170 -0
  168. stage_cli-1.0.0.dist-info/WHEEL +4 -0
  169. stage_cli-1.0.0.dist-info/entry_points.txt +2 -0
  170. stage_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
stage/cli/schedule.py ADDED
@@ -0,0 +1,702 @@
1
+ import argparse
2
+ import os
3
+ import platform
4
+ import plistlib
5
+ import random
6
+ import re
7
+ import subprocess
8
+ import sys
9
+ import time
10
+ import xml.etree.ElementTree as ElementTree
11
+ from collections.abc import Callable, Sequence
12
+ from dataclasses import dataclass
13
+ from datetime import datetime
14
+ from html import unescape
15
+ from pathlib import Path
16
+ from tempfile import TemporaryDirectory
17
+ from typing import TextIO, cast
18
+
19
+ from stage.cli.logfile import rotate
20
+ from stage.cli.schedule_state import (
21
+ HEARTBEAT_INTERVAL_SECONDS,
22
+ ScheduleStateWriter,
23
+ read_state,
24
+ state_for_status,
25
+ state_path,
26
+ )
27
+ from stage.paths import data_dir
28
+
29
+ SYNC_TIME = "00:00"
30
+ DISCOVER_TIME = "10:30"
31
+ VERIFY_TIME = "11:30"
32
+ SYNC_EVERY_HOURS = 6
33
+ MAX_JITTER_S = 1800
34
+ _SUBSYSTEM_GUI = 2
35
+ _CREATE_NO_WINDOW = 0x08000000
36
+
37
+
38
+ class ScheduleError(Exception):
39
+ pass
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class ScheduledAction:
44
+ key: str
45
+ label: str
46
+ cadence: str
47
+ time: str
48
+ windows_schedule: str
49
+ cli_arguments: tuple[str, ...]
50
+ windows_day: str | None = None
51
+ launchd_weekday: int | None = None
52
+ systemd_day: str | None = None
53
+ repeat_hours: int | None = None
54
+
55
+ @property
56
+ def start_hours(self) -> tuple[int, ...]:
57
+ first = int(self.time[:2])
58
+ if self.repeat_hours is None:
59
+ return (first,)
60
+ return tuple(range(first, 24, self.repeat_hours))
61
+
62
+
63
+ @dataclass(frozen=True, slots=True)
64
+ class ScheduleStatus:
65
+ backend: str
66
+ actions: tuple[tuple[ScheduledAction, bool, str], ...]
67
+ log_dir: Path
68
+ states: tuple[dict[str, object] | None, ...] = ()
69
+ needs_update: tuple[bool, ...] = ()
70
+ stale_interpreter: tuple[str, ...] = ()
71
+
72
+
73
+ _ACTIONS = (
74
+ ScheduledAction(
75
+ key="sync",
76
+ label="Stage Sync",
77
+ cadence=f"every {SYNC_EVERY_HOURS} hours",
78
+ time=SYNC_TIME,
79
+ windows_schedule="DAILY",
80
+ cli_arguments=("sync",),
81
+ repeat_hours=SYNC_EVERY_HOURS,
82
+ ),
83
+ ScheduledAction(
84
+ key="discover",
85
+ label="Stage Discover",
86
+ cadence="weekly on Monday",
87
+ time=DISCOVER_TIME,
88
+ windows_schedule="WEEKLY",
89
+ cli_arguments=("discover", "--unregistered", "--direct-only", "--limit", "40"),
90
+ windows_day="MON",
91
+ launchd_weekday=1,
92
+ systemd_day="Mon",
93
+ ),
94
+ ScheduledAction(
95
+ key="verify",
96
+ label="Stage Verify",
97
+ cadence="weekly on Wednesday",
98
+ time=VERIFY_TIME,
99
+ windows_schedule="WEEKLY",
100
+ cli_arguments=("discover", "--verify"),
101
+ windows_day="WED",
102
+ launchd_weekday=3,
103
+ systemd_day="Wed",
104
+ ),
105
+ )
106
+
107
+
108
+ def action_keys() -> tuple[str, ...]:
109
+ return tuple(action.key for action in _ACTIONS)
110
+
111
+
112
+ def _chosen(keys: Sequence[str] | None) -> tuple[ScheduledAction, ...]:
113
+ if not keys:
114
+ return _ACTIONS
115
+ unknown = sorted(set(keys) - set(action_keys()))
116
+ if unknown:
117
+ raise ScheduleError(
118
+ f"unknown scheduled action(s): {', '.join(unknown)} (known: {', '.join(action_keys())})"
119
+ )
120
+ wanted = set(keys)
121
+ return tuple(action for action in _ACTIONS if action.key in wanted)
122
+
123
+
124
+ def enable(actions: Sequence[str] | None = None) -> ScheduleStatus:
125
+ backend = _backend()
126
+ chosen = _chosen(actions)
127
+ if backend == "windows":
128
+ _enable_windows(chosen)
129
+ elif backend == "macos":
130
+ _enable_macos(chosen)
131
+ else:
132
+ _enable_linux(chosen)
133
+ return status()
134
+
135
+
136
+ def disable(actions: Sequence[str] | None = None) -> ScheduleStatus:
137
+ backend = _backend()
138
+ chosen = _chosen(actions)
139
+ if backend == "windows":
140
+ _disable_windows(chosen)
141
+ elif backend == "macos":
142
+ _disable_macos(chosen)
143
+ else:
144
+ _disable_linux(chosen)
145
+ return status()
146
+
147
+
148
+ def status() -> ScheduleStatus:
149
+ backend = _backend()
150
+ exists, label = {
151
+ "windows": (_windows_exists, "Windows Task Scheduler"),
152
+ "macos": (_macos_exists, "macOS launchd"),
153
+ }.get(backend, (_linux_exists, "Linux systemd user timers"))
154
+ actions = tuple(
155
+ (action, exists(action), _installed_cadence(action, backend)) for action in _ACTIONS
156
+ )
157
+ return ScheduleStatus(
158
+ label,
159
+ actions,
160
+ _log_dir(),
161
+ tuple(state_for_status(read_state(action.key)) for action in _ACTIONS),
162
+ tuple(
163
+ enabled and not _definition_matches(action, backend) for action, enabled, _ in actions
164
+ ),
165
+ tuple(
166
+ _stale_interpreter(action, backend) if enabled else "" for action, enabled, _ in actions
167
+ ),
168
+ )
169
+
170
+
171
+ def _installed_interpreter(action: ScheduledAction, backend: str) -> str:
172
+ if backend == "windows":
173
+ result = _query(("schtasks", "/Query", "/TN", action.label, "/XML"))
174
+ if result.returncode != 0:
175
+ return ""
176
+ command = re.search(r"<Command>([^<]*)</Command>", result.stdout)
177
+ return unescape(command.group(1)) if command else ""
178
+ if backend == "macos":
179
+ try:
180
+ payload = plistlib.loads(_launch_path(action).read_bytes())
181
+ except (OSError, plistlib.InvalidFileException):
182
+ return ""
183
+ arguments = payload.get("ProgramArguments") if isinstance(payload, dict) else None
184
+ return str(arguments[0]) if isinstance(arguments, list) and arguments else ""
185
+ try:
186
+ service = (_systemd_dir() / f"{_systemd_name(action)}.service").read_text(encoding="utf-8")
187
+ except OSError:
188
+ return ""
189
+ match = re.search(r"^ExecStart=\"([^\"]*)\"", service, flags=re.MULTILINE)
190
+ return match.group(1).replace('\\"', '"').replace("\\\\", "\\") if match else ""
191
+
192
+
193
+ def _stale_interpreter(action: ScheduledAction, backend: str) -> str:
194
+ executable = _installed_interpreter(action, backend)
195
+ if not executable or Path(executable).exists():
196
+ return ""
197
+ return executable
198
+
199
+
200
+ def _installed_cadence(action: ScheduledAction, backend: str) -> str:
201
+ if backend == "windows":
202
+ return _windows_installed(action)
203
+ if backend == "macos":
204
+ return _macos_installed(action)
205
+ return _linux_installed(action)
206
+
207
+
208
+ def _windows_installed(action: ScheduledAction) -> str:
209
+ result = _query(("schtasks", "/Query", "/TN", action.label, "/XML"))
210
+ if result.returncode != 0:
211
+ return ""
212
+ body = result.stdout
213
+ match = re.search(r"<Interval>PT(\d+)H</Interval>", body)
214
+ if match:
215
+ return f"every {int(match.group(1))} hours"
216
+ return "daily" if "<ScheduleByDay>" in body else "weekly"
217
+
218
+
219
+ def _macos_installed(action: ScheduledAction) -> str:
220
+ path = _launch_path(action)
221
+ if not path.is_file():
222
+ return ""
223
+ try:
224
+ payload = plistlib.loads(path.read_bytes())
225
+ except (OSError, plistlib.InvalidFileException):
226
+ return ""
227
+ schedule = payload.get("StartCalendarInterval")
228
+ if isinstance(schedule, list) and len(schedule) > 1:
229
+ return f"every {24 // len(schedule)} hours"
230
+ return "weekly" if action.launchd_weekday is not None else "daily"
231
+
232
+
233
+ def _linux_installed(action: ScheduledAction) -> str:
234
+ path = _systemd_dir() / f"{_systemd_name(action)}.timer"
235
+ try:
236
+ body = path.read_text(encoding="utf-8")
237
+ except OSError:
238
+ return ""
239
+ match = re.search(r"OnCalendar=.*?\s(\d{2}(?:,\d{2})*):", body)
240
+ if match:
241
+ slots = len(match.group(1).split(","))
242
+ return f"every {24 // slots} hours" if slots > 1 else "daily"
243
+ return "weekly" if action.systemd_day is not None else "daily"
244
+
245
+
246
+ def _definition_matches(action: ScheduledAction, backend: str) -> bool:
247
+ if backend == "windows":
248
+ result = _query(("schtasks", "/Query", "/TN", action.label, "/XML"))
249
+ if result.returncode != 0:
250
+ return False
251
+ command = re.search(r"<Command>([^<]*)</Command>", result.stdout)
252
+ arguments = re.search(r"<Arguments>([^<]*)</Arguments>", result.stdout)
253
+ expected = _command(action)
254
+ return bool(
255
+ command
256
+ and arguments
257
+ and unescape(command.group(1)) == expected[0]
258
+ and unescape(arguments.group(1)) == subprocess.list2cmdline(expected[1:])
259
+ )
260
+ if backend == "macos":
261
+ try:
262
+ payload = plistlib.loads(_launch_path(action).read_bytes())
263
+ except (OSError, plistlib.InvalidFileException):
264
+ return False
265
+ if not isinstance(payload, dict):
266
+ return False
267
+ log = str(_log_dir() / f"scheduled-{action.key}.log")
268
+ return (
269
+ payload.get("ProgramArguments") == list(_command(action))
270
+ and payload.get("StandardOutPath") == log
271
+ and payload.get("StandardErrorPath") == log
272
+ and payload.get("ProcessType") == "Background"
273
+ )
274
+ try:
275
+ service = (_systemd_dir() / f"{_systemd_name(action)}.service").read_text(encoding="utf-8")
276
+ timer = (_systemd_dir() / f"{_systemd_name(action)}.timer").read_text(encoding="utf-8")
277
+ except OSError:
278
+ return False
279
+ return (service, timer) == _systemd_units(action)
280
+
281
+
282
+ def run_scheduled(
283
+ key: str,
284
+ *,
285
+ jitter_seconds: float | None = None,
286
+ sleeper: Callable[[float], None] = time.sleep,
287
+ ) -> int:
288
+ action = next((candidate for candidate in _ACTIONS if candidate.key == key), None)
289
+ if action is None:
290
+ raise ScheduleError(f"unknown scheduled action: {key}")
291
+ path = _log_dir() / f"scheduled-{action.key}.log"
292
+ rotate(path)
293
+ state = ScheduleStateWriter.start(action.key, path, destination=state_path(action.key))
294
+ delay = _jitter_seconds(action) if jitter_seconds is None else jitter_seconds
295
+ with path.open("a", encoding="utf-8", buffering=1) as stream:
296
+ stream.write(f"\n{action.label}\nTriggered: {_timestamp()}\n")
297
+ state.waiting(delay)
298
+ if delay:
299
+ stream.write(f"Waiting {round(delay)}s before work starts.\n")
300
+ _wait_for_start(delay, sleeper, state.heartbeat)
301
+ phase = "syncing" if action.key == "sync" else "discovering"
302
+ state.started(phase)
303
+ stream.write(f"Work started: {_timestamp()}\n")
304
+ sync_exit = _invoke_cli(
305
+ action.cli_arguments,
306
+ stream,
307
+ progress_path=state.path,
308
+ heartbeat=state.heartbeat,
309
+ )
310
+ doctor_exit: int | None = None
311
+ exit_code = sync_exit
312
+ if action.key == "sync" and not state.path_is_blocked:
313
+ state.checking()
314
+ stream.write("Running health check.\n")
315
+ doctor_exit = _invoke_cli(("doctor",), stream, heartbeat=state.heartbeat)
316
+ if doctor_exit:
317
+ exit_code = doctor_exit
318
+ state.complete(
319
+ sync_exit_code=sync_exit,
320
+ doctor_exit_code=doctor_exit,
321
+ exit_code=exit_code,
322
+ )
323
+ stream.write(f"Finished: {_timestamp()}\n")
324
+ stream.write(f"exit code: {exit_code}\n")
325
+ return exit_code
326
+
327
+
328
+ def _spread_load(action: ScheduledAction, sleeper: Callable[[float], None] = time.sleep) -> None:
329
+ if action.repeat_hours is not None:
330
+ sleeper(_jitter_seconds(action))
331
+
332
+
333
+ def _jitter_seconds(action: ScheduledAction) -> float:
334
+ if action.repeat_hours is None:
335
+ return 0.0
336
+ return random.SystemRandom().uniform(0.0, float(MAX_JITTER_S))
337
+
338
+
339
+ def _wait_for_start(
340
+ delay_seconds: float,
341
+ sleeper: Callable[[float], None],
342
+ heartbeat: Callable[[], None],
343
+ ) -> None:
344
+ remaining = delay_seconds
345
+ while remaining > 0:
346
+ interval = min(HEARTBEAT_INTERVAL_SECONDS, remaining)
347
+ sleeper(interval)
348
+ remaining -= interval
349
+ heartbeat()
350
+
351
+
352
+ def _timestamp() -> str:
353
+ return datetime.now().astimezone().isoformat(timespec="seconds")
354
+
355
+
356
+ def main() -> None:
357
+ parser = argparse.ArgumentParser(description="Run a Stage scheduled action.")
358
+ commands = parser.add_subparsers(dest="command", required=True)
359
+ run = commands.add_parser("run", help="run one configured action")
360
+ run.add_argument("action", choices=[action.key for action in _ACTIONS])
361
+ arguments = parser.parse_args()
362
+ raise SystemExit(run_scheduled(arguments.action))
363
+
364
+
365
+ def _backend() -> str:
366
+ name = platform.system()
367
+ if name == "Windows":
368
+ return "windows"
369
+ if name == "Darwin":
370
+ return "macos"
371
+ if name == "Linux":
372
+ return "linux"
373
+ raise ScheduleError(f"automatic scheduling is not supported on {name}")
374
+
375
+
376
+ def _is_windowless(candidate: Path) -> bool:
377
+ try:
378
+ header = candidate.read_bytes()[:1024]
379
+ except OSError:
380
+ return False
381
+ if len(header) < 0x40 or header[:2] != b"MZ":
382
+ return False
383
+ offset = int.from_bytes(header[0x3C:0x40], "little")
384
+ if offset + 0x5E > len(header) or header[offset : offset + 4] != b"PE\0\0":
385
+ return False
386
+ return int.from_bytes(header[offset + 0x5C : offset + 0x5E], "little") == _SUBSYSTEM_GUI
387
+
388
+
389
+ def _windowless_python() -> str | None:
390
+ seen: set[Path] = set()
391
+ for parent in (Path(sys.executable).parent, Path(sys.base_prefix), Path(sys.prefix)):
392
+ for candidate in (parent / "pythonw.exe", parent / "Scripts" / "pythonw.exe"):
393
+ if candidate in seen:
394
+ continue
395
+ seen.add(candidate)
396
+ if candidate.is_file() and _is_windowless(candidate):
397
+ return str(candidate)
398
+ return None
399
+
400
+
401
+ def _command(action: ScheduledAction) -> tuple[str, ...]:
402
+ executable = sys.executable
403
+ if _backend() == "windows":
404
+ executable = _windowless_python() or executable
405
+ return (executable, "-m", "stage.cli.schedule", "run", action.key)
406
+
407
+
408
+ def _log_dir() -> Path:
409
+ path = data_dir() / "logs"
410
+ path.mkdir(parents=True, exist_ok=True)
411
+ return path
412
+
413
+
414
+ def _invoke_cli(
415
+ arguments: Sequence[str],
416
+ stream: TextIO,
417
+ *,
418
+ progress_path: Path | None = None,
419
+ heartbeat: Callable[[], None] | None = None,
420
+ ) -> int:
421
+ command = [sys.executable, "-u", "-m", "stage", *arguments]
422
+ if progress_path is not None:
423
+ command.extend(("--scheduled-progress", str(progress_path)))
424
+ try:
425
+ process = subprocess.Popen(
426
+ command,
427
+ stdin=subprocess.DEVNULL,
428
+ stdout=stream,
429
+ stderr=subprocess.STDOUT,
430
+ creationflags=_CREATE_NO_WINDOW if _backend() == "windows" else 0,
431
+ )
432
+ except OSError as error:
433
+ raise ScheduleError(f"unable to start scheduled command: {command[0]}") from error
434
+ while True:
435
+ try:
436
+ return int(process.wait(timeout=HEARTBEAT_INTERVAL_SECONDS))
437
+ except subprocess.TimeoutExpired:
438
+ if heartbeat is not None:
439
+ heartbeat()
440
+
441
+
442
+ def _execute(command: Sequence[str]) -> subprocess.CompletedProcess[str]:
443
+ try:
444
+ return subprocess.run(command, check=False, capture_output=True, text=True)
445
+ except FileNotFoundError as error:
446
+ raise ScheduleError(f"required scheduler command is unavailable: {command[0]}") from error
447
+
448
+
449
+ def _query(command: Sequence[str]) -> subprocess.CompletedProcess[str]:
450
+ try:
451
+ return _execute(command)
452
+ except ScheduleError:
453
+ return subprocess.CompletedProcess(command, 1, "", "")
454
+
455
+
456
+ def _run(command: Sequence[str]) -> None:
457
+ completed = _execute(command)
458
+ if completed.returncode == 0:
459
+ return
460
+ detail = (completed.stderr or completed.stdout).strip()
461
+ raise ScheduleError(
462
+ f"scheduler command failed: {' '.join(command)}{': ' + detail if detail else ''}"
463
+ )
464
+
465
+
466
+ def _windows_exists(action: ScheduledAction) -> bool:
467
+ return _query(("schtasks", "/Query", "/TN", action.label)).returncode == 0
468
+
469
+
470
+ def _enable_windows(actions: Sequence[ScheduledAction] = _ACTIONS) -> None:
471
+ for action in actions:
472
+ _write_windows_task(action)
473
+
474
+
475
+ def _write_windows_task(action: ScheduledAction) -> None:
476
+ with TemporaryDirectory(prefix="stage-schedule-") as directory:
477
+ path = Path(directory) / f"{action.key}.xml"
478
+ path.write_bytes(_windows_task_xml(action))
479
+ _run(("schtasks", "/Create", "/F", "/TN", action.label, "/XML", str(path)))
480
+
481
+
482
+ _WINDOWS_DAYS = {
483
+ "MON": "Monday",
484
+ "TUE": "Tuesday",
485
+ "WED": "Wednesday",
486
+ "THU": "Thursday",
487
+ "FRI": "Friday",
488
+ "SAT": "Saturday",
489
+ "SUN": "Sunday",
490
+ }
491
+
492
+
493
+ def _windows_task_xml(action: ScheduledAction) -> bytes:
494
+ root = ElementTree.Element(
495
+ "Task", {"version": "1.4", "xmlns": "http://schemas.microsoft.com/windows/2004/02/mit/task"}
496
+ )
497
+ registration = ElementTree.SubElement(root, "RegistrationInfo")
498
+ ElementTree.SubElement(registration, "Description").text = f"{action.label}"
499
+ triggers = ElementTree.SubElement(root, "Triggers")
500
+ trigger = ElementTree.SubElement(triggers, "CalendarTrigger")
501
+ ElementTree.SubElement(trigger, "StartBoundary").text = _windows_start_boundary(action)
502
+ ElementTree.SubElement(trigger, "Enabled").text = "true"
503
+ if action.windows_day is None:
504
+ schedule = ElementTree.SubElement(trigger, "ScheduleByDay")
505
+ ElementTree.SubElement(schedule, "DaysInterval").text = "1"
506
+ if action.repeat_hours is not None:
507
+ repetition = ElementTree.SubElement(trigger, "Repetition")
508
+ ElementTree.SubElement(repetition, "Interval").text = f"PT{action.repeat_hours}H"
509
+ ElementTree.SubElement(repetition, "Duration").text = "P1D"
510
+ ElementTree.SubElement(repetition, "StopAtDurationEnd").text = "false"
511
+ else:
512
+ schedule = ElementTree.SubElement(trigger, "ScheduleByWeek")
513
+ ElementTree.SubElement(schedule, "WeeksInterval").text = "1"
514
+ days = ElementTree.SubElement(schedule, "DaysOfWeek")
515
+ ElementTree.SubElement(days, _WINDOWS_DAYS.get(action.windows_day or "MON", "Monday"))
516
+ principals = ElementTree.SubElement(root, "Principals")
517
+ principal = ElementTree.SubElement(principals, "Principal", {"id": "Stage"})
518
+ ElementTree.SubElement(principal, "LogonType").text = "InteractiveToken"
519
+ ElementTree.SubElement(principal, "RunLevel").text = "LeastPrivilege"
520
+ settings = ElementTree.SubElement(root, "Settings")
521
+ for name, value in (
522
+ ("MultipleInstancesPolicy", "IgnoreNew"),
523
+ ("DisallowStartIfOnBatteries", "false"),
524
+ ("StopIfGoingOnBatteries", "false"),
525
+ ("AllowHardTerminate", "true"),
526
+ ("StartWhenAvailable", "true"),
527
+ ("RunOnlyIfNetworkAvailable", "false"),
528
+ ("AllowStartOnDemand", "true"),
529
+ ("Enabled", "true"),
530
+ ("Hidden", "true"),
531
+ ("RunOnlyIfIdle", "false"),
532
+ ("WakeToRun", "false"),
533
+ ("ExecutionTimeLimit", "PT2H"),
534
+ ("Priority", "7"),
535
+ ):
536
+ ElementTree.SubElement(settings, name).text = value
537
+ actions = ElementTree.SubElement(root, "Actions", {"Context": "Stage"})
538
+ execute = ElementTree.SubElement(actions, "Exec")
539
+ command = _command(action)
540
+ ElementTree.SubElement(execute, "Command").text = command[0]
541
+ ElementTree.SubElement(execute, "Arguments").text = subprocess.list2cmdline(command[1:])
542
+ return cast(bytes, ElementTree.tostring(root, encoding="utf-16", xml_declaration=True))
543
+
544
+
545
+ def _windows_start_boundary(action: ScheduledAction) -> str:
546
+ local = datetime.now().astimezone()
547
+ return f"{local.date().isoformat()}T{action.time}:00"
548
+
549
+
550
+ def _disable_windows(actions: Sequence[ScheduledAction] = _ACTIONS) -> None:
551
+ for action in actions:
552
+ _execute(("schtasks", "/Delete", "/TN", action.label, "/F"))
553
+
554
+
555
+ def _launch_agent_dir() -> Path:
556
+ return Path.home() / "Library" / "LaunchAgents"
557
+
558
+
559
+ def _launch_label(action: ScheduledAction) -> str:
560
+ return f"com.stage.{action.key}"
561
+
562
+
563
+ def _launch_path(action: ScheduledAction) -> Path:
564
+ return _launch_agent_dir() / f"{_launch_label(action)}.plist"
565
+
566
+
567
+ def _write_launch_agent(action: ScheduledAction) -> Path:
568
+ minute = int(action.time[3:])
569
+ schedule: list[dict[str, int]] = [
570
+ {"Hour": hour, "Minute": minute} for hour in action.start_hours
571
+ ]
572
+ if action.launchd_weekday is not None:
573
+ for entry in schedule:
574
+ entry["Weekday"] = action.launchd_weekday
575
+ payload: dict[str, object] = {
576
+ "Label": _launch_label(action),
577
+ "ProgramArguments": list(_command(action)),
578
+ "StartCalendarInterval": schedule if len(schedule) > 1 else schedule[0],
579
+ "StandardOutPath": str(_log_dir() / f"scheduled-{action.key}.log"),
580
+ "StandardErrorPath": str(_log_dir() / f"scheduled-{action.key}.log"),
581
+ "ProcessType": "Background",
582
+ }
583
+ path = _launch_path(action)
584
+ path.parent.mkdir(parents=True, exist_ok=True)
585
+ path.write_bytes(plistlib.dumps(payload, sort_keys=False))
586
+ path.chmod(0o600)
587
+ return path
588
+
589
+
590
+ def _launch_domain() -> str:
591
+ getuid = getattr(os, "getuid", None)
592
+ if not callable(getuid):
593
+ raise ScheduleError("launchd scheduling is unavailable without a macOS user ID")
594
+ return f"gui/{getuid()}"
595
+
596
+
597
+ def _macos_exists(action: ScheduledAction) -> bool:
598
+ if not _launch_path(action).is_file():
599
+ return False
600
+ return (
601
+ _query(("launchctl", "print", f"{_launch_domain()}/{_launch_label(action)}")).returncode
602
+ == 0
603
+ )
604
+
605
+
606
+ def _enable_macos(actions: Sequence[ScheduledAction] = _ACTIONS) -> None:
607
+ paths = {action: _write_launch_agent(action) for action in actions}
608
+ active: list[ScheduledAction] = []
609
+ try:
610
+ for action in actions:
611
+ _execute(("launchctl", "bootout", f"{_launch_domain()}/{_launch_label(action)}"))
612
+ _run(("launchctl", "bootstrap", _launch_domain(), str(paths[action])))
613
+ active.append(action)
614
+ except ScheduleError:
615
+ for action in active:
616
+ _execute(("launchctl", "bootout", f"{_launch_domain()}/{_launch_label(action)}"))
617
+ raise
618
+
619
+
620
+ def _disable_macos(actions: Sequence[ScheduledAction] = _ACTIONS) -> None:
621
+ for action in actions:
622
+ _execute(("launchctl", "bootout", f"{_launch_domain()}/{_launch_label(action)}"))
623
+ _launch_path(action).unlink(missing_ok=True)
624
+
625
+
626
+ def _systemd_dir() -> Path:
627
+ root = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
628
+ return root / "systemd" / "user"
629
+
630
+
631
+ def _systemd_name(action: ScheduledAction) -> str:
632
+ return f"stage-{action.key}"
633
+
634
+
635
+ def _systemd_argument(value: str) -> str:
636
+ return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
637
+
638
+
639
+ def _systemd_units(action: ScheduledAction) -> tuple[str, str]:
640
+ name = _systemd_name(action)
641
+ command = " ".join(_systemd_argument(value) for value in _command(action))
642
+ service = (
643
+ f"[Unit]\nDescription={action.label}\n\n[Service]\nType=oneshot\nExecStart={command}\n"
644
+ )
645
+ hours = ",".join(f"{hour:02d}" for hour in action.start_hours)
646
+ calendar = f"*-*-* {hours}:{action.time[3:]}:00"
647
+ if action.systemd_day is not None:
648
+ calendar = f"{action.systemd_day} {calendar}"
649
+ timer = (
650
+ "[Unit]\n"
651
+ f"Description={action.label} timer\n\n"
652
+ "[Timer]\n"
653
+ f"OnCalendar={calendar}\n"
654
+ "Persistent=true\n"
655
+ f"Unit={name}.service\n\n"
656
+ "[Install]\n"
657
+ "WantedBy=timers.target\n"
658
+ )
659
+ return service, timer
660
+
661
+
662
+ def _write_systemd_units(action: ScheduledAction) -> None:
663
+ root = _systemd_dir()
664
+ root.mkdir(parents=True, exist_ok=True)
665
+ name = _systemd_name(action)
666
+ service, timer = _systemd_units(action)
667
+ for path, body in ((root / f"{name}.service", service), (root / f"{name}.timer", timer)):
668
+ path.write_text(body, encoding="utf-8")
669
+ path.chmod(0o600)
670
+
671
+
672
+ def _linux_exists(action: ScheduledAction) -> bool:
673
+ name = f"{_systemd_name(action)}.timer"
674
+ return _query(("systemctl", "--user", "is-enabled", name)).returncode == 0
675
+
676
+
677
+ def _enable_linux(actions: Sequence[ScheduledAction] = _ACTIONS) -> None:
678
+ for action in actions:
679
+ _write_systemd_units(action)
680
+ _run(("systemctl", "--user", "daemon-reload"))
681
+ active: list[ScheduledAction] = []
682
+ try:
683
+ for action in actions:
684
+ _run(("systemctl", "--user", "enable", "--now", f"{_systemd_name(action)}.timer"))
685
+ active.append(action)
686
+ except ScheduleError:
687
+ for action in active:
688
+ _execute(("systemctl", "--user", "disable", "--now", f"{_systemd_name(action)}.timer"))
689
+ raise
690
+
691
+
692
+ def _disable_linux(actions: Sequence[ScheduledAction] = _ACTIONS) -> None:
693
+ for action in actions:
694
+ name = _systemd_name(action)
695
+ _execute(("systemctl", "--user", "disable", "--now", f"{name}.timer"))
696
+ for suffix in ("service", "timer"):
697
+ (_systemd_dir() / f"{name}.{suffix}").unlink(missing_ok=True)
698
+ _run(("systemctl", "--user", "daemon-reload"))
699
+
700
+
701
+ if __name__ == "__main__":
702
+ main()