android-watcher 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.
- android_watcher/__init__.py +10 -0
- android_watcher/catalog/__init__.py +32 -0
- android_watcher/catalog/catalog.toml +531 -0
- android_watcher/cli.py +161 -0
- android_watcher/config.py +262 -0
- android_watcher/detect/__init__.py +1 -0
- android_watcher/detect/_normalize.py +192 -0
- android_watcher/detect/android_sitemap.py +540 -0
- android_watcher/detect/base.py +14 -0
- android_watcher/detect/content.py +99 -0
- android_watcher/detect/feed.py +135 -0
- android_watcher/detect/sitemap.py +203 -0
- android_watcher/doctor.py +125 -0
- android_watcher/fetch.py +162 -0
- android_watcher/group.py +79 -0
- android_watcher/lock.py +32 -0
- android_watcher/models.py +156 -0
- android_watcher/notify/__init__.py +1 -0
- android_watcher/notify/base.py +21 -0
- android_watcher/notify/email.py +52 -0
- android_watcher/notify/html.py +114 -0
- android_watcher/notify/render.py +239 -0
- android_watcher/notify/slack.py +124 -0
- android_watcher/notify/telegram.py +46 -0
- android_watcher/rank.py +84 -0
- android_watcher/registry.py +38 -0
- android_watcher/run.py +283 -0
- android_watcher/schedule.py +488 -0
- android_watcher/seed/__init__.py +45 -0
- android_watcher/seed/seed.sql.gz +0 -0
- android_watcher/store.py +492 -0
- android_watcher/triage/__init__.py +1 -0
- android_watcher/triage/base.py +25 -0
- android_watcher/triage/claude_cli.py +185 -0
- android_watcher/triage/noop.py +24 -0
- android_watcher/tui/__init__.py +1 -0
- android_watcher/tui/app.py +163 -0
- android_watcher/tui/configio.py +215 -0
- android_watcher/tui/screens.py +927 -0
- android_watcher-1.0.0.dist-info/METADATA +310 -0
- android_watcher-1.0.0.dist-info/RECORD +44 -0
- android_watcher-1.0.0.dist-info/WHEEL +4 -0
- android_watcher-1.0.0.dist-info/entry_points.txt +2 -0
- android_watcher-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
"""Schedule text generators and native scheduler integration.
|
|
2
|
+
|
|
3
|
+
This module has two layers:
|
|
4
|
+
1. Pure generators: render_plist, render_timer, render_service, render_crontab
|
|
5
|
+
— input ScheduleConfig -> output str; no subprocess calls.
|
|
6
|
+
2. Side-effecting install/remove/status: install_schedule, remove_schedule,
|
|
7
|
+
schedule_status — detect platform, write unit files, and call the loader.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import plistlib
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from android_watcher.config import Config, ScheduleConfig
|
|
21
|
+
from android_watcher.models import Check
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"CRON_BEGIN",
|
|
25
|
+
"CRON_END",
|
|
26
|
+
"LAUNCHD_LABEL",
|
|
27
|
+
"SYSTEMD_UNIT_NAME",
|
|
28
|
+
"ScheduleError",
|
|
29
|
+
"_calendar_intervals",
|
|
30
|
+
"_on_calendar",
|
|
31
|
+
"install_schedule",
|
|
32
|
+
"remove_schedule",
|
|
33
|
+
"render_crontab",
|
|
34
|
+
"render_plist",
|
|
35
|
+
"render_service",
|
|
36
|
+
"render_timer",
|
|
37
|
+
"schedule_status",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
LAUNCHD_LABEL = "com.krayong.android-watcher"
|
|
41
|
+
SYSTEMD_UNIT_NAME = "android-watcher"
|
|
42
|
+
CRON_BEGIN = "# >>> android-watcher >>>"
|
|
43
|
+
CRON_END = "# <<< android-watcher <<<"
|
|
44
|
+
|
|
45
|
+
_CRON_DOW_TO_SYSTEMD = {
|
|
46
|
+
0: "Sun",
|
|
47
|
+
1: "Mon",
|
|
48
|
+
2: "Tue",
|
|
49
|
+
3: "Wed",
|
|
50
|
+
4: "Thu",
|
|
51
|
+
5: "Fri",
|
|
52
|
+
6: "Sat",
|
|
53
|
+
7: "Sun",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_WD_ORDER = ("mon", "tue", "wed", "thu", "fri", "sat", "sun")
|
|
58
|
+
# launchd Weekday and cron day-of-week both use Sun=0, Mon=1 … Sat=6.
|
|
59
|
+
_WD_NUM = {"mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5, "sat": 6, "sun": 0}
|
|
60
|
+
_WD_SYSTEMD = {
|
|
61
|
+
"mon": "Mon",
|
|
62
|
+
"tue": "Tue",
|
|
63
|
+
"wed": "Wed",
|
|
64
|
+
"thu": "Thu",
|
|
65
|
+
"fri": "Fri",
|
|
66
|
+
"sat": "Sat",
|
|
67
|
+
"sun": "Sun",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ScheduleError(RuntimeError):
|
|
72
|
+
"""Raised when a ScheduleConfig cannot be translated to a native schedule."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# Shared helpers
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _parse_hm(at: str) -> tuple[int, int]:
|
|
81
|
+
"""Parse an "HH:MM" string into (hour, minute) ints."""
|
|
82
|
+
try:
|
|
83
|
+
hh, mm = at.split(":")
|
|
84
|
+
h, m = int(hh), int(mm)
|
|
85
|
+
except ValueError:
|
|
86
|
+
raise ScheduleError(f"schedule.at must be 'HH:MM', got {at!r}") from None
|
|
87
|
+
if not (0 <= h <= 23 and 0 <= m <= 59):
|
|
88
|
+
raise ScheduleError(f"schedule.at out of range: {at!r}")
|
|
89
|
+
return h, m
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _parse_times(at: str) -> list[tuple[int, int]]:
|
|
93
|
+
"""Parse one or more comma-separated "HH:MM" times into (hour, minute) pairs."""
|
|
94
|
+
parts = [t.strip() for t in at.split(",") if t.strip()]
|
|
95
|
+
return [_parse_hm(t) for t in (parts or ["09:00"])]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _weekdays(days: str) -> list[str]:
|
|
99
|
+
"""Parse a comma-separated weekday list into canonical abbrevs, in week order."""
|
|
100
|
+
chosen = {d.strip().lower()[:3] for d in days.split(",")}
|
|
101
|
+
ordered = [d for d in _WD_ORDER if d in chosen]
|
|
102
|
+
return ordered or ["mon"]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _parse_cron_field(raw: str, name: str) -> int | None:
|
|
106
|
+
"""Return int for a literal digit field, None for '*'. Reject everything else."""
|
|
107
|
+
if raw == "*":
|
|
108
|
+
return None
|
|
109
|
+
try:
|
|
110
|
+
return int(raw)
|
|
111
|
+
except ValueError:
|
|
112
|
+
raise ScheduleError(
|
|
113
|
+
f"cron field {name!r} must be '*' or a single integer, got {raw!r}; "
|
|
114
|
+
"ranges, steps, and lists are not supported in the v0 subset"
|
|
115
|
+
) from None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _cron_fields(cron: str) -> tuple[str, str, str, str, str]:
|
|
119
|
+
"""Split and validate a 5-field cron expression; return the five parts."""
|
|
120
|
+
parts = cron.split()
|
|
121
|
+
if len(parts) != 5:
|
|
122
|
+
raise ScheduleError(f"cron must have 5 fields (min hour dom mon dow), got {cron!r}")
|
|
123
|
+
return parts[0], parts[1], parts[2], parts[3], parts[4]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
# launchd plist generation
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _calendar_intervals(sched: ScheduleConfig) -> list[dict[str, int]]:
|
|
132
|
+
"""Translate a ScheduleConfig into launchd StartCalendarInterval dicts."""
|
|
133
|
+
if sched.interval == "hourly":
|
|
134
|
+
return [{"Minute": m} for _, m in _parse_times(sched.at)]
|
|
135
|
+
if sched.interval == "daily":
|
|
136
|
+
return [{"Hour": h, "Minute": m} for h, m in _parse_times(sched.at)]
|
|
137
|
+
if sched.interval == "weekly":
|
|
138
|
+
return [
|
|
139
|
+
{"Weekday": _WD_NUM[d], "Hour": h, "Minute": m}
|
|
140
|
+
for d in _weekdays(sched.days)
|
|
141
|
+
for h, m in _parse_times(sched.at)
|
|
142
|
+
]
|
|
143
|
+
if sched.interval == "cron":
|
|
144
|
+
minute, hour, dom, mon, dow = _cron_fields(sched.cron)
|
|
145
|
+
if dom != "*" and dow != "*":
|
|
146
|
+
raise ScheduleError(
|
|
147
|
+
"cron sets both day-of-month and day-of-week; cron ORs them but "
|
|
148
|
+
"launchd ANDs the keys in a single StartCalendarInterval, so the "
|
|
149
|
+
"schedule would diverge. Set only one of dom/dow."
|
|
150
|
+
)
|
|
151
|
+
keys = (
|
|
152
|
+
("Minute", minute),
|
|
153
|
+
("Hour", hour),
|
|
154
|
+
("Day", dom),
|
|
155
|
+
("Month", mon),
|
|
156
|
+
("Weekday", dow),
|
|
157
|
+
)
|
|
158
|
+
interval: dict[str, int] = {}
|
|
159
|
+
for key, raw in keys:
|
|
160
|
+
val = _parse_cron_field(raw, key)
|
|
161
|
+
if val is not None:
|
|
162
|
+
interval[key] = val
|
|
163
|
+
if not interval:
|
|
164
|
+
raise ScheduleError("refusing per-minute schedule (all cron fields are '*')")
|
|
165
|
+
return [interval]
|
|
166
|
+
raise ScheduleError(f"unknown interval {sched.interval!r}")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def render_plist(label: str, program_args: list[str], sched: ScheduleConfig) -> str:
|
|
170
|
+
"""Render a launchd plist string for the given label, args, and schedule."""
|
|
171
|
+
intervals = _calendar_intervals(sched)
|
|
172
|
+
payload: dict[str, object] = {
|
|
173
|
+
"Label": label,
|
|
174
|
+
"ProgramArguments": program_args,
|
|
175
|
+
"RunAtLoad": False,
|
|
176
|
+
"StartCalendarInterval": intervals[0] if len(intervals) == 1 else intervals,
|
|
177
|
+
}
|
|
178
|
+
return plistlib.dumps(payload, sort_keys=True).decode("utf-8")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ---------------------------------------------------------------------------
|
|
182
|
+
# systemd timer + service, and crontab generation
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _on_calendars(sched: ScheduleConfig, tz: str) -> list[str]:
|
|
187
|
+
"""Build one systemd OnCalendar expression per scheduled time."""
|
|
188
|
+
if sched.interval == "hourly":
|
|
189
|
+
return [f"*-*-* *:{m:02d}:00 {tz}" for _, m in _parse_times(sched.at)]
|
|
190
|
+
if sched.interval == "daily":
|
|
191
|
+
return [f"*-*-* {h:02d}:{m:02d}:00 {tz}" for h, m in _parse_times(sched.at)]
|
|
192
|
+
if sched.interval == "weekly":
|
|
193
|
+
dow = ",".join(_WD_SYSTEMD[d] for d in _weekdays(sched.days))
|
|
194
|
+
return [f"{dow} *-*-* {h:02d}:{m:02d}:00 {tz}" for h, m in _parse_times(sched.at)]
|
|
195
|
+
return [_on_calendar(sched, tz)]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _on_calendar(sched: ScheduleConfig, tz: str) -> str:
|
|
199
|
+
"""Build a single systemd OnCalendar expression (first scheduled time)."""
|
|
200
|
+
if sched.interval == "hourly":
|
|
201
|
+
_, m = _parse_times(sched.at)[0]
|
|
202
|
+
return f"*-*-* *:{m:02d}:00 {tz}"
|
|
203
|
+
if sched.interval == "daily":
|
|
204
|
+
h, m = _parse_times(sched.at)[0]
|
|
205
|
+
return f"*-*-* {h:02d}:{m:02d}:00 {tz}"
|
|
206
|
+
if sched.interval == "weekly":
|
|
207
|
+
h, m = _parse_times(sched.at)[0]
|
|
208
|
+
dow = ",".join(_WD_SYSTEMD[d] for d in _weekdays(sched.days))
|
|
209
|
+
return f"{dow} *-*-* {h:02d}:{m:02d}:00 {tz}"
|
|
210
|
+
if sched.interval == "cron":
|
|
211
|
+
minute, hour, dom, mon, dow = _cron_fields(sched.cron)
|
|
212
|
+
mm = _parse_cron_field(minute, "minute")
|
|
213
|
+
hh = _parse_cron_field(hour, "hour")
|
|
214
|
+
dd = _parse_cron_field(dom, "dom")
|
|
215
|
+
mo = _parse_cron_field(mon, "mon")
|
|
216
|
+
wd = _parse_cron_field(dow, "dow")
|
|
217
|
+
if mm is None and hh is None and dd is None and mo is None and wd is None:
|
|
218
|
+
raise ScheduleError("refusing per-minute schedule (all cron fields are '*')")
|
|
219
|
+
if dd is not None and wd is not None:
|
|
220
|
+
raise ScheduleError(
|
|
221
|
+
"cron sets both day-of-month and day-of-week; cron ORs them but "
|
|
222
|
+
"launchd/systemd AND them, so the schedules would diverge. "
|
|
223
|
+
"Set only one of dom/dow."
|
|
224
|
+
)
|
|
225
|
+
dow_part = "" if wd is None else f"{_CRON_DOW_TO_SYSTEMD[wd]} "
|
|
226
|
+
mon_s = "*" if mo is None else f"{mo:02d}"
|
|
227
|
+
dom_s = "*" if dd is None else f"{dd:02d}"
|
|
228
|
+
hour_s = "*" if hh is None else f"{hh:02d}"
|
|
229
|
+
min_s = "*" if mm is None else f"{mm:02d}"
|
|
230
|
+
return f"{dow_part}*-{mon_s}-{dom_s} {hour_s}:{min_s}:00 {tz}"
|
|
231
|
+
raise ScheduleError(f"unknown interval {sched.interval!r}")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def render_service(exec_path: str, args: list[str]) -> str:
|
|
235
|
+
"""Render a systemd .service unit for android-watcher."""
|
|
236
|
+
exec_start = " ".join([exec_path, *args])
|
|
237
|
+
return (
|
|
238
|
+
"[Unit]\n"
|
|
239
|
+
"Description=android-watcher scheduled run\n"
|
|
240
|
+
"\n"
|
|
241
|
+
"[Service]\n"
|
|
242
|
+
"Type=oneshot\n"
|
|
243
|
+
f"ExecStart={exec_start}\n"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def render_timer(sched: ScheduleConfig, tz: str) -> str:
|
|
248
|
+
"""Render a systemd .timer unit for android-watcher."""
|
|
249
|
+
on_cals = "".join(f"OnCalendar={c}\n" for c in _on_calendars(sched, tz))
|
|
250
|
+
return (
|
|
251
|
+
"[Unit]\n"
|
|
252
|
+
"Description=android-watcher scheduled run timer\n"
|
|
253
|
+
"\n"
|
|
254
|
+
"[Timer]\n"
|
|
255
|
+
f"{on_cals}"
|
|
256
|
+
"Persistent=true\n"
|
|
257
|
+
"\n"
|
|
258
|
+
"[Install]\n"
|
|
259
|
+
"WantedBy=timers.target\n"
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _cron_lines(sched: ScheduleConfig) -> list[str]:
|
|
264
|
+
"""Return the 5-field cron time spec(s) — one per scheduled time."""
|
|
265
|
+
if sched.interval == "cron":
|
|
266
|
+
_cron_fields(sched.cron) # validate 5 fields
|
|
267
|
+
return [sched.cron]
|
|
268
|
+
times = _parse_times(sched.at)
|
|
269
|
+
if sched.interval == "hourly":
|
|
270
|
+
return [f"{m} * * * *" for _, m in times]
|
|
271
|
+
if sched.interval == "daily":
|
|
272
|
+
return [f"{m} {h} * * *" for h, m in times]
|
|
273
|
+
if sched.interval == "weekly":
|
|
274
|
+
dow = ",".join(str(_WD_NUM[d]) for d in _weekdays(sched.days))
|
|
275
|
+
return [f"{m} {h} * * {dow}" for h, m in times]
|
|
276
|
+
raise ScheduleError(f"unknown interval {sched.interval!r}")
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _cron_line(sched: ScheduleConfig) -> str:
|
|
280
|
+
"""Return the first 5-field cron time spec (back-compat single-line helper)."""
|
|
281
|
+
return _cron_lines(sched)[0]
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def render_crontab(line_command: str, sched: ScheduleConfig, tz: str) -> str:
|
|
285
|
+
"""Render a marked crontab block for android-watcher (one line per scheduled time)."""
|
|
286
|
+
body = "\n".join(f"{spec} {line_command}" for spec in _cron_lines(sched))
|
|
287
|
+
return f"{CRON_BEGIN}\nCRON_TZ={tz}\n{body}\n{CRON_END}\n"
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# ---------------------------------------------------------------------------
|
|
291
|
+
# Platform detection helpers (seams for testing via monkeypatch)
|
|
292
|
+
# ---------------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _platform() -> str:
|
|
296
|
+
return sys.platform
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _has_systemd() -> bool:
|
|
300
|
+
return shutil.which("systemctl") is not None and Path("/run/systemd/system").exists()
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _local_tz() -> str:
|
|
304
|
+
"""Best-effort IANA zone name; falls back to the /etc/localtime symlink."""
|
|
305
|
+
tz = os.environ.get("TZ")
|
|
306
|
+
if tz:
|
|
307
|
+
return tz
|
|
308
|
+
try:
|
|
309
|
+
link = os.readlink("/etc/localtime")
|
|
310
|
+
if "zoneinfo/" in link:
|
|
311
|
+
return link.split("zoneinfo/", 1)[1]
|
|
312
|
+
except OSError:
|
|
313
|
+
pass
|
|
314
|
+
return time.tzname[0] or "UTC"
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _program_args() -> list[str]:
|
|
318
|
+
exe = shutil.which("android-watcher") or sys.argv[0]
|
|
319
|
+
return [exe, "run"]
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _launchd_plist_path() -> str:
|
|
323
|
+
return str(Path.home() / "Library" / "LaunchAgents" / f"{LAUNCHD_LABEL}.plist")
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _systemd_dir() -> str:
|
|
327
|
+
base = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))
|
|
328
|
+
return str(Path(base) / "systemd" / "user")
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _linger_enabled() -> bool:
|
|
332
|
+
try:
|
|
333
|
+
out = subprocess.run(
|
|
334
|
+
["loginctl", "show-user", os.environ.get("USER", ""), "--property=Linger"],
|
|
335
|
+
capture_output=True,
|
|
336
|
+
text=True,
|
|
337
|
+
check=False,
|
|
338
|
+
)
|
|
339
|
+
return "Linger=yes" in out.stdout
|
|
340
|
+
except (OSError, subprocess.SubprocessError):
|
|
341
|
+
return False
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _warn(msg: str) -> None:
|
|
345
|
+
print(f"warning: {msg}", file=sys.stderr)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _run(argv: list[str], *, input: str | None = None) -> subprocess.CompletedProcess:
|
|
349
|
+
return subprocess.run(argv, input=input, capture_output=True, text=True, check=False)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# ---------------------------------------------------------------------------
|
|
353
|
+
# Internal per-platform install helpers
|
|
354
|
+
# ---------------------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _install_macos(config: Config) -> None:
|
|
358
|
+
path = Path(_launchd_plist_path())
|
|
359
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
360
|
+
path.write_text(render_plist(LAUNCHD_LABEL, _program_args(), config.schedule))
|
|
361
|
+
_run(["launchctl", "unload", str(path)])
|
|
362
|
+
_run(["launchctl", "load", "-w", str(path)])
|
|
363
|
+
_warn(
|
|
364
|
+
"launchd LaunchAgents only fire while you are logged in to a GUI session; "
|
|
365
|
+
"the job runs once on wake if a scheduled time was missed."
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _install_systemd(config: Config) -> None:
|
|
370
|
+
d = Path(_systemd_dir())
|
|
371
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
372
|
+
exe, *run_args = _program_args()
|
|
373
|
+
(d / f"{SYSTEMD_UNIT_NAME}.service").write_text(render_service(exe, run_args))
|
|
374
|
+
(d / f"{SYSTEMD_UNIT_NAME}.timer").write_text(render_timer(config.schedule, _local_tz()))
|
|
375
|
+
_run(["systemctl", "--user", "daemon-reload"])
|
|
376
|
+
_run(["systemctl", "--user", "enable", "--now", f"{SYSTEMD_UNIT_NAME}.timer"])
|
|
377
|
+
if not _linger_enabled():
|
|
378
|
+
_warn(
|
|
379
|
+
"systemd user timers only run while you are logged in unless lingering is enabled. "
|
|
380
|
+
f"Run: loginctl enable-linger {os.environ.get('USER', '$USER')}"
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _strip_cron_block(text: str) -> str:
|
|
385
|
+
"""Return *text* with the android-watcher marked block removed."""
|
|
386
|
+
lines = text.splitlines()
|
|
387
|
+
out: list[str] = []
|
|
388
|
+
skip = False
|
|
389
|
+
for line in lines:
|
|
390
|
+
if line.strip() == CRON_BEGIN:
|
|
391
|
+
skip = True
|
|
392
|
+
continue
|
|
393
|
+
if line.strip() == CRON_END:
|
|
394
|
+
skip = False
|
|
395
|
+
continue
|
|
396
|
+
if not skip:
|
|
397
|
+
out.append(line)
|
|
398
|
+
return "\n".join(out)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _install_crontab(config: Config) -> None:
|
|
402
|
+
existing = _run(["crontab", "-l"]).stdout
|
|
403
|
+
block = render_crontab(" ".join(_program_args()), config.schedule, _local_tz())
|
|
404
|
+
cleaned = _strip_cron_block(existing)
|
|
405
|
+
new = (cleaned.rstrip("\n") + "\n" if cleaned.strip() else "") + block
|
|
406
|
+
_run(["crontab", "-"], input=new)
|
|
407
|
+
_warn(
|
|
408
|
+
"crontab fallback pins the timezone via CRON_TZ where supported (Vixie/cronie); "
|
|
409
|
+
"on cron builds that ignore CRON_TZ it uses the system timezone. "
|
|
410
|
+
"Missed runs backfill via the run catch-up gate."
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
# ---------------------------------------------------------------------------
|
|
415
|
+
# Public interface
|
|
416
|
+
# ---------------------------------------------------------------------------
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def install_schedule(config: Config) -> None:
|
|
420
|
+
"""Install the native scheduled job for the current platform."""
|
|
421
|
+
if _platform() == "darwin":
|
|
422
|
+
_install_macos(config)
|
|
423
|
+
elif _platform().startswith("linux"):
|
|
424
|
+
if _has_systemd():
|
|
425
|
+
_install_systemd(config)
|
|
426
|
+
else:
|
|
427
|
+
_install_crontab(config)
|
|
428
|
+
else:
|
|
429
|
+
raise ScheduleError(f"unsupported platform {_platform()!r}")
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def remove_schedule() -> None:
|
|
433
|
+
"""Remove the native scheduled job."""
|
|
434
|
+
if _platform() == "darwin":
|
|
435
|
+
path = Path(_launchd_plist_path())
|
|
436
|
+
if path.exists():
|
|
437
|
+
_run(["launchctl", "unload", str(path)])
|
|
438
|
+
path.unlink()
|
|
439
|
+
elif _platform().startswith("linux"):
|
|
440
|
+
if _has_systemd():
|
|
441
|
+
_run(["systemctl", "--user", "disable", "--now", f"{SYSTEMD_UNIT_NAME}.timer"])
|
|
442
|
+
d = Path(_systemd_dir())
|
|
443
|
+
for name in (f"{SYSTEMD_UNIT_NAME}.timer", f"{SYSTEMD_UNIT_NAME}.service"):
|
|
444
|
+
(d / name).unlink(missing_ok=True)
|
|
445
|
+
_run(["systemctl", "--user", "daemon-reload"])
|
|
446
|
+
else:
|
|
447
|
+
existing = _run(["crontab", "-l"]).stdout
|
|
448
|
+
_run(["crontab", "-"], input=_strip_cron_block(existing) + "\n")
|
|
449
|
+
else:
|
|
450
|
+
raise ScheduleError(f"unsupported platform {_platform()!r}")
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def schedule_status() -> Check:
|
|
454
|
+
"""Return a Check indicating whether the scheduled job is actually loaded/active."""
|
|
455
|
+
name = "schedule"
|
|
456
|
+
if _platform() == "darwin":
|
|
457
|
+
out = _run(["launchctl", "list"]).stdout
|
|
458
|
+
loaded = LAUNCHD_LABEL in out
|
|
459
|
+
return Check(
|
|
460
|
+
name=name,
|
|
461
|
+
ok=loaded,
|
|
462
|
+
detail=f"launchd agent {LAUNCHD_LABEL}: {'loaded' if loaded else 'not loaded'}",
|
|
463
|
+
)
|
|
464
|
+
if _platform().startswith("linux"):
|
|
465
|
+
if _has_systemd():
|
|
466
|
+
active = _run(
|
|
467
|
+
["systemctl", "--user", "is-active", f"{SYSTEMD_UNIT_NAME}.timer"]
|
|
468
|
+
).stdout.strip()
|
|
469
|
+
enabled = _run(
|
|
470
|
+
["systemctl", "--user", "is-enabled", f"{SYSTEMD_UNIT_NAME}.timer"]
|
|
471
|
+
).stdout.strip()
|
|
472
|
+
linger = "linger on" if _linger_enabled() else "linger OFF (runs only while logged in)"
|
|
473
|
+
ok = active == "active"
|
|
474
|
+
return Check(
|
|
475
|
+
name=name,
|
|
476
|
+
ok=ok,
|
|
477
|
+
detail=(
|
|
478
|
+
f"systemd timer is-active={active or 'unknown'} "
|
|
479
|
+
f"is-enabled={enabled or 'unknown'}; {linger}"
|
|
480
|
+
),
|
|
481
|
+
)
|
|
482
|
+
present = CRON_BEGIN in _run(["crontab", "-l"]).stdout
|
|
483
|
+
return Check(
|
|
484
|
+
name=name,
|
|
485
|
+
ok=present,
|
|
486
|
+
detail=f"crontab entry {'present' if present else 'absent'}",
|
|
487
|
+
)
|
|
488
|
+
return Check(name=name, ok=False, detail=f"unsupported platform {_platform()!r}")
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Shipped baseline seed.
|
|
2
|
+
|
|
3
|
+
A seed is a pre-built baseline (snapshots + feed seen-set + HTTP validators)
|
|
4
|
+
generated by ``scripts/build_seed.py`` and bundled as ``seed.sql.gz``. On a
|
|
5
|
+
fresh DB it gives users a starting point so the first scheduled run diffs
|
|
6
|
+
against the seeded baseline instead of crawling every page from scratch.
|
|
7
|
+
|
|
8
|
+
The bundled artifact is optional: when it is absent (e.g. before a release has
|
|
9
|
+
generated it), import is a graceful no-op and the first run falls back to the
|
|
10
|
+
fetch-free baseline the sitemap detector establishes on its own.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import gzip
|
|
16
|
+
import logging
|
|
17
|
+
from importlib.resources import files
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger("android_watcher.seed")
|
|
20
|
+
|
|
21
|
+
_SEED_FILE = "seed.sql.gz"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def bundled_seed_sql() -> str | None:
|
|
25
|
+
"""Return the decompressed bundled seed SQL, or None if no seed is shipped."""
|
|
26
|
+
resource = files(__name__).joinpath(_SEED_FILE)
|
|
27
|
+
if not resource.is_file():
|
|
28
|
+
return None
|
|
29
|
+
return gzip.decompress(resource.read_bytes()).decode()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def apply_seed_if_empty(store) -> str | None:
|
|
33
|
+
"""Import the bundled seed into a fresh (snapshot-less) DB.
|
|
34
|
+
|
|
35
|
+
Returns the seed date when a seed was applied, else None. Safe to call on
|
|
36
|
+
every run: it short-circuits once any snapshot exists, and the seed's
|
|
37
|
+
``INSERT OR IGNORE`` statements never overwrite user data.
|
|
38
|
+
"""
|
|
39
|
+
if store.snapshot_count() > 0:
|
|
40
|
+
return None
|
|
41
|
+
sql = bundled_seed_sql()
|
|
42
|
+
if sql is None:
|
|
43
|
+
return None
|
|
44
|
+
store.import_seed_sql(sql)
|
|
45
|
+
return store.seed_date()
|
|
Binary file
|