mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-11 17:54:00 +01:00
sickdays: per-date pool, no cap or reason, add/remove today or later only, import_legacy for the migration. LWW state flip per date (no tombstones), Firebase RTDB sickdays-sync/devices/<uuid>/sick_days.json, HMAC audit trail. Readers fail closed (unreadable pool = not sick, logged); writers refuse a corrupt pool. Free-day cross-check via a lazily imported freedays predicate. earned_time: resolve(..., sick=True) floors the final shutdown at 20:00 (capped at the ceiling); gaming, terms and base unchanged. Result dataclasses moved to _resolution.py for the line cap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xfj1sxKH9VBPqpcAni8Jry
146 lines
4.9 KiB
Python
146 lines
4.9 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
"""Pushing and pulling the pool, so a sick day marked on the phone lands on the PC.
|
|
|
|
Sync is a *separate* operation from every decision in :mod:`sickdays._api`.
|
|
Nothing that decides whether a gate fires ever waits on this: a gate reads
|
|
the last-merged local file and moves on, so an unreachable network makes the
|
|
pool stale rather than making a lock hang.
|
|
|
|
The remote is Firebase RTDB, matching every other app in the fleet, with one
|
|
directory per device -- no two devices write the same node, so there is
|
|
nothing to conflict and convergence is the CRDT layer's job.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import TYPE_CHECKING
|
|
|
|
from crdt_sync import (
|
|
ConfigError,
|
|
FileSyncStateStore,
|
|
FirebaseAuthError,
|
|
LogCodec,
|
|
RemoteSyncError,
|
|
RevisionTracking,
|
|
SyncTarget,
|
|
dump_log,
|
|
firebase_client_for,
|
|
load_log,
|
|
sync_log,
|
|
)
|
|
|
|
from sickdays._api import actor_id, load
|
|
from sickdays._constants import (
|
|
APP_NAME,
|
|
SYNC_FILENAME,
|
|
SYNC_PATH_PREFIX,
|
|
)
|
|
from sickdays._paths import Paths, resolve_paths
|
|
from sickdays._store import write_pool
|
|
|
|
if TYPE_CHECKING:
|
|
from crdt_sync import Log, RemoteStore
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class SyncUnavailableError(Exception):
|
|
"""No usable remote right now -- unconfigured, offline, or rejected.
|
|
|
|
Deliberately not a subclass of anything in :mod:`sickdays._errors`: this
|
|
is never a policy refusal, and callers treat it the way they treat a
|
|
network timeout.
|
|
"""
|
|
|
|
|
|
def get_client() -> RemoteStore:
|
|
"""Return a signed-in Firebase client, or raise :class:`SyncUnavailableError`.
|
|
|
|
``ConfigError`` subclasses ``Exception`` directly rather than
|
|
``RemoteSyncError``, so both are caught explicitly -- assuming one covers
|
|
the other has bitten two other apps in this fleet already.
|
|
"""
|
|
try:
|
|
return firebase_client_for(APP_NAME)
|
|
except ConfigError as exc:
|
|
msg = f"sick-day sync is not configured on this machine: {exc}"
|
|
raise SyncUnavailableError(msg) from exc
|
|
except FirebaseAuthError as exc:
|
|
msg = f"sick-day sync credentials were rejected: {exc}"
|
|
raise SyncUnavailableError(msg) from exc
|
|
|
|
|
|
def sync(
|
|
*,
|
|
client: RemoteStore | None = None,
|
|
paths: Paths | None = None,
|
|
) -> Log:
|
|
"""Run one pull/merge/push tick and persist the merged pool.
|
|
|
|
Args:
|
|
client: Override for the remote; ``None`` builds the Firebase one.
|
|
paths: Where the pool, uuid and sync bookkeeping live.
|
|
|
|
Returns:
|
|
The merged pool, as pushed.
|
|
|
|
Raises:
|
|
SyncUnavailableError: If no remote could be reached or authenticated.
|
|
"""
|
|
resolved = resolve_paths(paths)
|
|
target_client = client if client is not None else get_client()
|
|
node = actor_id(resolved)
|
|
local = load(resolved.log)
|
|
state_path = resolved.sync_state
|
|
try:
|
|
merged = sync_log(
|
|
SyncTarget(
|
|
client=target_client,
|
|
device_id=node,
|
|
path_prefix=SYNC_PATH_PREFIX,
|
|
),
|
|
local,
|
|
LogCodec(
|
|
encode=dump_log,
|
|
decode=load_log,
|
|
filename=SYNC_FILENAME,
|
|
commit_message="sickdays: update pool",
|
|
),
|
|
RevisionTracking(state_store=FileSyncStateStore(state_path)),
|
|
)
|
|
except RemoteSyncError as exc:
|
|
msg = f"sick-day sync could not reach the remote: {exc}"
|
|
raise SyncUnavailableError(msg) from exc
|
|
write_pool(resolved.log, merged)
|
|
return merged
|
|
|
|
|
|
def sync_quietly(*, paths: Paths | None = None) -> bool:
|
|
"""Sync, swallowing every failure. Returns whether it actually ran.
|
|
|
|
For callers on a timer that want a fresher pool but must not fail, and
|
|
must not say anything, when the sync cannot happen.
|
|
|
|
The broad catch is deliberate and is the whole point of this function.
|
|
Today ``FirebaseAuthError`` happens to subclass ``RemoteSyncError`` and
|
|
``requests``' ``JSONDecodeError`` happens to subclass
|
|
``RequestException``, so the narrow handler above would in fact catch an
|
|
expired refresh token or a malformed payload. But that is an upstream
|
|
class hierarchy, not a promise to this package -- and a background sync
|
|
whose contract is "never fail" must not have that contract hold only by
|
|
coincidence. Anything unexpected is logged with its traceback, so a real
|
|
bug is loud in the journal while the timer stays green.
|
|
"""
|
|
try:
|
|
sync(paths=paths)
|
|
except SyncUnavailableError as exc:
|
|
# The expected case -- offline, or not configured yet. Debug level:
|
|
# this is normal on a laptop and must not fill the journal.
|
|
_logger.debug("sick-day sync skipped: %s", exc)
|
|
return False
|
|
except Exception: # pylint: disable=broad-exception-caught
|
|
_logger.exception("sick-day sync failed unexpectedly")
|
|
return False
|
|
return True
|