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.
Files changed (44) hide show
  1. android_watcher/__init__.py +10 -0
  2. android_watcher/catalog/__init__.py +32 -0
  3. android_watcher/catalog/catalog.toml +531 -0
  4. android_watcher/cli.py +161 -0
  5. android_watcher/config.py +262 -0
  6. android_watcher/detect/__init__.py +1 -0
  7. android_watcher/detect/_normalize.py +192 -0
  8. android_watcher/detect/android_sitemap.py +540 -0
  9. android_watcher/detect/base.py +14 -0
  10. android_watcher/detect/content.py +99 -0
  11. android_watcher/detect/feed.py +135 -0
  12. android_watcher/detect/sitemap.py +203 -0
  13. android_watcher/doctor.py +125 -0
  14. android_watcher/fetch.py +162 -0
  15. android_watcher/group.py +79 -0
  16. android_watcher/lock.py +32 -0
  17. android_watcher/models.py +156 -0
  18. android_watcher/notify/__init__.py +1 -0
  19. android_watcher/notify/base.py +21 -0
  20. android_watcher/notify/email.py +52 -0
  21. android_watcher/notify/html.py +114 -0
  22. android_watcher/notify/render.py +239 -0
  23. android_watcher/notify/slack.py +124 -0
  24. android_watcher/notify/telegram.py +46 -0
  25. android_watcher/rank.py +84 -0
  26. android_watcher/registry.py +38 -0
  27. android_watcher/run.py +283 -0
  28. android_watcher/schedule.py +488 -0
  29. android_watcher/seed/__init__.py +45 -0
  30. android_watcher/seed/seed.sql.gz +0 -0
  31. android_watcher/store.py +492 -0
  32. android_watcher/triage/__init__.py +1 -0
  33. android_watcher/triage/base.py +25 -0
  34. android_watcher/triage/claude_cli.py +185 -0
  35. android_watcher/triage/noop.py +24 -0
  36. android_watcher/tui/__init__.py +1 -0
  37. android_watcher/tui/app.py +163 -0
  38. android_watcher/tui/configio.py +215 -0
  39. android_watcher/tui/screens.py +927 -0
  40. android_watcher-1.0.0.dist-info/METADATA +310 -0
  41. android_watcher-1.0.0.dist-info/RECORD +44 -0
  42. android_watcher-1.0.0.dist-info/WHEEL +4 -0
  43. android_watcher-1.0.0.dist-info/entry_points.txt +2 -0
  44. 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