mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:27:13 +01:00
`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
120 lines
4.4 KiB
Python
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)
|