screen-locker/screen_locker/_credit_slot.py
Krzysztof kuhy Rudnicki f3875302d7 Fix pylint findings in source and scripts
Real fixes: split over-long functions (lock-decision chain, manual
sync, validation, phone verification, status), hoist a dead lazy
import, context-manage Xvfb and temp dirs, add encodings, share the
silent-failure argv loop.

lock_grab_handoff read lock._recovery, which gatelock's LockWindow
does not have (its public property is recovery); a contract test now
checks every attribute the handoff reads against the real class.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU
2026-11-01 21:11:34 +01:00

87 lines
3.3 KiB
Python

"""Which credit a newly written workout earns, decided by slot like the reset.
The live credit path used to ask "was any counted workout already logged
today?" -- so the two StrongLifts ingestion paths of ONE session
(``pc_workout_verified`` from the session sync, ``phone_verified`` from the
early-bird auto-upgrade) earned the first-unit +2h and then a further-unit +1h
(2026-10-09; also 07-17 and 07-27 in ``duplicate_credits.json``). The daily
reset counts the same day with :func:`~screen_locker._weekly_check.count_day_credits`,
which collapses them onto one slot. Deciding the live credit with that same
counter makes the two agree by construction.
"""
from __future__ import annotations
from contextlib import ExitStack, contextmanager
import enum
import logging
from typing import TYPE_CHECKING, Any, Final
from screen_locker._bonus_lock import bonus_lock
from screen_locker._weekly_check import count_day_credits
if TYPE_CHECKING:
from collections.abc import Iterator, Mapping
from pathlib import Path
_logger: Final = logging.getLogger(__name__)
# count_day_credits only uses the date to build slot keys, and both counts
# below are taken over the same day, so any fixed label is equivalent.
_SAME_DAY: Final = "day"
class CreditSlot(enum.Enum):
"""What the newest entry of a day earns."""
NONE = "earns no credit (not a counted workout type)"
FIRST = "first credit slot of the day"
FURTHER = "a new, distinct credit slot after the first"
OCCUPIED = "a slot an earlier entry of the day already credited"
def credit_slot(
prior_entries: list[dict[str, Any]], workout_data: Mapping[str, Any]
) -> CreditSlot:
"""Classify ``workout_data`` against the day's entries written before it.
Args:
prior_entries: The day's log entries before the new one, as returned
by the write chokepoint (snapshotted under the log lock).
workout_data: The new entry's ``workout_data``.
"""
new_entry = {"workout_data": dict(workout_data)}
day = [e for e in prior_entries if isinstance(e, dict)]
before = count_day_credits(_SAME_DAY, day)
with_new = count_day_credits(_SAME_DAY, [*day, new_entry])
if with_new == before:
# No new slot: either it earns nothing at all, or its slot is taken.
return CreditSlot.OCCUPIED if _counts_alone(new_entry) else CreditSlot.NONE
return CreditSlot.FURTHER if before else CreditSlot.FIRST
def _counts_alone(entry: Mapping[str, Any]) -> bool:
"""Whether ``entry`` would earn a credit on an otherwise empty day."""
return count_day_credits(_SAME_DAY, [entry]) == 1
@contextmanager
def locked_shutdown_state(state_file: Path) -> Iterator[None]:
"""Hold :func:`bonus_lock` on ``state_file``; on failure, warn and go on.
The reset and the flat-bonus pass hold the same lock, so a credit can no
longer lose its minutes to their read-add-write. Never nest inside them:
a second flock on a new fd in the same process deadlocks.
"""
with ExitStack() as stack:
try:
stack.enter_context(bonus_lock(state_file))
except OSError as exc:
_logger.warning(
"Could not lock %s (%s) — applying the workout credit without "
"it; a reset running at the same moment could overwrite it",
state_file,
exc,
)
yield