mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 22:27:13 +01:00
Some checks are pending
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (3.10) (push) Waiting to run
crdt-sync tests / test (3.11) (push) Waiting to run
crdt-sync tests / test (3.12) (push) Waiting to run
gatelock pre-commit / pre-commit (push) Waiting to run
gatelock tests / test (3.10) (push) Waiting to run
gatelock tests / test (3.11) (push) Waiting to run
gatelock tests / test (3.12) (push) Waiting to run
palette drift / palette (push) Waiting to run
_sync.py 456 -> 245 + _syncstate 131 + _pull 106 + _revisions 34 _firebase_auth 412 -> 242 + _credentials 146 + _autherrors 69 _firebase.py 336 -> 250 + _fbkeys 100 google_id_token 316 -> 232 + tool/_oauth_callback 105 _github.py 255 -> 238 + _gherrors 30 revision_of became its own leaf module because both the push and pull halves need it; without that the two would have imported each other. _fbkeys holds the RTDB key escaping — the escape tables moved with the functions that read them, since the pair has to round-trip exactly or a device silently stops seeing its own writes. crdt-sync/tool/ is outside the coverage source, so the split there has no test net: verified by running it the documented way instead (`python3 -m tool.google_id_token --help` prints its usage, and `from tool.google_id_token import TokenError, fetch_id_token` — what seed_session.py does — still resolves). An AST pass over each pair caught what the suite would not have: _is_revoked needs the error type, so it stayed with it rather than following the credentials; _TERMINAL_AUTH_REASONS and _CONSENT_TIMEOUT_SECONDS had to be carried to their new callers. 259 passed at 100% branch coverage. crdt-sync/tool/seed_session.py and firebase-debug.log are pre-existing local changes and are deliberately not in this commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
132 lines
4.3 KiB
Python
132 lines
4.3 KiB
Python
"""Where a device records what it has already pushed and pulled.
|
|
|
|
Split from :mod:`crdt_sync._sync`, which keeps the sync itself. The state is
|
|
what makes a tick cheap: without it every run re-downloads every peer, and
|
|
with a stale one a device silently stops seeing updates. Two stores ship --
|
|
in-memory for tests, and a JSON file for real installs.
|
|
|
|
Re-exported from :mod:`crdt_sync._sync`, so existing imports keep working.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Protocol
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SyncState:
|
|
"""What one device remembers between ticks to skip needless traffic.
|
|
|
|
Attributes:
|
|
----------
|
|
pushed_rev:
|
|
Hash of what this device last pushed, so an unchanged log is not
|
|
re-uploaded. 88% of the pushes in the GitHub-backed history this
|
|
replaces were byte-identical no-ops.
|
|
peer_revs:
|
|
The hash each peer had when we last downloaded it, so an unchanged
|
|
peer is not re-downloaded.
|
|
|
|
Notes:
|
|
-----
|
|
**Must be stored next to the log itself and cleared with it.** Skipping
|
|
an unchanged peer is only sound because that peer's records are already
|
|
merged into the local log; a cache that outlived its log would skip peers
|
|
whose data had been lost.
|
|
|
|
"""
|
|
|
|
pushed_rev: str | None = None
|
|
peer_revs: dict[str, str] = field(default_factory=dict)
|
|
|
|
def to_json(self) -> dict[str, object]:
|
|
"""Return a JSON-serializable form."""
|
|
return {"pushed_rev": self.pushed_rev, "peer_revs": self.peer_revs}
|
|
|
|
@classmethod
|
|
def from_json(cls, data: dict[str, object]) -> SyncState:
|
|
"""Rebuild state from :meth:`to_json` output, ignoring junk.
|
|
|
|
A malformed cache degrades into "fetch everything" rather than
|
|
raising: it is an optimisation, never a source of failure.
|
|
"""
|
|
pushed = data.get("pushed_rev")
|
|
peers = data.get("peer_revs")
|
|
return cls(
|
|
pushed_rev=pushed if isinstance(pushed, str) else None,
|
|
peer_revs={
|
|
key: value
|
|
for key, value in (peers or {}).items()
|
|
if isinstance(value, str)
|
|
}
|
|
if isinstance(peers, dict)
|
|
else {},
|
|
)
|
|
|
|
|
|
class SyncStateStore(Protocol):
|
|
"""Where :func:`sync_log` persists its :class:`SyncState` between runs."""
|
|
|
|
def load(self) -> SyncState:
|
|
"""Return the stored state, or a default one."""
|
|
|
|
def save(self, state: SyncState) -> None:
|
|
"""Persist ``state``."""
|
|
|
|
|
|
class MemorySyncStateStore:
|
|
"""A :class:`SyncStateStore` that forgets on exit, for tests.
|
|
|
|
Correct but pessimistic: every process re-downloads every peer and
|
|
re-pushes the local log, which is exactly the old behaviour.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
"""Start with empty state."""
|
|
self._state = SyncState()
|
|
|
|
def load(self) -> SyncState:
|
|
"""Return the held state."""
|
|
return self._state
|
|
|
|
def save(self, state: SyncState) -> None:
|
|
"""Replace the held state."""
|
|
self._state = state
|
|
|
|
|
|
class FileSyncStateStore:
|
|
"""A :class:`SyncStateStore` backed by a JSON file.
|
|
|
|
Persistence is what makes the saving real for short-lived callers:
|
|
``wake_alarm``'s PC side is a fresh process every minute and
|
|
``diet_guard``'s every 15 minutes, so an in-memory store would save
|
|
nothing at all.
|
|
"""
|
|
|
|
def __init__(self, path: Path) -> None:
|
|
"""Keep state in the file at ``path``."""
|
|
self._path = path
|
|
|
|
def load(self) -> SyncState:
|
|
"""Return the stored state, or a default one if unreadable."""
|
|
try:
|
|
data = json.loads(self._path.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError):
|
|
return SyncState()
|
|
if not isinstance(data, dict):
|
|
return SyncState()
|
|
return SyncState.from_json(data)
|
|
|
|
def save(self, state: SyncState) -> None:
|
|
"""Write ``state`` atomically, so a crash cannot truncate it."""
|
|
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
temp = self._path.with_suffix(f"{self._path.suffix}.tmp")
|
|
temp.write_text(json.dumps(state.to_json()), encoding="utf-8")
|
|
temp.replace(self._path)
|