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.
- stage/__init__.py +1 -0
- stage/__main__.py +8 -0
- stage/banner.py +32 -0
- stage/bootstrap/__init__.py +0 -0
- stage/bootstrap/openjobs.py +392 -0
- stage/classify/__init__.py +29 -0
- stage/classify/eligibility.py +115 -0
- stage/classify/internship.py +64 -0
- stage/classify/role.py +91 -0
- stage/classify/scope.py +47 -0
- stage/cli/__init__.py +0 -0
- stage/cli/app.py +4 -0
- stage/cli/commands/__init__.py +8 -0
- stage/cli/commands/discovery.py +294 -0
- stage/cli/commands/insight.py +494 -0
- stage/cli/commands/pipeline.py +337 -0
- stage/cli/commands/postings.py +473 -0
- stage/cli/commands/schedule.py +171 -0
- stage/cli/housekeeping.py +64 -0
- stage/cli/logfile.py +56 -0
- stage/cli/notify.py +170 -0
- stage/cli/options.py +678 -0
- stage/cli/render.py +1398 -0
- stage/cli/runlock.py +74 -0
- stage/cli/schedule.py +702 -0
- stage/cli/schedule_state.py +363 -0
- stage/cli/selection.py +83 -0
- stage/cli/serialize.py +196 -0
- stage/companies.py +542 -0
- stage/data/companies/a.yaml +1289 -0
- stage/data/companies/b.yaml +900 -0
- stage/data/companies/c.yaml +1377 -0
- stage/data/companies/d.yaml +497 -0
- stage/data/companies/e.yaml +519 -0
- stage/data/companies/f.yaml +454 -0
- stage/data/companies/g.yaml +601 -0
- stage/data/companies/h.yaml +446 -0
- stage/data/companies/i.yaml +503 -0
- stage/data/companies/j.yaml +138 -0
- stage/data/companies/k.yaml +278 -0
- stage/data/companies/l.yaml +402 -0
- stage/data/companies/m.yaml +937 -0
- stage/data/companies/n.yaml +549 -0
- stage/data/companies/o.yaml +371 -0
- stage/data/companies/other.yaml +58 -0
- stage/data/companies/p.yaml +825 -0
- stage/data/companies/q.yaml +121 -0
- stage/data/companies/r.yaml +583 -0
- stage/data/companies/s.yaml +1140 -0
- stage/data/companies/t.yaml +817 -0
- stage/data/companies/u.yaml +196 -0
- stage/data/companies/v.yaml +325 -0
- stage/data/companies/w.yaml +353 -0
- stage/data/companies/x.yaml +67 -0
- stage/data/companies/y.yaml +36 -0
- stage/data/companies/z.yaml +146 -0
- stage/data/fonts/DejaVuSans.LICENSE.txt +99 -0
- stage/data/fonts/DejaVuSans.ttf +0 -0
- stage/data/lexicon/company_tokens.yaml +228 -0
- stage/data/lexicon/eligibility.yaml +455 -0
- stage/data/lexicon/inclusive_suffixes.yaml +37 -0
- stage/data/lexicon/internship.yaml +187 -0
- stage/data/lexicon/language.yaml +226 -0
- stage/data/lexicon/locations.yaml +1159 -0
- stage/data/lexicon/roles.yaml +2012 -0
- stage/data/lexicon/terms.yaml +76 -0
- stage/data/lexicon/workday_facets.yaml +27 -0
- stage/data/seed_companies.yaml +198 -0
- stage/dedup/__init__.py +19 -0
- stage/dedup/identity.py +113 -0
- stage/dedup/resolve.py +97 -0
- stage/domain/__init__.py +244 -0
- stage/domain/company.py +49 -0
- stage/domain/coverage.py +86 -0
- stage/domain/custom_board.py +92 -0
- stage/domain/discovery.py +94 -0
- stage/domain/enums.py +114 -0
- stage/domain/events.py +204 -0
- stage/domain/filters.py +27 -0
- stage/domain/health.py +169 -0
- stage/domain/ids.py +48 -0
- stage/domain/job.py +47 -0
- stage/domain/matching.py +15 -0
- stage/domain/priority.py +34 -0
- stage/domain/quarantine.py +39 -0
- stage/domain/rate_state.py +78 -0
- stage/domain/retention.py +20 -0
- stage/domain/rotation.py +46 -0
- stage/domain/signals.py +12 -0
- stage/domain/sync_run.py +35 -0
- stage/domain/text.py +113 -0
- stage/domain/validator.py +14 -0
- stage/domain/visits.py +60 -0
- stage/domain/workday.py +38 -0
- stage/http/__init__.py +58 -0
- stage/http/breaker.py +53 -0
- stage/http/cache.py +44 -0
- stage/http/client.py +725 -0
- stage/http/profiles.py +101 -0
- stage/lexicon.py +370 -0
- stage/normalize/__init__.py +16 -0
- stage/normalize/language.py +47 -0
- stage/normalize/location.py +271 -0
- stage/normalize/terms.py +153 -0
- stage/normalize/urls.py +122 -0
- stage/paths.py +86 -0
- stage/py.typed +0 -0
- stage/services/__init__.py +0 -0
- stage/services/canary.py +120 -0
- stage/services/coverage.py +231 -0
- stage/services/discover.py +747 -0
- stage/services/export.py +274 -0
- stage/services/health.py +237 -0
- stage/services/maintenance.py +225 -0
- stage/services/quarantine.py +20 -0
- stage/services/query.py +86 -0
- stage/services/sync.py +1257 -0
- stage/sources/__init__.py +82 -0
- stage/sources/_text.py +79 -0
- stage/sources/ashby.py +93 -0
- stage/sources/bamboohr.py +80 -0
- stage/sources/base.py +225 -0
- stage/sources/breezy.py +90 -0
- stage/sources/collage.py +60 -0
- stage/sources/community_feeds.py +142 -0
- stage/sources/curated_markdown.py +289 -0
- stage/sources/custom_json.py +610 -0
- stage/sources/espresso.py +154 -0
- stage/sources/feed.py +44 -0
- stage/sources/greenhouse.py +104 -0
- stage/sources/jobbank.py +147 -0
- stage/sources/jobvite.py +133 -0
- stage/sources/lever.py +76 -0
- stage/sources/oracle_cloud.py +187 -0
- stage/sources/platforms.py +609 -0
- stage/sources/quebec_emploi.py +146 -0
- stage/sources/recruitee.py +96 -0
- stage/sources/simplify.py +110 -0
- stage/sources/smartrecruiters.py +216 -0
- stage/sources/speedyapply.py +200 -0
- stage/sources/themuse.py +157 -0
- stage/sources/workable.py +83 -0
- stage/sources/workday.py +524 -0
- stage/sources/zshah.py +99 -0
- stage/storage/__init__.py +29 -0
- stage/storage/migrations/0001_initial.sql +239 -0
- stage/storage/migrations/__init__.py +135 -0
- stage/storage/repository.py +213 -0
- stage/storage/search.py +28 -0
- stage/storage/sqlite_repo.py +1586 -0
- stage/storage/writer.py +249 -0
- stage/tui/__init__.py +0 -0
- stage/tui/app.py +82 -0
- stage/tui/help.py +26 -0
- stage/tui/safe.py +21 -0
- stage/tui/screens/__init__.py +0 -0
- stage/tui/screens/boards.py +186 -0
- stage/tui/screens/postings.py +509 -0
- stage/tui/screens/review.py +209 -0
- stage/tui/screens/splash.py +37 -0
- stage/tui/screens/stats.py +124 -0
- stage/tui/screens/sync.py +194 -0
- stage/tui/state.py +160 -0
- stage/tui/theme.tcss +205 -0
- stage/tui/widgets/__init__.py +0 -0
- stage_cli-1.0.0.dist-info/METADATA +379 -0
- stage_cli-1.0.0.dist-info/RECORD +170 -0
- stage_cli-1.0.0.dist-info/WHEEL +4 -0
- stage_cli-1.0.0.dist-info/entry_points.txt +2 -0
- 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()
|