mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:47:38 +01:00
The three Python packages pinned ruff twice at different versions: requirements.txt at 0.16.7, .pre-commit-config.yaml at v0.15.2. Only the older one ever ran, so `select = ["ALL"]` was enforced two minor versions behind. Measured before aligning: v0.15.2 reports 0 findings, every 0.16.x reports CPY001 on each file -- 70 crdt-sync, 23 freedays, 71 gatelock, and no other rule. Resolved by adding the notice rather than ignoring the rule, so the gate keeps its teeth. Two gatelock modules sat at exactly the 250-line cap, so a one-line header tipped them to 251. Both are split along a real seam rather than shaved: _widget_fills.py colour arithmetic over LockConfig, no tkinter import _surfaces_focus.py selects among existing surfaces, never mutates them The focus split keeps _surfaces.py's documented containment (it stays the only module that creates, moves or destroys windows), and the static guard in test_recovery_invariants.py globs _surfaces*.py, so the new sibling is covered with no list to extend. widgets.py re-exports the fills helpers, which keeps gatelock.widgets the single public surface its tests import. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019kMvSMtsVMB3d2uoZ56pbp
130 lines
4.4 KiB
Python
130 lines
4.4 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
"""Record: a per-field LWW map with a sticky (monotonic) delete flag.
|
|
|
|
This is the unit CRDT merges operate on. It's deliberately domain-agnostic --
|
|
translating an app's own model (a note, a food-log entry, a workout entry,
|
|
a wake-time value) to and from ``Record`` is each app's job, not this
|
|
library's.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
from crdt_sync._hlc import Hlc
|
|
|
|
Field = tuple[Any, Hlc]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Record:
|
|
"""One CRDT record: an id, per-field LWW values, and a sticky tombstone.
|
|
|
|
Attributes:
|
|
id: Stable identifier, unique within one ``Log``.
|
|
fields: Field name -> ``(value, Hlc)``. Concurrent edits to the same
|
|
field converge to whichever side has the greater ``Hlc``.
|
|
deleted: Whether this record is tombstoned. Deletion is monotonic:
|
|
once true on either side of a merge, it stays true.
|
|
deleted_hlc: The clock value of the delete, if any -- kept for
|
|
informational/ordering purposes only; it does not participate in
|
|
the merge decision (that's a plain boolean OR, see
|
|
:func:`merge_record`).
|
|
"""
|
|
|
|
id: str
|
|
fields: dict[str, Field]
|
|
deleted: bool = False
|
|
deleted_hlc: Hlc | None = None
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
"""Serialize to a JSON-compatible dict."""
|
|
return {
|
|
"id": self.id,
|
|
"fields": {
|
|
name: [value, hlc.to_str()]
|
|
for name, (value, hlc) in self.fields.items()
|
|
},
|
|
"deleted": self.deleted,
|
|
"deleted_hlc": self.deleted_hlc.to_str()
|
|
if self.deleted_hlc is not None
|
|
else None,
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: dict[str, Any]) -> Record:
|
|
"""Parse the format produced by :meth:`to_dict`."""
|
|
fields = {
|
|
name: (value, Hlc.from_str(hlc_str))
|
|
for name, (value, hlc_str) in data["fields"].items()
|
|
}
|
|
deleted_hlc_str = data.get("deleted_hlc")
|
|
deleted_hlc = (
|
|
Hlc.from_str(deleted_hlc_str) if deleted_hlc_str is not None else None
|
|
)
|
|
return cls(
|
|
id=data["id"],
|
|
fields=fields,
|
|
deleted=bool(data["deleted"]),
|
|
deleted_hlc=deleted_hlc,
|
|
)
|
|
|
|
|
|
def merge_field(a: Field, b: Field) -> Field:
|
|
"""Return whichever of ``a``, ``b`` has the greater ``Hlc``.
|
|
|
|
Ties (equal ``Hlc``) keep ``a``; two ticks never compare equal across
|
|
different nodes (see :class:`crdt_sync._hlc.Hlc`), so a tie only happens
|
|
when both sides are literally the same tick re-merged, in which case the
|
|
value is expected to be identical anyway.
|
|
"""
|
|
return a if a[1] >= b[1] else b
|
|
|
|
|
|
def _merge_deleted_hlc(a: Record, b: Record) -> Hlc | None:
|
|
"""Return the delete-clock to keep, informational only (see class docs)."""
|
|
if a.deleted and b.deleted:
|
|
if a.deleted_hlc is None:
|
|
return b.deleted_hlc
|
|
if b.deleted_hlc is None:
|
|
return a.deleted_hlc
|
|
return max(a.deleted_hlc, b.deleted_hlc)
|
|
if a.deleted:
|
|
return a.deleted_hlc
|
|
if b.deleted:
|
|
return b.deleted_hlc
|
|
return None
|
|
|
|
|
|
def merge_record(a: Record, b: Record) -> Record:
|
|
"""Merge two versions of the same record.
|
|
|
|
Per-field last-writer-wins over the union of field names, plus a sticky
|
|
delete: ``deleted = a.deleted or b.deleted``, never the other way around.
|
|
A tombstone can never be resurrected by merging in an older, non-deleted
|
|
copy pulled from a device that hasn't seen the delete yet.
|
|
|
|
Commutative and idempotent: ``merge_record(a, b) == merge_record(b, a)``
|
|
and ``merge_record(a, a) == a``.
|
|
|
|
Raises:
|
|
ValueError: If ``a.id != b.id`` -- merging two different records is
|
|
a caller bug, not a case to silently paper over.
|
|
"""
|
|
if a.id != b.id:
|
|
msg = f"cannot merge records with different ids: {a.id!r} != {b.id!r}"
|
|
raise ValueError(msg)
|
|
merged_fields = dict(a.fields)
|
|
for name, b_field in b.fields.items():
|
|
a_field = merged_fields.get(name)
|
|
merged_fields[name] = (
|
|
b_field if a_field is None else merge_field(a_field, b_field)
|
|
)
|
|
return Record(
|
|
id=a.id,
|
|
fields=merged_fields,
|
|
deleted=a.deleted or b.deleted,
|
|
deleted_hlc=_merge_deleted_hlc(a, b),
|
|
)
|