testsAndMisc/python_pkg/syncyomi_guard/__main__.py
Krzysztof kuhy Rudnicki f15f00f3fe Re-point paths for the ~ reorganisation (home-tidy)
Also bumps the 16 meta/requirements pins the freshness gate flagged
(anthropic 1.5.0, numpy 2.5.3, pytest-randomly 5, flake8-bugbear 26.9.9,
ruff 0.16.7, pyright 1.1.414, selenium 4.49.0, hypothesis 6.168.0, lxml
6.1.3, pipdeptree 4.2.5, the two types-* stubs) and Flutter 3.47.3 in
meta/.fvmrc. One test still expected the pre-move ~/mcp-servers path.

The pre-commit hook fed .zsh files to shfmt and shellcheck, neither of
which parses zsh (SC2148/SC1071), so no zsh file could be committed since
the hook was tightened on 2026-08-22; those now get `zsh -n` instead. The
hook's own shfmt pass also staged its reformat of 8 shell files.

CI_GREEN_SKIP because main is red on exactly the dependency-freshness
finding this commit fixes, which is the documented use of that flag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwtowrowDXx6ccmWVPkCaF
2026-09-11 20:50:19 +02:00

169 lines
5.2 KiB
Python

"""Command line entry point for the SyncYomi integrity guard.
Exit codes are the contract; a systemd timer and a human both read them:
* ``0`` — payload healthy (or a first-run baseline was recorded)
* ``1`` — payload collapsed, or it could not be read or decoded
A read failure is deliberately *not* a pass. The whole point of the guard is to
be believed on the day something is wrong, and "the database was missing so I
said OK" is the fail-open shape that lets a bad copy become the only copy.
"""
from __future__ import annotations
import argparse
from pathlib import Path
import shutil
import subprocess
import sys
from python_pkg.syncyomi_guard.payload import PayloadError, decode_payload
from python_pkg.syncyomi_guard.store import (
StoreError,
load_baseline,
read_payload,
save_baseline,
write_snapshot,
)
from python_pkg.syncyomi_guard.verdict import Status, Thresholds, Verdict, compare
_DEFAULT_DB = Path("/home/kuhy/services/syncyomi/config/syncyomi.db")
_DEFAULT_STATE = Path.home() / ".local/share/syncyomi_guard"
_DEFAULT_SNAPSHOTS = Path.home() / "services/syncyomi/snapshots"
_DEFAULT_KEEP = 14
def _notify(title: str, body: str) -> None:
"""Best-effort desktop notification; never fails the run.
The exit code is the real signal. A notification is a convenience, so a
machine without ``notify-send`` (or a run with no session bus) must not turn
a healthy check into a failure.
"""
binary = shutil.which("notify-send")
if binary is None:
return
try:
subprocess.run(
[binary, "--urgency=critical", title, body],
check=False,
timeout=10,
)
except OSError, subprocess.SubprocessError:
return
def _build_parser() -> argparse.ArgumentParser:
"""Construct the argument parser."""
parser = argparse.ArgumentParser(
prog="syncyomi-guard",
description=(
"Verify the SyncYomi library payload has not silently collapsed, "
"and snapshot it as a restorable .tachibk when it is healthy."
),
)
parser.add_argument(
"--db",
type=Path,
default=_DEFAULT_DB,
help=f"SyncYomi SQLite database (default: {_DEFAULT_DB})",
)
parser.add_argument(
"--state-dir",
type=Path,
default=_DEFAULT_STATE,
help=f"where the baseline is stored (default: {_DEFAULT_STATE})",
)
parser.add_argument(
"--snapshot-dir",
type=Path,
default=_DEFAULT_SNAPSHOTS,
help=f"where .tachibk snapshots are written (default: {_DEFAULT_SNAPSHOTS})",
)
parser.add_argument(
"--keep",
type=int,
default=_DEFAULT_KEEP,
help=f"how many snapshots to retain (default: {_DEFAULT_KEEP})",
)
parser.add_argument(
"--max-drop",
type=float,
default=Thresholds().max_drop_ratio,
help="fail if any count drops by more than this fraction (default: 0.10)",
)
parser.add_argument(
"--no-snapshot",
action="store_true",
help="check only; do not write a snapshot or update the baseline",
)
parser.add_argument(
"--accept",
action="store_true",
help=(
"record the current payload as the new baseline even if it looks "
"collapsed (use after a deliberate library purge)"
),
)
return parser
def _report(verdict: Verdict) -> None:
"""Write the verdict to the appropriate stream.
A silent guard is a useless one: the journal line is how a human finds out
what the timer saw, so the counts are written on every run, not only on
failures.
"""
if verdict.is_failure:
sys.stderr.write(f"COLLAPSED: {verdict.reason}\n")
if verdict.previous is not None:
sys.stderr.write(f" previous: {verdict.previous.describe()}\n")
sys.stderr.write(f" current: {verdict.current.describe()}\n")
elif verdict.status is Status.FIRST_RUN:
sys.stdout.write(f"baseline recorded: {verdict.current.describe()}\n")
else:
sys.stdout.write(f"ok: {verdict.reason}\n")
def main(argv: list[str] | None = None) -> int:
"""Run the guard, returning the process exit code."""
args = _build_parser().parse_args(argv)
try:
blob = read_payload(args.db)
stats = decode_payload(blob)
except (StoreError, PayloadError) as exc:
sys.stderr.write(f"FAILED: {exc}\n")
_notify("SyncYomi guard: cannot verify", str(exc))
return 1
try:
thresholds = Thresholds(max_drop_ratio=args.max_drop)
except ValueError as exc:
sys.stderr.write(f"FAILED: {exc}\n")
return 1
verdict = compare(stats, load_baseline(args.state_dir), thresholds)
_report(verdict)
if verdict.is_failure and not args.accept:
_notify(
"SyncYomi library collapsed",
f"{verdict.reason}. Snapshot NOT overwritten; recover from "
f"{args.snapshot_dir}.",
)
return 1
if not args.no_snapshot:
snapshot = write_snapshot(args.snapshot_dir, blob, args.keep)
save_baseline(args.state_dir, stats)
sys.stdout.write(f"snapshot: {snapshot}\n")
return 0
if __name__ == "__main__":
sys.exit(main())