mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 22:47:02 +01:00
`requires-python` and the CI pin already said 3.14, but ruff, mypy and pylint were still grading `meta/` against 3.10 -- three tools judging an interpreter nothing runs any more, and ruff withholding the pyupgrade fixes that only become safe at the newer target. `pin_ci_python.py` rewrites these three knobs. What the stale target was hiding: `datetime.timezone.utc` where `datetime.UTC` is now correct (UP017), and ruff's stable formatter applying PEP 758 -- `except (A, B):` becomes `except A, B:`. That makes the source 3.14-only at the syntax level, which is what `requires-python = ">=3.14"` already promised; kuhy chose it deliberately over holding ruff a release back (2026-08-29). Also drops a comment in `wsg_grabber/models.py` that the bump makes false: it explained why the file used ``str, Enum`` instead of ``StrEnum`` under mypy at 3.10, sitting directly above a ``class Verdict(StrEnum)`` that had already stopped obeying it. ruff, ruff-format, mypy --strict and pylint all clean at the new target. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SfnJmxxsZ534AKk16rC2Gr
183 lines
6.2 KiB
Python
183 lines
6.2 KiB
Python
"""Read the live payload and keep versioned snapshots of the good ones.
|
|
|
|
Two rules shape this module, both learned on 2026-08-09:
|
|
|
|
* The database is opened **read-only**. The guard runs on a timer against a
|
|
database a container is actively writing; it must never be the reason
|
|
SyncYomi sees a lock.
|
|
* A snapshot is written only for a payload that passed. Snapshotting first and
|
|
judging afterwards would have faithfully archived the 267 KB stub.
|
|
|
|
Snapshots are gzipped ``Backup`` messages, which is exactly the ``.tachibk``
|
|
format the app restores from — so recovery is "copy this file to the phone",
|
|
with no decoding step at the moment it is needed most.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import asdict
|
|
from datetime import UTC, datetime
|
|
import gzip
|
|
import json
|
|
import sqlite3
|
|
from typing import TYPE_CHECKING, Final
|
|
|
|
from python_pkg.syncyomi_guard.payload import PayloadError, PayloadStats
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
_STATE_FILENAME: Final = "last_known_good.json"
|
|
|
|
# The glob matches the timestamp layout *only*. A looser pattern such as
|
|
# ``syncyomi_*.tachibk`` would also match a hand-named file like
|
|
# ``syncyomi_recovered_2026-08-09.tachibk`` — the irreplaceable original
|
|
# rescued from the WAL — and the pruner would delete it as a stale snapshot.
|
|
_SNAPSHOT_GLOB: Final = "syncyomi_[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T*Z.tachibk"
|
|
_SNAPSHOT_STEM: Final = "syncyomi_%Y-%m-%dT%H-%M-%SZ"
|
|
|
|
|
|
class StoreError(RuntimeError):
|
|
"""Raised when the payload cannot be read from the SyncYomi database."""
|
|
|
|
|
|
def read_payload(db_path: Path) -> bytes:
|
|
"""Read the single ``sync_data`` blob from a SyncYomi SQLite database.
|
|
|
|
Opened through an immutable URI: no locking, no WAL recovery, no write to
|
|
the directory. The database stays exactly as the container left it.
|
|
|
|
Raises:
|
|
StoreError: If the database is unreadable or holds no sync row.
|
|
"""
|
|
if not db_path.is_file():
|
|
msg = f"no SyncYomi database at {db_path}"
|
|
raise StoreError(msg)
|
|
|
|
# ``sqlite3.connect`` as a context manager commits the transaction but does
|
|
# NOT close the connection, so it is closed explicitly. A guard that leaks
|
|
# handles against the live database would eventually become the thing
|
|
# holding a file open on it.
|
|
uri = f"file:{db_path}?immutable=1"
|
|
try:
|
|
conn = sqlite3.connect(uri, uri=True)
|
|
except sqlite3.Error as exc:
|
|
msg = f"cannot read {db_path}: {exc}"
|
|
raise StoreError(msg) from exc
|
|
|
|
try:
|
|
rows = conn.execute(
|
|
"SELECT data FROM sync_data ORDER BY id LIMIT 1",
|
|
).fetchall()
|
|
except sqlite3.Error as exc:
|
|
msg = f"cannot read {db_path}: {exc}"
|
|
raise StoreError(msg) from exc
|
|
finally:
|
|
conn.close()
|
|
|
|
if not rows:
|
|
msg = f"{db_path} has no sync_data row"
|
|
raise StoreError(msg)
|
|
|
|
blob = rows[0][0]
|
|
if not isinstance(blob, bytes):
|
|
msg = f"sync_data.data is {type(blob).__name__}, expected bytes"
|
|
raise StoreError(msg)
|
|
return blob
|
|
|
|
|
|
def load_baseline(state_dir: Path) -> PayloadStats | None:
|
|
"""Load the last known good stats, or ``None`` when there is no baseline.
|
|
|
|
A corrupt or partial state file is treated as absent rather than fatal: the
|
|
guard then re-baselines loudly (``FIRST_RUN``) instead of refusing to run.
|
|
"""
|
|
path = state_dir / _STATE_FILENAME
|
|
if not path.is_file():
|
|
return None
|
|
try:
|
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
return PayloadStats(
|
|
size_bytes=int(raw["size_bytes"]),
|
|
manga=int(raw["manga"]),
|
|
categories=int(raw["categories"]),
|
|
chapters=int(raw["chapters"]),
|
|
sources=int(raw["sources"]),
|
|
)
|
|
except OSError, ValueError, KeyError, TypeError:
|
|
return None
|
|
|
|
|
|
def save_baseline(state_dir: Path, stats: PayloadStats) -> Path:
|
|
"""Record ``stats`` as the new last known good baseline."""
|
|
state_dir.mkdir(parents=True, exist_ok=True)
|
|
path = state_dir / _STATE_FILENAME
|
|
payload = dict(asdict(stats))
|
|
payload["recorded_at"] = datetime.now(UTC).isoformat()
|
|
path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
|
|
return path
|
|
|
|
|
|
def write_snapshot(
|
|
snapshot_dir: Path,
|
|
blob: bytes,
|
|
keep: int,
|
|
now: datetime | None = None,
|
|
) -> Path:
|
|
"""Write ``blob`` as a restorable ``.tachibk`` and prune old snapshots.
|
|
|
|
Raises:
|
|
ValueError: If ``keep`` is not at least 1.
|
|
"""
|
|
if keep < 1:
|
|
msg = f"keep must be >= 1, got {keep}"
|
|
raise ValueError(msg)
|
|
|
|
snapshot_dir.mkdir(parents=True, exist_ok=True)
|
|
stamp = (now or datetime.now(UTC)).strftime(_SNAPSHOT_STEM)
|
|
|
|
# Timestamps are second-granular, so two runs in the same second would map
|
|
# to one filename and the second would silently overwrite the first —
|
|
# quietly holding fewer than ``keep`` distinct snapshots, and in the worst
|
|
# case replacing a good library with a stub written a fraction of a second
|
|
# later. Suffix on collision rather than clobber.
|
|
path = snapshot_dir / f"{stamp}.tachibk"
|
|
suffix = 1
|
|
while path.exists():
|
|
path = snapshot_dir / f"{stamp}-{suffix}.tachibk"
|
|
suffix += 1
|
|
|
|
path.write_bytes(gzip.compress(blob))
|
|
_prune(snapshot_dir, keep)
|
|
return path
|
|
|
|
|
|
def _prune(snapshot_dir: Path, keep: int) -> None:
|
|
"""Delete all but the ``keep`` newest snapshots, by filename order.
|
|
|
|
Filenames are UTC timestamps in a sortable layout, so lexical order is
|
|
chronological order without stat-ing every file.
|
|
"""
|
|
snapshots = sorted(snapshot_dir.glob(_SNAPSHOT_GLOB))
|
|
for stale in snapshots[:-keep]:
|
|
stale.unlink()
|
|
|
|
|
|
def latest_snapshot(snapshot_dir: Path) -> Path | None:
|
|
"""Return the newest snapshot, or ``None`` when none exist."""
|
|
snapshots = sorted(snapshot_dir.glob(_SNAPSHOT_GLOB))
|
|
return snapshots[-1] if snapshots else None
|
|
|
|
|
|
def read_snapshot(path: Path) -> bytes:
|
|
"""Decompress a ``.tachibk`` snapshot back into a raw payload.
|
|
|
|
Raises:
|
|
PayloadError: If the file is not readable gzip.
|
|
"""
|
|
try:
|
|
return gzip.decompress(path.read_bytes())
|
|
except (OSError, gzip.BadGzipFile) as exc:
|
|
msg = f"cannot read snapshot {path}: {exc}"
|
|
raise PayloadError(msg) from exc
|