utils/crdt-sync/crdt_sync/_syncstate.py
Krzysztof kuhy Rudnicki 305e59b3e8
Some checks failed
crdt-sync pre-commit / pre-commit (push) Has been cancelled
crdt-sync tests / test (push) Has been cancelled
dependency freshness / dependencies on newest stable (push) Has been cancelled
file length / file-length (push) Has been cancelled
gatelock pre-commit / pre-commit (push) Has been cancelled
gatelock tests / test (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
palette drift / palette (push) Has been cancelled
shared gate tests / gate unit tests (push) Has been cancelled
Grade crdt-sync and gatelock against 3.14 too, not just run on them
Both libraries had already moved `requires-python` to 3.14, but ruff, mypy and
pylint were still grading 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), a dead `timezone` import left behind by it, and ruff's
stable formatter applying PEP 758 -- `except (A, B):` becomes `except A, B:`.
That last one 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).

These two are libraries the apps install, so the floor matters more here than
anywhere else in the fleet -- every consumer already pins >=3.14.

Per project: ruff, ruff-format, mypy --strict and pylint clean, crdt-sync 432
tests and gatelock 389 tests, both at 100% line and branch coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfnJmxxsZ534AKk16rC2Gr
2026-08-29 11:47:21 +02:00

134 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 typing import TYPE_CHECKING, Protocol
if TYPE_CHECKING:
from pathlib import Path
_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)