screen-locker/screen_locker/_source_health.py
Krzysztof kuhy Rudnicki ec0946f64e Grade against 3.14 too, not just run on it
`requires-python` and the CI pin already said 3.14, but ruff, mypy and pylint
were still grading against 3.10 -- three tools judging an interpreter nothing
runs any more, and ruff withholding the pyupgrade fixes that only become safe
at the newer target. `pin_ci_python.py` rewrites exactly these three knobs.

What the stale target was hiding: 118 sites on `datetime.timezone.utc` instead
of `datetime.UTC` (UP017), two classes still written with `Generic[...]` rather
than PEP 695 syntax, and ruff's stable formatter applying PEP 758 --
`except (A, B):` becomes `except A, B:`. That last one makes the source
3.14-only at the syntax level, which is what `requires-python = ">=3.14"`
already promised; kuhy chose it deliberately over holding ruff a release back
(2026-08-29).

The PEP 695 fix needed a hand: ruff rewrote `class WidgetGroup(Generic[_W])`
to `class WidgetGroup[W: tk.Misc]` but left the body annotated with the
module-level `_W`, a *different* type variable -- mypy caught it as "a function
returning TypeVar should receive at least one argument containing the same
TypeVar". Renamed the body to the new parameter, gave `FrameGroup.__init__`
the concrete `list[tk.Frame]` it always wrapped, and deleted the two now-dead
module-level TypeVars.

ruff, ruff-format, mypy --strict and both pylint passes clean at the new target.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfnJmxxsZ534AKk16rC2Gr
2026-08-29 11:42:47 +02:00

120 lines
4.4 KiB
Python

"""Why a workout source is untrustworthy, in words a human can act on.
Two different failures produced the same blank screen on 2026-08-24:
* Firebase raised -- the credential no longer authenticated. Loud, but only
in the journal.
* The GitHub mirror did *not* raise. It answered promptly and completely with
data that had stopped changing on 2026-08-15, because the phone had quietly
stopped writing to it. Nothing in the code compared a record's age to now,
so a backend that had been dead for nine days looked identical to a backend
reporting "no workouts yet today".
The second is the dangerous one: an exception at least leaves a traceback. A
silent staleness leaves a confident, wrong answer. Both are surfaced here as
the same kind of finding, so the lock screen can explain itself instead of
accusing the user of skipping a workout they actually did.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from screen_locker._degraded_sources import degraded_sources
# A phone that has synced nothing for this long is not "quiet", it is broken.
# Two days spans a rest day plus a travel day without crying wolf, while still
# catching the nine-day silence that made 2026-08-24 unexplainable.
STALE_AFTER = timedelta(days=2)
@dataclass(frozen=True)
class SourceFinding:
"""One reason a workout source cannot be trusted right now."""
name: str
headline: str
detail: str
def describe_staleness(
name: str, newest: datetime | None, *, now: datetime | None = None
) -> SourceFinding | None:
"""Return a finding when *name*'s newest record is too old, else None.
Args:
name: The backend, as the user would name it ("GitHub mirror").
newest: Timestamp of the most recent record it returned, or None when
it returned nothing at all.
now: Injected for tests.
"""
instant = now if now is not None else datetime.now(tz=UTC)
if newest is None:
return SourceFinding(
name=name,
headline=f"{name} returned no workouts at all",
detail=(
f"{name} answered, but with an empty log. Either nothing has "
"ever synced from your phone, or this backend is not the one "
"your phone writes to."
),
)
age = instant - newest.astimezone(UTC)
if age < STALE_AFTER:
return None
return SourceFinding(
name=name,
headline=f"{name} has not changed in {age.days} days",
detail=(
f"The newest workout {name} knows about is from "
f"{newest.date().isoformat()} — {age.days} days ago. It is "
"answering, so nothing looks broken, but your phone has stopped "
"writing to it. A workout logged today would not appear here."
),
)
def collect_source_findings(
*, newest_synced: datetime | None = None, now: datetime | None = None
) -> list[SourceFinding]:
"""Gather every reason the workout sources may be lying right now.
Combines the two failure shapes: backends that raised (recorded by
``_sync_client``) and backends that answered with stale data.
"""
findings = [
SourceFinding(
name=source.name,
headline=f"{source.name} could not be read at all",
detail=(
f"{source.name} refused the connection: {source.reason}. Your "
"phone syncs workouts there, so anything logged today is "
"invisible to this machine until that is fixed."
),
)
for source in degraded_sources()
]
stale = describe_staleness("the workout sync", newest_synced, now=now)
if stale is not None:
findings.append(stale)
return findings
def explain_findings(findings: list[SourceFinding]) -> str:
"""Render findings as the block of text shown on the lock screen.
Deliberately plain prose: this is read by someone who just finished a
two-hour workout and is being told it does not count. "Sync error" is
not an explanation; naming the backend, the age and the consequence is.
"""
if not findings:
return ""
lines = ["Why this may be wrong:"]
lines.extend(f"• {finding.headline}\n {finding.detail}" for finding in findings)
lines.append(
"If you did work out, this is a sync fault, not a missed workout — "
"use TRY AGAIN, or log it once sync is restored."
)
return "\n".join(lines)