diff --git a/screen_locker/_compliance_predicates.py b/screen_locker/_compliance_predicates.py index 6a20f0e..2592c85 100644 --- a/screen_locker/_compliance_predicates.py +++ b/screen_locker/_compliance_predicates.py @@ -14,7 +14,6 @@ rather than at the old ``_compliance_state`` path. from __future__ import annotations -from datetime import UTC, datetime import json import logging from typing import TYPE_CHECKING @@ -27,6 +26,7 @@ from screen_locker._constants import ( EARLY_BIRD_END_MINUTE, EARLY_BIRD_START_HOUR, ) +from screen_locker._day import today_str from screen_locker._log_io import load_workout_log from screen_locker._sick_tracker import is_sick_day as _is_sick_day from screen_locker._weekly_check import RELAXED_DAY_SKIP_TYPE @@ -41,8 +41,8 @@ _logger = logging.getLogger(__name__) def _today_str() -> str: - """Return today's date as ``YYYY-MM-DD`` in UTC.""" - return datetime.now(tz=UTC).strftime("%Y-%m-%d") + """Return today's LOCAL date as ``YYYY-MM-DD`` (see ``screen_locker._day``).""" + return today_str() def is_scheduled_skip_today(today: date | None = None) -> bool: diff --git a/screen_locker/_compliance_state.py b/screen_locker/_compliance_state.py index 6d010b9..771c042 100644 --- a/screen_locker/_compliance_state.py +++ b/screen_locker/_compliance_state.py @@ -38,6 +38,7 @@ from screen_locker._compliance_trace import ( describe_auto_upgrade_opportunity, describe_degraded_sources, ) +from screen_locker._day import today_str as local_day if TYPE_CHECKING: from collections.abc import Sequence @@ -79,7 +80,7 @@ def explain_lock_decision( reported via ``heat_skip_evaluated=False``, not guessed. """ instant = now if now is not None else datetime.now(tz=UTC) - today_str = instant.astimezone(UTC).strftime("%Y-%m-%d") + today_str = local_day(instant) local_dt = instant.astimezone() local_minutes = local_dt.hour * 60 + local_dt.minute diff --git a/screen_locker/_day.py b/screen_locker/_day.py new file mode 100644 index 0000000..41753e4 --- /dev/null +++ b/screen_locker/_day.py @@ -0,0 +1,32 @@ +"""The one definition of "what day is it" for every day-keyed file. + +``log.json``, ``sick_day_state.json``, ``early_bird_pending.json``, +``wake_state.json`` and the sick history are all keyed by ``YYYY-MM-DD``. +Until 2026-09-13 each writer and reader derived that key on its own, and +most of them used ``datetime.now(tz=UTC)`` while the lock window, the phone +app, RunnerUp verification and the status window used LOCAL time. In CEST +that is a two-hour window after local midnight in which a workout logged +"today" was filed under yesterday — the lock check then looked for today, +found nothing, and locked the PC for a workout that was on disk. + +The day is the **local calendar day**: it is what the user sees on the +clock when they log, what the lock screen names when it says "no workout +for today", and what the phone already writes. Every module goes through +:func:`today_str` so the two can never disagree again; tests freeze the +clock by patching ``screen_locker._day.datetime``. +""" + +from __future__ import annotations + +from datetime import UTC, datetime + + +def today_str(now: datetime | None = None) -> str: + """Return the local calendar day of *now* (default: this instant) as ``YYYY-MM-DD``. + + *now* may be in any timezone; it is converted to the machine's local zone + first, which is the only conversion that turns ``22:02Z`` on the 12th + into the 13th the user actually logged on. + """ + instant = now if now is not None else datetime.now(tz=UTC) + return instant.astimezone().strftime("%Y-%m-%d") diff --git a/screen_locker/_early_bird.py b/screen_locker/_early_bird.py index 7c56937..80eea9c 100644 --- a/screen_locker/_early_bird.py +++ b/screen_locker/_early_bird.py @@ -23,14 +23,15 @@ from screen_locker._constants import ( EARLY_BIRD_START_HOUR, EXTRA_BENEFITS_FILE, ) +from screen_locker._day import today_str from screen_locker._extra_benefits import has_extended_early_bird _logger = logging.getLogger(__name__) def _today_str() -> str: - """Return today's date as YYYY-MM-DD in UTC.""" - return datetime.now(tz=UTC).strftime("%Y-%m-%d") + """Return today's LOCAL date as YYYY-MM-DD (see ``screen_locker._day``).""" + return today_str() class EarlyBirdMixin: diff --git a/screen_locker/_log_mixin.py b/screen_locker/_log_mixin.py index febe5fb..d655b03 100644 --- a/screen_locker/_log_mixin.py +++ b/screen_locker/_log_mixin.py @@ -11,6 +11,7 @@ from typing import TYPE_CHECKING from gatelock.log_integrity import compute_entry_hmac from screen_locker import _compliance_state +from screen_locker._day import today_str from screen_locker._log_io import load_workout_log from screen_locker._manual_workout import MANUAL_WORKOUT_TYPE, manual_sync_record_id @@ -78,6 +79,22 @@ def _entry_workout_id(date: str, entry: Mapping[str, object]) -> str | None: return _derive_workout_id(date, workout_data) +def _is_logged_anywhere(logs: dict[str, list[dict]], workout_id: str) -> bool: + """True if any day already holds an entry with this ``workout_id``. + + Searched across ALL days, not just the one being written: a workout id is + globally unique (it embeds its own date), and a copy of the same workout + can arrive keyed on a different day — e.g. an entry moved to the local day + it was really logged on, then its synced twin coming back stamped with + the old UTC day. Per-day dedup would append that twin as a duplicate. + """ + return any( + _entry_workout_id(day, e) == workout_id + for day, entries in logs.items() + for e in entries + ) + + def write_signed_entry( log_file: Path, date: str, workout_data: Mapping[str, object] ) -> RecordResult: @@ -87,7 +104,7 @@ def write_signed_entry( workout) and manual-sync ingestion, which files a synced workout under its OWN date so the weekly count places it in the right ISO week. The log is day-keyed but now holds MULTIPLE entries per day: this APPENDS rather than - overwrites. If an entry with the same ``workout_id`` already exists for the + overwrites. If an entry with the same ``workout_id`` already exists on ANY day, it is a no-op — the log itself is the idempotency store, so re-verifying the same workout never double-records. Every write normalizes the whole file to the list shape, progressively migrating legacy entries. @@ -100,9 +117,7 @@ def write_signed_entry( prior = list(entries) workout_id = _derive_workout_id(date, workout_data) - if workout_id is not None and any( - _entry_workout_id(date, e) == workout_id for e in entries - ): + if workout_id is not None and _is_logged_anywhere(logs, workout_id): return RecordResult(appended=False, prior_entries=prior) entry: dict[str, object] = { @@ -153,5 +168,5 @@ class LogMixin: credit only for a genuinely new workout and scale it by whether today already had one. """ - today = datetime.now(tz=UTC).strftime("%Y-%m-%d") + today = today_str() return write_signed_entry(self.log_file, today, self.workout_data) diff --git a/screen_locker/_manual_cli.py b/screen_locker/_manual_cli.py index b27865e..ce2f069 100644 --- a/screen_locker/_manual_cli.py +++ b/screen_locker/_manual_cli.py @@ -18,10 +18,10 @@ that can drift. from __future__ import annotations import argparse -from datetime import UTC, datetime import logging from typing import TYPE_CHECKING +from screen_locker._day import today_str from screen_locker._manual_sync import ingest_manual_records from screen_locker._manual_workout import ( SPORT_CHOICES, @@ -129,10 +129,10 @@ def run_manual_log(log_file: Path, argv: Sequence[str]) -> int: add_manual_arguments(parser) args = parser.parse_args(list(argv)) - # UTC, matching how the budget windows compute "today" -- a local date - # near midnight would file the workout in a different window than the one - # it is then counted in. - date = args.date or datetime.now(tz=UTC).strftime("%Y-%m-%d") + # The LOCAL day, like every other day-key (screen_locker._day): the old + # UTC default filed a workout logged at 00:02 CEST under yesterday, and the + # lock check then could not find it "today". + date = args.date or today_str() draft = _draft(args) # Validated here as well as inside the ingest, purely so a person running # this by hand gets the concrete field error on stderr and a non-zero exit diff --git a/screen_locker/_manual_workout_types.py b/screen_locker/_manual_workout_types.py index e4b0b27..1e01489 100644 --- a/screen_locker/_manual_workout_types.py +++ b/screen_locker/_manual_workout_types.py @@ -19,6 +19,7 @@ from screen_locker._constants import ( MANUAL_WORKOUT_BUDGET_PER_7_DAYS, MANUAL_WORKOUT_BUDGET_PER_30_DAYS, ) +from screen_locker._day import today_str from screen_locker._log_io import load_workout_log if TYPE_CHECKING: @@ -38,8 +39,8 @@ SPORT_LABELS: dict[str, str] = { def _today_iso() -> str: - """Return today's date as ``YYYY-MM-DD`` (UTC).""" - return datetime.now(tz=UTC).strftime("%Y-%m-%d") + """Return today's LOCAL date as ``YYYY-MM-DD`` (see ``screen_locker._day``).""" + return today_str() def _parse_iso(date_str: str) -> datetime | None: diff --git a/screen_locker/_phone_verification.py b/screen_locker/_phone_verification.py index 70a39b6..44af237 100644 --- a/screen_locker/_phone_verification.py +++ b/screen_locker/_phone_verification.py @@ -8,7 +8,6 @@ import json import logging from pathlib import Path import tempfile -import time from screen_locker._adb_transport import AdbTransportMixin from screen_locker._constants import ( @@ -16,6 +15,7 @@ from screen_locker._constants import ( WORKOUT_APP_JSON_REMOTES, WORKOUT_DURATION_ACCEPT_MINUTES, ) +from screen_locker._day import today_str from screen_locker._http_workout_fetch import HttpWorkoutFetchMixin from screen_locker._log_mixin import write_signed_entry from screen_locker._time_check import check_clock_skew @@ -41,7 +41,7 @@ class PhoneVerificationMixin(AdbTransportMixin, HttpWorkoutFetchMixin): only fall back to a stale/older payload if no candidate is from today. """ tmp = Path(tempfile.gettempdir()) / "workout_result.json" - today = time.strftime("%Y-%m-%d") + today = today_str() first_parsed: dict | None = None for remote in WORKOUT_APP_JSON_REMOTES: ok, _ = self._run_adb(["pull", remote, str(tmp)]) @@ -91,7 +91,7 @@ class PhoneVerificationMixin(AdbTransportMixin, HttpWorkoutFetchMixin): must reflect a workout done *right now*. For crediting a workout found dated earlier in the week, see ``_try_fill_stronglifts_for_week``. """ - today = time.strftime("%Y-%m-%d") + today = today_str() if data.get("date") != today: return "stale", f"Workout JSON is from {data.get('date')}, not today" return self._validate_json_content(data) diff --git a/screen_locker/_runnerup_verification.py b/screen_locker/_runnerup_verification.py index 2c72261..69cba52 100644 --- a/screen_locker/_runnerup_verification.py +++ b/screen_locker/_runnerup_verification.py @@ -7,7 +7,6 @@ Root DB fallback lives in ``_runnerup_db.py``. from __future__ import annotations -from datetime import UTC, datetime import logging from typing import Any @@ -18,6 +17,7 @@ from screen_locker._constants import ( RUNNERUP_EXPORT_DIRS, WORKOUT_DURATION_ACCEPT_MINUTES, ) +from screen_locker._day import today_str from screen_locker._runnerup_backfill import RunnerUpBackfillMixin from screen_locker._runnerup_db import RunnerUpDbMixin from screen_locker._runnerup_tcx import RunnerUpTcxMixin @@ -77,7 +77,7 @@ class RunnerUpVerificationMixin( fails validation), or ``None`` if no today's file exists at all (caller should try the root DB path instead). """ - today = datetime.now(tz=UTC).astimezone().strftime("%Y-%m-%d") + today = today_str() exports = self._find_runnerup_exports_for_date(today) if not exports: _logger.warning( diff --git a/screen_locker/_shutdown.py b/screen_locker/_shutdown.py index 3212586..786083c 100644 --- a/screen_locker/_shutdown.py +++ b/screen_locker/_shutdown.py @@ -2,7 +2,6 @@ from __future__ import annotations -from datetime import UTC, datetime import logging import subprocess from typing import TYPE_CHECKING @@ -11,6 +10,7 @@ from screen_locker._constants import ( ADJUST_SHUTDOWN_SCRIPT, SHUTDOWN_CONFIG_FILE, ) +from screen_locker._day import today_str from screen_locker._shutdown_sick_state import SickDayStateMixin from screen_locker._wake_alarm import WakeAlarmMixin @@ -76,7 +76,7 @@ class ShutdownMixin(SickDayStateMixin, WakeAlarmMixin): Returns True if successful, False otherwise. """ - today = datetime.now(tz=UTC).strftime("%Y-%m-%d") + today = today_str() self._restore_original_config_if_needed() if self._sick_mode_used_today(): _logger.warning("Sick mode already used today") diff --git a/screen_locker/_shutdown_base.py b/screen_locker/_shutdown_base.py index 9ecbbd5..5d066e7 100644 --- a/screen_locker/_shutdown_base.py +++ b/screen_locker/_shutdown_base.py @@ -10,11 +10,12 @@ overwrite the fresh base when it runs later in the same startup. from __future__ import annotations -from datetime import UTC, datetime import json import logging from typing import TYPE_CHECKING, Any +from screen_locker._day import today_str + if TYPE_CHECKING: from pathlib import Path @@ -62,7 +63,7 @@ def reset_to_base_if_new_day( Returns True if a reset was performed, False if today was already reset. """ - today = datetime.now(tz=UTC).strftime("%Y-%m-%d") + today = today_str() if state_file.exists(): try: diff --git a/screen_locker/_shutdown_sick_state.py b/screen_locker/_shutdown_sick_state.py index 88213ff..609aaf0 100644 --- a/screen_locker/_shutdown_sick_state.py +++ b/screen_locker/_shutdown_sick_state.py @@ -10,11 +10,11 @@ earlier, so the following day can put them back. from __future__ import annotations -from datetime import UTC, datetime import json import logging from screen_locker._constants import SICK_DAY_STATE_FILE +from screen_locker._day import today_str _logger = logging.getLogger(__name__) @@ -30,7 +30,7 @@ class SickDayStateMixin: try: with SICK_DAY_STATE_FILE.open() as f: state = json.load(f) - today = datetime.now(tz=UTC).strftime("%Y-%m-%d") + today = today_str() return state.get("date") == today except (OSError, json.JSONDecodeError) as exc: _logger.warning( @@ -112,7 +112,7 @@ class SickDayStateMixin: if loaded is None: return state_date, orig_mw, orig_ts = loaded - today = datetime.now(tz=UTC).strftime("%Y-%m-%d") + today = today_str() if state_date != today: self._write_restored_config(orig_mw, orig_ts, state_date) except (OSError, json.JSONDecodeError) as e: diff --git a/screen_locker/_sick_budget.py b/screen_locker/_sick_budget.py index 428944e..1fe4f3e 100644 --- a/screen_locker/_sick_budget.py +++ b/screen_locker/_sick_budget.py @@ -22,6 +22,7 @@ from screen_locker._constants import ( SICK_LOCKOUT_MULTIPLIER_PER_RECENT, SICK_LOCKOUT_SECONDS, ) +from screen_locker._day import today_str _logger = logging.getLogger(__name__) @@ -43,8 +44,8 @@ class SickHistory: def _today_iso() -> str: - """Return today's date as ``YYYY-MM-DD`` (UTC).""" - return datetime.now(tz=UTC).strftime("%Y-%m-%d") + """Return today's LOCAL date as ``YYYY-MM-DD`` (see ``screen_locker._day``).""" + return today_str() def _parse_iso(date_str: str) -> datetime | None: diff --git a/screen_locker/_wake_state.py b/screen_locker/_wake_state.py index 0116287..6806a17 100644 --- a/screen_locker/_wake_state.py +++ b/screen_locker/_wake_state.py @@ -11,20 +11,20 @@ by patching ``screen_locker._wake_state.WAKE_STATE_FILE``. from __future__ import annotations -from datetime import UTC, datetime import json import logging from gatelock.log_integrity import verify_entry_hmac from screen_locker._constants import WAKE_STATE_FILE +from screen_locker._day import today_str _logger = logging.getLogger(__name__) def _today_str() -> str: - """Return today's date as YYYY-MM-DD in UTC.""" - return datetime.now(tz=UTC).strftime("%Y-%m-%d") + """Return today's LOCAL date as YYYY-MM-DD (see ``screen_locker._day``).""" + return today_str() def load_wake_state() -> dict[str, object] | None: diff --git a/screen_locker/tests/test_local_day_key.py b/screen_locker/tests/test_local_day_key.py new file mode 100644 index 0000000..0768da1 --- /dev/null +++ b/screen_locker/tests/test_local_day_key.py @@ -0,0 +1,140 @@ +"""The workout day is the LOCAL calendar day, on every writer and reader. + +Regression for 2026-09-13: a manual workout logged at 00:02 local (CEST, +``22:02Z`` the day before) was filed under the previous day because every +day-key derivation used ``datetime.now(tz=UTC)``. The lock check then looked +for the local date, found nothing, and locked the PC — while the trailing +window budget (which does not key on an exact date) happily counted it. Any +log between 00:00 and 02:00 local landed on yesterday. + +Each test freezes the clock at that exact instant through +``screen_locker._day`` so the same mismatch can never come back on one of the +fourteen former ``now(tz=UTC)`` sites without failing here. +""" + +from __future__ import annotations + +from datetime import UTC, datetime, tzinfo +import json +import time +from typing import TYPE_CHECKING +from unittest.mock import patch + +import pytest + +from screen_locker import _day +from screen_locker._compliance_predicates import has_logged_today +from screen_locker._compliance_state import explain_lock_decision +from screen_locker._log_io import load_workout_log +from screen_locker._log_mixin import write_signed_entry +from screen_locker._sick_tracker import SickHistory + +if TYPE_CHECKING: + from collections.abc import Iterator + from pathlib import Path + +# 00:02:35 on 2026-09-13 in Europe/Warsaw — the football was logged then. +JUST_AFTER_MIDNIGHT_UTC = datetime(2026, 9, 12, 22, 2, 35, tzinfo=UTC) +LOCAL_DAY = "2026-09-13" +UTC_DAY = "2026-09-12" + + +@pytest.fixture +def warsaw_after_midnight(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + """Pin TZ to Europe/Warsaw and the clock to 00:02:35 local on the 13th.""" + monkeypatch.setenv("TZ", "Europe/Warsaw") + time.tzset() + + class _FrozenClock: + """Stands in for ``datetime`` inside ``_day``; only ``now`` is called.""" + + @staticmethod + def now(tz: tzinfo | None = None) -> datetime: + return JUST_AFTER_MIDNIGHT_UTC.astimezone(tz or UTC) + + monkeypatch.setattr(_day, "datetime", _FrozenClock) + yield + monkeypatch.delenv("TZ") + time.tzset() + + +class TestTodayStr: + """``_day.today_str`` is the single source of "what day is it".""" + + def test_uses_the_local_calendar_day(self, warsaw_after_midnight: None) -> None: + assert _day.today_str() == LOCAL_DAY + + def test_explicit_instant_is_converted_to_local( + self, warsaw_after_midnight: None + ) -> None: + assert _day.today_str(JUST_AFTER_MIDNIGHT_UTC) == LOCAL_DAY + + +class TestAfterMidnightLog: + """A workout logged just after local midnight counts for the new day.""" + + def test_write_and_lock_check_agree( + self, tmp_path: Path, warsaw_after_midnight: None + ) -> None: + log_file = tmp_path / "log.json" + write_signed_entry( + log_file, + _day.today_str(), + {"type": "manual_workout", "start_time": "14:30"}, + ) + assert list(load_workout_log(log_file)) == [LOCAL_DAY] + with patch( + "screen_locker._compliance_predicates.verify_entry_hmac", return_value=True + ): + assert has_logged_today(log_file) is True + + def test_explain_sees_the_workout( + self, tmp_path: Path, warsaw_after_midnight: None + ) -> None: + """The read-only decision chain keys on the same local day.""" + log_file = tmp_path / "log.json" + log_file.write_text(json.dumps({LOCAL_DAY: [{"workout_data": {"type": "x"}}]})) + with patch( + "screen_locker._compliance_predicates.verify_entry_hmac", return_value=True + ): + result = explain_lock_decision( + log_file=log_file, + early_bird_pending_file=tmp_path / "early_bird_pending.json", + sick_history=SickHistory(), + extended_early_bird=False, + weekly_minimum_met=True, + relaxed_day=False, + now=JUST_AFTER_MIDNIGHT_UTC, + ) + assert result.fired is False + assert result.stage == "already_logged" + + def test_utc_day_entry_does_not_count( + self, tmp_path: Path, warsaw_after_midnight: None + ) -> None: + """An entry filed under the UTC day is yesterday's, not today's.""" + log_file = tmp_path / "log.json" + log_file.write_text(json.dumps({UTC_DAY: [{"workout_data": {"type": "x"}}]})) + with patch( + "screen_locker._compliance_predicates.verify_entry_hmac", return_value=True + ): + assert has_logged_today(log_file) is False + + +class TestCrossDayDedup: + """``write_signed_entry`` dedups by workout_id across ALL days. + + Moving an entry to its correct day must not let its synced copy re-ingest + under the old key: the id is globally unique, so the log is searched + whole, not per day. + """ + + def test_same_workout_id_on_another_day_is_not_appended( + self, tmp_path: Path + ) -> None: + log_file = tmp_path / "log.json" + data = {"type": "manual_workout", "workout_id": "manual:2026-09-12T14:30"} + assert write_signed_entry(log_file, LOCAL_DAY, data).appended is True + result = write_signed_entry(log_file, UTC_DAY, data) + assert result.appended is False + assert UTC_DAY not in load_workout_log(log_file) diff --git a/scripts/rekey_log_entry.py b/scripts/rekey_log_entry.py new file mode 100755 index 0000000..410950c --- /dev/null +++ b/scripts/rekey_log_entry.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +r"""Move one ``log.json`` entry to the day it was really logged on. + +Until 2026-09-13 the PC filed workouts under the UTC day, so anything logged +between local midnight and 02:00 CEST landed on *yesterday*. The code now +keys on the local day (``screen_locker._day``); this script repairs an entry +that was filed under the wrong key before that fix. + +The HMAC signs ``timestamp`` + ``workout_data`` + ``workout_id`` — never the +day key — so the entry moves verbatim and still verifies. Its ``workout_id`` +is kept too: the write chokepoint dedups by id across all days, so the synced +copy (still stamped with the old day) is recognised rather than re-ingested. + +Reads and writes ``log.json`` directly rather than importing ``screen_locker``, +like the other scripts here: recovery must work while the package is mid-edit. + +Usage: + python3 scripts/rekey_log_entry.py --workout-id manual:2026-09-12T14:30 \\ + --to 2026-09-13 +""" + +from __future__ import annotations + +import argparse +import json +import logging +from pathlib import Path +import sys + +LOG_FILE = Path(__file__).resolve().parent.parent / "screen_locker" / "log.json" + +_logger = logging.getLogger(__name__) + + +def _report(message: str) -> None: + """Write one line to stdout (matches the other scripts in this directory).""" + sys.stdout.write(f"{message}\n") + + +def rekey(log: dict, workout_id: str, to_day: str) -> str: + """Move the entry carrying ``workout_id`` under ``to_day`` in place. + + Idempotent: an entry already under ``to_day`` is left alone. Raises + ``LookupError`` when the id is not in the log at all — a silent no-op + there would let a caller revoke a sick day against nothing. + """ + for day, entries in log.items(): + if not isinstance(entries, list): + continue + for entry in entries: + if entry.get("workout_id") != workout_id: + continue + if day == to_day: + return f" {workout_id}: already under {to_day} — nothing to do" + entries.remove(entry) + if not entries: + del log[day] + log.setdefault(to_day, []).append(entry) + return f" {workout_id}: moved {day} -> {to_day}" + msg = f"{workout_id} is not in the log" + raise LookupError(msg) + + +def main() -> int: + """Re-key one entry and rewrite the log, or exit non-zero having changed nothing.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--workout-id", required=True, help="entry's workout_id") + parser.add_argument("--to", required=True, help="YYYY-MM-DD to file it under") + parser.add_argument("--log-file", default=str(LOG_FILE)) + args = parser.parse_args() + + log_path = Path(args.log_file) + try: + log = json.loads(log_path.read_text()) + except (OSError, json.JSONDecodeError) as exc: + _logger.exception("Cannot read %s", log_path) + _report(f"ERROR: cannot read {log_path}: {exc}") + return 1 + try: + outcome = rekey(log, args.workout_id, args.to) + except LookupError as exc: + _logger.exception("Nothing changed") + _report(f"ERROR: {exc} — nothing changed") + return 1 + try: + log_path.write_text(json.dumps(log, indent=2)) + except OSError as exc: + _logger.exception("Could not write %s", log_path) + _report(f"ERROR: could not write {log_path}: {exc}") + return 1 + _report(outcome) + return 0 + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") + sys.exit(main())