screen-locker/screen_locker/_cli.py
Krzysztof kuhy Rudnicki 9131c8086e Log manual workouts headlessly, with the form's rules intact
The phone form was the only way to log a manual workout, so logging one
depended on the phone's foreground. Another app taking focus mid-entry could
interleave a field (seen: "Played 6 games 1 did nPlayed 6 games...") or drop
one, which is a bad property for a record that spends budget.

`--log-manual-workout` takes every field as a flag and needs no UI, no phone
and no app. It is emphatically NOT a way to log a workout without evidence:
the draft goes through the same `validate_manual_workout`, so a missing or
too-short field refuses the whole run, writes nothing and exits non-zero with
the concrete reason. Sport-specific rules survive too -- table tennis still
demands its match counts, racket and balls.

Routed through `ingest_manual_records` rather than writing `log.json`
directly, so validation, the rolling 2/week budget, HMAC signing and
workout_id dedup are the shared implementation with the sync path instead of
a second copy that can drift. That is the difference from the deleted
`log_manual_workout.py`, which bypassed the checks: here the budget refuses a
third entry in a week exactly as the phone does, and re-running an identical
command is a no-op rather than a duplicate.

"Today" is UTC, matching how the budget windows compute it -- a local date
near midnight would file a workout in a different window than it is counted in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWq9MRemX9ZWvNmnhNWUP2
2026-08-27 20:50:52 +02:00

81 lines
3.1 KiB
Python

"""Command-line entry points for ``screen_lock.py``.
Split out of ``screen_lock.py`` to keep every file under the 250-line cap.
The argument handling lives here; ``screen_lock.py`` keeps only the
``ScreenLocker`` class and a one-line ``__main__`` delegation, so the module
that every mixin imports stays small.
"""
from __future__ import annotations
import logging
from pathlib import Path
import sys
from typing import TYPE_CHECKING
from screen_locker._decision_log import record_no_decision
from screen_locker._manual_cli import run_manual_log
from screen_locker._status import run_status
if TYPE_CHECKING:
from screen_locker.screen_lock import ScreenLocker
__all__ = ["main"]
_LOG_FILE_NAME = "log.json"
_LOG_MANUAL_FLAG = "--log-manual-workout"
def _headless_locker(locker_cls: type[ScreenLocker]) -> ScreenLocker:
"""A ScreenLocker with ``__init__`` bypassed, for the no-UI subcommands.
``--status`` and ``--sync-only`` touch only ``log_file`` and
``workout_data``; constructing the real object would build a Tk UI and,
in the ``--production`` case, grab the screen.
"""
locker = object.__new__(locker_cls)
locker.log_file = Path(__file__).resolve().parent / _LOG_FILE_NAME
locker.workout_data = {}
return locker
def main(locker_cls: type[ScreenLocker], argv: list[str]) -> None:
"""Dispatch on ``argv`` and run the requested mode."""
# Configure logging for EVERY mode, not just --sync-only. This used to sit
# inside the --sync-only branch, so `--production` -- the mode systemd
# actually runs -- had no handler and fell back to lastResort, which drops
# everything below WARNING. The result: zero INFO lines from
# workout-locker.service since June, and a thirteen-day enforcement outage
# that left no trace at all. Never narrow this back to one branch.
logging.basicConfig(
level=logging.INFO,
format="%(levelname)s %(name)s: %(message)s",
)
if "--status" in argv:
run_status(_headless_locker(locker_cls))
if _LOG_MANUAL_FLAG in argv:
# Headless manual logging: no Tk, no phone, no app in the foreground.
# Everything after the flag is the workout's evidence, validated by
# the same rules the phone form applies.
rest = argv[argv.index(_LOG_MANUAL_FLAG) + 1 :]
sys.exit(run_manual_log(_headless_locker(locker_cls).log_file, rest))
if "--sync-only" in argv:
# Headless sync for the timer unit: pull other devices' workouts and
# apply their credit, with no Tk and no lock screen. Sync used to run
# ONLY at process start, so a workout finished after login was not seen
# until the next login — the timer closes that window.
_headless_locker(locker_cls).sync_now()
# Say so explicitly: --sync-only NEVER locks, so a reader tracing "why
# didn't it lock?" through the journal must not mistake a sync run for
# an enforcement run that decided to skip.
record_no_decision("--sync-only")
sys.exit(0)
locker = locker_cls(
demo_mode="--production" not in argv,
verify_only="--verify-workout" in argv,
)
locker.run()