mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 19:07:03 +01:00
Read the phone's alarm from Firebase, keeping GitHub as the mirror
The seventh and last app on the GitHub-repo transport. fetch_remote_alarm() is now `_fetch_firebase_alarm() or _fetch_github_alarm()`, so retiring the mirror later is deleting one branch and three constants. Two things made this look harder than it was. The first was the standing "_sync.py is stdlib-only" rule: crdt_sync pulls in requests, and the service runs /usr/bin/python rather than a venv. But the package already depends on gatelock and python-kasa, and _smart_plug.py already has the pattern for exactly this -- a module-scope import inside try/except ImportError. So the Firebase import is guarded the same way: a dependency that failed to install degrades the read to GitHub instead of killing the per-minute trigger. The GitHub path stays stdlib-only, which is what makes that fallback trustworthy. The second was the shutdown wrapper, which used to do its own authenticated GitHub read. It could not have worked against Firebase -- an ID token lasts about an hour and a shell script at shutdown time has no safe way to refresh one. It now reads the local alarm cache the trigger already keeps fresh (deployed to /usr/local/bin/shutdown), so the backend is invisible to it. install.sh also now refuses to run under sudo, which silently skipped its later steps. Verified against the live database, not just in tests: the phone's alarm is already published at wake-alarm-sync/devices/phone/alarm.json, and Firebase and GitHub both resolve it to 07:45. `_sync --show --no-cache` reports "07:45 (remote)" and the trigger dry-run behaves unchanged. 338 tests at 100% branch coverage, no new lint suppressions. The backend tests moved to test_sync_backends.py to keep both files under the 500-line cap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017FH32BgGQbQLDstBhR26Xa
This commit is contained in:
parent
864fea5022
commit
66bc3a9e55
59
CLAUDE.md
59
CLAUDE.md
@ -44,9 +44,18 @@ the same synced alarm, and sets `rtcwake`. `sleep-hook.sh` (in
|
||||
If you change `WAKE_AFTER_HOURS` in `_constants.py`, you must also update the
|
||||
duplicate constant in `shutdown-wrapper.sh` (`WAKE_AFTER_HOURS=8`) — they are
|
||||
not wired together, by design (the shell wrapper has no Python runtime
|
||||
available at the point it intercepts `shutdown`). The same duplication applies
|
||||
to the token search order: `SYNC_CONFIG_FILE` / `SYNC_TOKEN_FALLBACK_FILES` in
|
||||
`_constants.py` are mirrored by `read_sync_token()` in the wrapper.
|
||||
available at the point it intercepts `shutdown`).
|
||||
|
||||
**The wrapper no longer talks to the network or handles a token.** It reads
|
||||
`~/.config/wake_alarm/alarm_cache.json` — the cache `_sync.py` already
|
||||
refreshes every minute — and falls back to `WAKE_AFTER_HOURS` if that file is
|
||||
absent, malformed, or more than 2 days old. This removed the duplicated token
|
||||
search order (`read_sync_token()` used to mirror `SYNC_CONFIG_FILE` /
|
||||
`SYNC_TOKEN_FALLBACK_FILES`), and it is what makes the wrapper survive the
|
||||
move to Firebase unchanged: an ID token expires in about an hour, and a shell
|
||||
script at shutdown time has no safe way to refresh one. The only coupling left
|
||||
is the cache's `{"hour","minute","fetched_at"}` shape — keep the reader in the
|
||||
wrapper in step with `_write_cache()` in `_sync.py`.
|
||||
|
||||
## `wake-alarm.service` must never auto-start
|
||||
|
||||
@ -63,20 +72,36 @@ auto-start.
|
||||
|
||||
## Reading the phone's alarm
|
||||
|
||||
`_sync.py` is the Python reader; `shutdown-wrapper.sh` is a second, jq-based
|
||||
reader of the **same file**. Keep their parse and token order in step.
|
||||
`_sync.py` is the only reader of the remote alarm. `shutdown-wrapper.sh` reads
|
||||
`_sync.py`'s local cache instead of the remote file, so there is no second
|
||||
remote reader to keep in step.
|
||||
|
||||
- Source of truth: `wake-alarm-sync/devices/phone/alarm.json` in the private
|
||||
`kuhyx/syncs` repo, written by `phone_app/` as a `crdt_sync` log. The value
|
||||
lives at `.alarm.fields.alarm_hour[0]` / `.alarm_minute[0]`.
|
||||
- Token: `~/.config/wake_alarm/sync.json` key `token`, else a sibling app's
|
||||
raw `~/.config/{diet_guard,screen_locker}/sync_token`. Needs `repo` scope —
|
||||
the repo is private, and a token without access returns **404, not 403**.
|
||||
- `_sync` is **stdlib-only** (`urllib.request`) on purpose: the service runs
|
||||
`/usr/bin/python`, so every dependency is a production install-path hazard.
|
||||
- Source of truth: `wake-alarm-sync/devices/phone/alarm.json`, written by
|
||||
`phone_app/` as a `crdt_sync` log. The value lives at
|
||||
`.alarm.fields.alarm_hour[0]` / `.alarm_minute[0]`. **The same logical path
|
||||
on both backends** — Firebase's namespace mirrors the repo layout — so only
|
||||
the transport differs, never the parse.
|
||||
- **Firebase first, GitHub as the cutover mirror** (`fetch_remote_alarm()` is
|
||||
just `_fetch_firebase_alarm() or _fetch_github_alarm()`). Retiring the
|
||||
mirror is deleting the GitHub branch plus `SYNC_API_BASE`/`SYNC_REPO_*`.
|
||||
- Firebase config: the shared `~/.config/crdt-sync/firebase.json`; the
|
||||
refresh token is cached per app under `SYNC_APP_NAME` (`wake_alarm`), so
|
||||
this app's credential is separate from its siblings'. The config file is
|
||||
checked **before** anything is constructed, so an unconfigured machine
|
||||
never touches the network.
|
||||
- GitHub token (fallback path only): `~/.config/wake_alarm/sync.json` key
|
||||
`token`, else a sibling app's raw
|
||||
`~/.config/{diet_guard,screen_locker}/sync_token`. Needs `repo` scope — the
|
||||
repo is private, and a token without access returns **404, not 403**.
|
||||
- `crdt_sync` is imported at module scope but **guarded by
|
||||
`try/except ImportError`**, exactly like `python-kasa` in `_smart_plug.py`:
|
||||
the service runs `/usr/bin/python`, not a venv, so a dependency that failed
|
||||
to install must degrade the read to the GitHub path rather than crash the
|
||||
per-minute trigger. The GitHub path stays stdlib-only for that reason.
|
||||
Verify with `/usr/bin/python3 -c "import crdt_sync"`, not the dev venv.
|
||||
- Resolution chain in `get_alarm()`: fresh cache (< 5 min) → live fetch →
|
||||
stale cache → `FALLBACK_ALARM_HOUR`. The cache
|
||||
(`~/.config/wake_alarm/alarm_cache.json`) is what bounds GitHub reads to
|
||||
(`~/.config/wake_alarm/alarm_cache.json`) is what bounds remote reads to
|
||||
~288/day despite the per-minute tick, and what keeps a network blip from
|
||||
costing a wake-up.
|
||||
- **Degraded reads log at INFO, not WARNING.** `get_alarm()` runs every minute,
|
||||
@ -177,8 +202,10 @@ Note the dev venv lacks `gatelock`, so anything importing `_state` (including
|
||||
## Do NOT
|
||||
|
||||
- Don't add a dependency without doing the production install-path check
|
||||
above. `_sync.py` is stdlib-only for exactly this reason — don't "improve"
|
||||
it to `requests`.
|
||||
above. `_sync.py`'s **GitHub** path is stdlib-only for exactly this reason —
|
||||
don't "improve" it to `requests`, and don't make the `crdt_sync` import
|
||||
unconditional: the `try/except ImportError` guard is what keeps a failed
|
||||
install from killing the trigger.
|
||||
- Don't forget the orchestrator when changing this package's module path or
|
||||
invocation — see "The morning_routine orchestrator lives elsewhere" above.
|
||||
- Don't give `wake-alarm.service` an `[Install]` section or enable it. It
|
||||
|
||||
14
install.sh
14
install.sh
@ -17,6 +17,20 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Refuse to run under sudo. This script must run as the logged-in user: it
|
||||
# installs *user* systemd units and calls `sudo` itself for the few root-owned
|
||||
# pieces. Under sudo, XDG_RUNTIME_DIR and DBUS_SESSION_BUS_ADDRESS are stripped,
|
||||
# so `systemctl --user` fails at step 3 and every later step -- including the
|
||||
# shutdown wrapper at step 6 -- is silently skipped while the earlier pip output
|
||||
# still looks like success. It also runs pip as root, which leaves root-owned
|
||||
# files in ~/.cache/pip. Failing loudly here beats a half-finished install.
|
||||
if [[ -n "${SUDO_USER:-}" || $EUID -eq 0 ]]; then
|
||||
echo "Error: do not run this script with sudo." >&2
|
||||
echo "Run it as your normal user: bash $0" >&2
|
||||
echo "It will prompt for sudo itself where root is actually needed." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Split declare/assign so the command-substitution exit code is not masked (SC2155).
|
||||
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
|
||||
readonly SCRIPT_DIR
|
||||
|
||||
@ -9,6 +9,10 @@ description = "Phone-driven wake alarm with fan ramp and smart-plug control"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.0#subdirectory=gatelock",
|
||||
# Firebase transport for the alarm read. _sync.py imports it lazily and
|
||||
# falls back to its stdlib GitHub path if the import fails, so a broken
|
||||
# install degrades the read instead of killing the per-minute trigger.
|
||||
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.5.1#subdirectory=crdt-sync",
|
||||
"python-kasa>=0.7",
|
||||
]
|
||||
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
# Wake Alarm — runtime + development dependencies
|
||||
# Runtime: tkinter/json/hmac/wave/subprocess (stdlib) plus gatelock and
|
||||
# python-kasa below (python-kasa is optional at runtime; the module degrades
|
||||
# gracefully if it's missing, but is required here for mypy/pylint/tests).
|
||||
# Runtime: tkinter/json/hmac/wave/subprocess (stdlib) plus gatelock,
|
||||
# crdt-sync and python-kasa below. crdt-sync and python-kasa are both
|
||||
# optional at runtime — their modules degrade gracefully if missing (the
|
||||
# alarm read falls back to GitHub, smart-plug control disables itself) — but
|
||||
# both are required here for mypy/pylint/tests.
|
||||
bandit>=1.7.0
|
||||
codespell>=2.2.0
|
||||
coverage>=7.4.0
|
||||
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.5.1#subdirectory=crdt-sync
|
||||
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.0#subdirectory=gatelock
|
||||
mypy>=1.8.0
|
||||
pre-commit>=3.6.0
|
||||
|
||||
@ -12,12 +12,25 @@
|
||||
# minute on the always-on machine and fires at the phone's alarm time without
|
||||
# any rtcwake involvement (see wake_alarm/_trigger.py).
|
||||
#
|
||||
# Wake time is taken from the alarm the phone app pushes to the private
|
||||
# kuhyx/syncs repo via the shared crdt_sync transport (see phone_app/). Falls
|
||||
# back to WAKE_AFTER_HOURS if sync is not configured or unreachable. Token:
|
||||
# ~/.config/wake_alarm/sync.json key "token", else a sibling app's
|
||||
# ~/.config/{diet_guard,screen_locker}/sync_token. Needs "repo" scope, since
|
||||
# the Contents API must read a private repo.
|
||||
# Wake time is read from ~/.config/wake_alarm/alarm_cache.json, the on-disk
|
||||
# cache that wake-alarm-trigger.timer refreshes every minute from the alarm
|
||||
# the phone app pushes. Falls back to WAKE_AFTER_HOURS if that cache is
|
||||
# missing or unusable.
|
||||
#
|
||||
# Deliberately a *local file read*, with no network call and no credentials.
|
||||
# This script runs as /usr/local/bin/shutdown, at the one moment the machine
|
||||
# is going down; a remote fetch there can block on a dying network, and under
|
||||
# Firebase it could not work at all, because an ID token lasts about an hour
|
||||
# and refreshing one is beyond what a shell script should be doing with a
|
||||
# secret. The Python side already holds the credentials and already keeps this
|
||||
# cache fresh, so the backend it syncs against is now invisible here: this
|
||||
# wrapper needs no changes when GitHub is retired.
|
||||
#
|
||||
# The trade-off is bounded and acceptable: a cache older than the trigger's
|
||||
# refresh interval still reflects the last alarm the phone published, and the
|
||||
# alarm changes far more rarely than once a minute. MAX_CACHE_AGE_SECONDS
|
||||
# below only rejects a cache so old it suggests the trigger has been dead for
|
||||
# days, in which case the fixed fallback is the safer answer.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@ -36,66 +49,42 @@ for arg in "$@"; do
|
||||
esac
|
||||
done
|
||||
|
||||
# Resolve the GitHub token, preferring wake_alarm's own JSON config and falling
|
||||
# back to the sibling companion apps' raw single-line token files (one
|
||||
# repo-scoped PAT covers every app syncing through kuhyx/syncs).
|
||||
# Mirrors SYNC_CONFIG_FILE / SYNC_TOKEN_FALLBACK_FILES in
|
||||
# wake_alarm/_constants.py — keep the two readers in step.
|
||||
read_sync_token() {
|
||||
local cfg="$HOME/.config/wake_alarm/sync.json"
|
||||
local token fallback
|
||||
ALARM_CACHE="$HOME/.config/wake_alarm/alarm_cache.json"
|
||||
# Reject a cache this old as evidence the trigger itself has stopped running.
|
||||
# Generous on purpose (2 days): the alarm rarely changes, so a merely stale
|
||||
# cache is still a far better answer than the fixed +8h fallback. Mirrors
|
||||
# ALARM_CACHE_FILE in wake_alarm/_constants.py.
|
||||
MAX_CACHE_AGE_SECONDS=$(( 2 * 24 * 3600 ))
|
||||
|
||||
if [[ -f "$cfg" ]]; then
|
||||
token=$(jq -r '.token // empty' "$cfg" 2>/dev/null) || token=""
|
||||
if [[ -n "$token" ]]; then
|
||||
printf '%s' "$token"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
for fallback in \
|
||||
"$HOME/.config/diet_guard/sync_token" \
|
||||
"$HOME/.config/screen_locker/sync_token"; do
|
||||
[[ -f "$fallback" ]] || continue
|
||||
token=$(tr -d '[:space:]' < "$fallback") || continue
|
||||
if [[ -n "$token" ]]; then
|
||||
printf '%s' "$token"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# Read the alarm time the phone pushed to kuhyx/syncs via crdt_sync.
|
||||
# Returns the Unix epoch (seconds) of the next occurrence of the stored H:M.
|
||||
# Exits non-zero if sync is not configured, unreachable, or malformed.
|
||||
# Read the alarm the phone published, from the local cache the trigger keeps
|
||||
# fresh. Returns the Unix epoch (seconds) of the next occurrence of that H:M.
|
||||
# Exits non-zero if the cache is absent, malformed, or older than
|
||||
# MAX_CACHE_AGE_SECONDS, so the caller can fall back.
|
||||
#
|
||||
# The phone is the sole writer, under the fixed device id "phone", so a single
|
||||
# Contents-API read of its alarm.json is enough (no device discovery / HLC
|
||||
# ordering needed here — that would only matter with multiple writers). The
|
||||
# stored payload is a crdt_sync Log: {"alarm": {"fields": {"alarm_hour":
|
||||
# [H, "<hlc>"], "alarm_minute": [M, "<hlc>"]}, ...}}. The raw media type makes
|
||||
# the Contents API return that JSON directly, so no base64 decode is needed.
|
||||
# The cache is written by wake_alarm/_sync.py as
|
||||
# {"hour": H, "minute": M, "fetched_at": <unix seconds>} — keep this reader in
|
||||
# step with _write_cache() there. Storing H/M rather than the raw crdt_sync log
|
||||
# means this script no longer needs to understand the sync payload format at
|
||||
# all, which is what makes it backend-agnostic.
|
||||
get_synced_alarm_epoch() {
|
||||
local token log_json hour minute target_epoch now_epoch
|
||||
token=$(read_sync_token) || return 1
|
||||
local hour minute fetched_at now_epoch age target_epoch
|
||||
|
||||
local url="https://api.github.com/repos/kuhyx/syncs/contents"
|
||||
url="$url/wake-alarm-sync/devices/phone/alarm.json"
|
||||
log_json=$(curl -sf --max-time 10 \
|
||||
-H "Authorization: token $token" \
|
||||
-H "Accept: application/vnd.github.raw" \
|
||||
"$url") || return 1
|
||||
[[ -n "$log_json" ]] || return 1
|
||||
[[ -f "$ALARM_CACHE" ]] || return 1
|
||||
|
||||
# One jq call for all three fields: -e fails on null, and the fixed field
|
||||
# order lets a single read destructure them.
|
||||
read -r hour minute fetched_at < <(
|
||||
jq -er '"\(.hour) \(.minute) \(.fetched_at)"' "$ALARM_CACHE" 2>/dev/null
|
||||
) || return 1
|
||||
|
||||
hour=$(printf '%s' "$log_json" \
|
||||
| jq -r '.alarm.fields.alarm_hour[0]' 2>/dev/null) || return 1
|
||||
minute=$(printf '%s' "$log_json" \
|
||||
| jq -r '.alarm.fields.alarm_minute[0]' 2>/dev/null) || return 1
|
||||
[[ "$hour" =~ ^[0-9]+$ && "$minute" =~ ^[0-9]+$ ]] || return 1
|
||||
(( hour <= 23 && minute <= 59 )) || return 1
|
||||
|
||||
printf -v now_epoch '%(%s)T' -1
|
||||
# fetched_at is a float; strip the fraction rather than pulling in bc.
|
||||
age=$(( now_epoch - ${fetched_at%%.*} ))
|
||||
(( age >= 0 && age <= MAX_CACHE_AGE_SECONDS )) || return 1
|
||||
|
||||
target_epoch=$(date -d "today $hour:$minute" +%s)
|
||||
if (( target_epoch <= now_epoch )); then
|
||||
target_epoch=$(date -d "tomorrow $hour:$minute" +%s)
|
||||
@ -109,11 +98,11 @@ case "$tomorrow_dow" in
|
||||
1|2|3|4|5|6|7)
|
||||
if wake_epoch=$(get_synced_alarm_epoch 2>/dev/null); then
|
||||
logger -t shutdown-wrapper \
|
||||
"crdt_sync: waking at $(date -d "@$wake_epoch") (epoch $wake_epoch)"
|
||||
"alarm cache: waking at $(date -d "@$wake_epoch") (epoch $wake_epoch)"
|
||||
else
|
||||
wake_epoch=$(( $(printf '%(%s)T' -1) + WAKE_AFTER_HOURS * 3600 ))
|
||||
logger -t shutdown-wrapper \
|
||||
"sync unavailable — fallback +${WAKE_AFTER_HOURS}h (epoch $wake_epoch)"
|
||||
"alarm cache unusable — fallback +${WAKE_AFTER_HOURS}h (epoch $wake_epoch)"
|
||||
fi
|
||||
sudo "$RTCWAKE" -m no -t "$wake_epoch"
|
||||
exec /usr/bin/systemctl hibernate
|
||||
|
||||
@ -92,13 +92,22 @@ SYNC_TOKEN_FALLBACK_FILES: tuple[Path, ...] = (
|
||||
# Where a successful read is cached, so a network blip can't cost a wake-up.
|
||||
ALARM_CACHE_FILE: Path = Path.home() / ".config" / "wake_alarm" / "alarm_cache.json"
|
||||
|
||||
# The shared private repo every companion app syncs through, and the phone's
|
||||
# path within it. The phone is the sole writer under a fixed device id, so one
|
||||
# read of its alarm.json is enough — no device discovery or HLC ordering here.
|
||||
# The phone's path to its published alarm. Deliberately the *same* logical
|
||||
# path on both backends — Firebase's namespace mirrors the repo layout — so
|
||||
# only the transport changes, never the parse. The phone is the sole writer
|
||||
# under a fixed device id, so one read of its alarm.json is enough: no device
|
||||
# discovery or HLC ordering here.
|
||||
SYNC_ALARM_PATH: str = "wake-alarm-sync/devices/phone/alarm.json"
|
||||
|
||||
# Names this app's credential cache under ~/.local/share/crdt-sync/, keeping
|
||||
# its Firebase refresh token separate from the sibling apps'.
|
||||
SYNC_APP_NAME: str = "wake_alarm"
|
||||
|
||||
# The shared private repo, used only by the GitHub fallback path. Retiring the
|
||||
# mirror means deleting these three and _fetch_github_alarm() together.
|
||||
SYNC_API_BASE: str = "https://api.github.com"
|
||||
SYNC_REPO_OWNER: str = "kuhyx"
|
||||
SYNC_REPO_NAME: str = "syncs"
|
||||
SYNC_ALARM_PATH: str = "wake-alarm-sync/devices/phone/alarm.json"
|
||||
|
||||
# Valid ranges for a wall-clock alarm, used to reject a malformed sync payload
|
||||
# or a corrupted cache before it can become a fire time.
|
||||
|
||||
@ -5,14 +5,19 @@ in a ``crdt_sync`` ``LogStore`` and pushes that log to a fixed path in the
|
||||
private ``kuhyx/syncs`` repo. This module is the PC-side reader used by the
|
||||
per-minute trigger (see ``_trigger.py``).
|
||||
|
||||
``shutdown-wrapper.sh`` reads the **same file** with ``jq`` for the hibernate
|
||||
fallback path. The parse here mirrors it exactly (``.alarm.fields.alarm_hour``
|
||||
and ``.alarm.fields.alarm_minute``, first tuple element) so the two readers
|
||||
cannot drift.
|
||||
``shutdown-wrapper.sh`` no longer reads the remote file at all — it reads the
|
||||
local cache this module writes, so there is only one remote reader to keep in
|
||||
step (see ``CLAUDE.md``).
|
||||
|
||||
Deliberately stdlib-only (``urllib.request`` + ``json``): the alarm runs under
|
||||
``/usr/bin/python``, not a venv, so every added dependency is a production
|
||||
install-path hazard (see ``CLAUDE.md``). A read is not worth that risk.
|
||||
**Backends.** Firebase is preferred and GitHub is the cutover fallback, the
|
||||
same order every other app in the fleet uses. Firebase needs ``crdt_sync``
|
||||
(hence ``requests``), which is a real dependency declared in
|
||||
``pyproject.toml``; the GitHub path stays stdlib-only (``urllib.request`` +
|
||||
``json``) so that if the import is somehow missing on the production
|
||||
interpreter, the alarm still resolves rather than crashing the trigger. That
|
||||
is why the import below is guarded rather than top-level: this module runs
|
||||
under ``/usr/bin/python``, not a venv, and a wake-up is not worth an
|
||||
ImportError.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -37,6 +42,7 @@ from wake_alarm._constants import (
|
||||
MIN_MINUTE,
|
||||
SYNC_ALARM_PATH,
|
||||
SYNC_API_BASE,
|
||||
SYNC_APP_NAME,
|
||||
SYNC_CONFIG_FILE,
|
||||
SYNC_HTTP_TIMEOUT_SECONDS,
|
||||
SYNC_REPO_NAME,
|
||||
@ -47,6 +53,20 @@ from wake_alarm._logging_setup import configure_logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Optional at runtime, exactly like python-kasa in _smart_plug.py: the trigger
|
||||
# runs every minute under /usr/bin/python, so a dependency that failed to
|
||||
# install must degrade this read to the GitHub path, not raise.
|
||||
try:
|
||||
from crdt_sync import ConfigError, firebase_client_for
|
||||
from crdt_sync._config import CONFIG_FILE as FIREBASE_CONFIG_FILE
|
||||
from crdt_sync._firebase_auth import FirebaseAuthError
|
||||
from crdt_sync._remote import RemoteSyncError
|
||||
|
||||
_FIREBASE_AVAILABLE = True
|
||||
except ImportError:
|
||||
_FIREBASE_AVAILABLE = False
|
||||
_logger.info("crdt_sync is not installed; reading the alarm from GitHub")
|
||||
|
||||
# Keys inside the crdt_sync log the phone writes.
|
||||
_RECORD_ID = "alarm"
|
||||
_HOUR_FIELD = "alarm_hour"
|
||||
@ -199,7 +219,39 @@ def _parse_log(raw: str) -> tuple[int, int] | None:
|
||||
return hour, minute
|
||||
|
||||
|
||||
def fetch_remote_alarm() -> tuple[int, int] | None:
|
||||
def _fetch_firebase_alarm() -> tuple[int, int] | None:
|
||||
"""Fetch the alarm the phone last pushed to Firebase.
|
||||
|
||||
Returns None — never raises — for every failure mode, including
|
||||
``crdt_sync`` not being importable on this interpreter, so the caller can
|
||||
fall back to GitHub and then to the cache.
|
||||
|
||||
Returns:
|
||||
The (hour, minute) pair, or None if unconfigured or unreadable.
|
||||
"""
|
||||
if not _FIREBASE_AVAILABLE:
|
||||
return None
|
||||
|
||||
# Checked before constructing anything, so an unconfigured machine never
|
||||
# touches the network (and a socket-blocking test suite stays quiet).
|
||||
if not FIREBASE_CONFIG_FILE.is_file():
|
||||
_logger.info("Firebase not configured at %s", FIREBASE_CONFIG_FILE)
|
||||
return None
|
||||
|
||||
try:
|
||||
client = firebase_client_for(SYNC_APP_NAME)
|
||||
raw = client.get_file_text(SYNC_ALARM_PATH)
|
||||
except (ConfigError, FirebaseAuthError, RemoteSyncError, OSError) as exc:
|
||||
_logger.info("Cannot fetch the alarm from Firebase: %s", exc)
|
||||
return None
|
||||
|
||||
if raw is None:
|
||||
_logger.info("No alarm published to Firebase yet")
|
||||
return None
|
||||
return _parse_log(raw)
|
||||
|
||||
|
||||
def _fetch_github_alarm() -> tuple[int, int] | None:
|
||||
"""Fetch the alarm the phone last pushed to GitHub.
|
||||
|
||||
Uses the raw media type so the Contents API returns the file body directly
|
||||
@ -251,6 +303,19 @@ def fetch_remote_alarm() -> tuple[int, int] | None:
|
||||
return _parse_log(raw)
|
||||
|
||||
|
||||
def fetch_remote_alarm() -> tuple[int, int] | None:
|
||||
"""Fetch the alarm the phone last pushed, Firebase first.
|
||||
|
||||
GitHub is tried only when Firebase yields nothing, which covers both the
|
||||
not-yet-migrated machine and a Firebase outage. Once every device has
|
||||
moved, deleting the GitHub branch is the whole retirement.
|
||||
|
||||
Returns:
|
||||
The (hour, minute) pair, or None if no backend has a usable answer.
|
||||
"""
|
||||
return _fetch_firebase_alarm() or _fetch_github_alarm()
|
||||
|
||||
|
||||
def _read_cache() -> tuple[int, int, float] | None:
|
||||
"""Read the cached alarm.
|
||||
|
||||
|
||||
@ -12,13 +12,13 @@ import pytest
|
||||
from wake_alarm._sync import (
|
||||
Alarm,
|
||||
_extract_field,
|
||||
_fetch_github_alarm,
|
||||
_parse_args,
|
||||
_parse_log,
|
||||
_read_cache,
|
||||
_read_token,
|
||||
_read_token_fallback,
|
||||
_write_cache,
|
||||
fetch_remote_alarm,
|
||||
get_alarm,
|
||||
main,
|
||||
)
|
||||
@ -208,11 +208,11 @@ class TestParseLog:
|
||||
|
||||
|
||||
class TestFetchRemoteAlarm:
|
||||
"""Tests for fetch_remote_alarm."""
|
||||
"""Tests for the GitHub backend behind fetch_remote_alarm."""
|
||||
|
||||
def test_no_token(self) -> None:
|
||||
"""Without a token there is nothing to fetch."""
|
||||
assert fetch_remote_alarm() is None
|
||||
assert _fetch_github_alarm() is None
|
||||
|
||||
def _with_token(self, config_file: Path) -> None:
|
||||
config_file.write_text(json.dumps({"token": "ghp_x"}))
|
||||
@ -224,7 +224,7 @@ class TestFetchRemoteAlarm:
|
||||
response.read.return_value = _log(6, 15).encode()
|
||||
response.__enter__.return_value = response
|
||||
with patch("urllib.request.urlopen", return_value=response):
|
||||
assert fetch_remote_alarm() == (6, 15)
|
||||
assert _fetch_github_alarm() == (6, 15)
|
||||
|
||||
def test_sends_raw_media_type_and_auth(self, config_file: Path) -> None:
|
||||
"""The request carries the token and the raw Accept header."""
|
||||
@ -233,7 +233,7 @@ class TestFetchRemoteAlarm:
|
||||
response.read.return_value = _log().encode()
|
||||
response.__enter__.return_value = response
|
||||
with patch("urllib.request.urlopen", return_value=response) as urlopen:
|
||||
fetch_remote_alarm()
|
||||
_fetch_github_alarm()
|
||||
request = urlopen.call_args[0][0]
|
||||
assert request.get_header("Authorization") == "token ghp_x"
|
||||
assert request.get_header("Accept") == "application/vnd.github.raw"
|
||||
@ -246,7 +246,7 @@ class TestFetchRemoteAlarm:
|
||||
patch("wake_alarm._sync.SYNC_API_BASE", "http://api.github.com"),
|
||||
patch("urllib.request.urlopen") as urlopen,
|
||||
):
|
||||
assert fetch_remote_alarm() is None
|
||||
assert _fetch_github_alarm() is None
|
||||
urlopen.assert_not_called()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@ -263,7 +263,7 @@ class TestFetchRemoteAlarm:
|
||||
"""Every transport failure degrades to None, never raises."""
|
||||
self._with_token(config_file)
|
||||
with patch("urllib.request.urlopen", side_effect=exc):
|
||||
assert fetch_remote_alarm() is None
|
||||
assert _fetch_github_alarm() is None
|
||||
|
||||
|
||||
class TestCache:
|
||||
|
||||
171
wake_alarm/tests/test_sync_backends.py
Normal file
171
wake_alarm/tests/test_sync_backends.py
Normal file
@ -0,0 +1,171 @@
|
||||
"""Tests for the Firebase/GitHub backend selection in _sync.py.
|
||||
|
||||
Split out of ``test_sync.py`` to keep both files under the repo's 500-line
|
||||
cap; the parse, cache and CLI tests stay there.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from wake_alarm._constants import SYNC_ALARM_PATH
|
||||
from wake_alarm._sync import _fetch_firebase_alarm, fetch_remote_alarm
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from contextlib import AbstractContextManager
|
||||
|
||||
|
||||
class _FakeConfigError(Exception):
|
||||
"""Stand-in for crdt_sync.ConfigError, which is optional at runtime."""
|
||||
|
||||
|
||||
class _FakeAuthError(Exception):
|
||||
"""Stand-in for crdt_sync's FirebaseAuthError."""
|
||||
|
||||
|
||||
class _FakeRemoteError(Exception):
|
||||
"""Stand-in for crdt_sync's RemoteSyncError."""
|
||||
|
||||
|
||||
def _log(hour: object = 7, minute: object = 30) -> str:
|
||||
"""Build a crdt_sync log payload shaped like the phone's."""
|
||||
return json.dumps(
|
||||
{
|
||||
"alarm": {
|
||||
"fields": {
|
||||
"alarm_hour": [hour, "hlc-1"],
|
||||
"alarm_minute": [minute, "hlc-1"],
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TestFetchFirebaseAlarm:
|
||||
"""Tests for the Firebase backend behind fetch_remote_alarm."""
|
||||
|
||||
def _patched(
|
||||
self, *, client: MagicMock | None = None, configured: bool = True
|
||||
) -> AbstractContextManager[None]:
|
||||
"""Patch the module-level crdt_sync bindings for one test.
|
||||
|
||||
crdt_sync is an optional dependency imported at module scope, so the
|
||||
tests drive its bound names directly rather than requiring it to be
|
||||
installed -- the same contract production degrades against.
|
||||
"""
|
||||
config = MagicMock()
|
||||
config.is_file.return_value = configured
|
||||
return patch.multiple(
|
||||
"wake_alarm._sync",
|
||||
_FIREBASE_AVAILABLE=True,
|
||||
FIREBASE_CONFIG_FILE=config,
|
||||
firebase_client_for=MagicMock(
|
||||
return_value=client if client is not None else MagicMock()
|
||||
),
|
||||
ConfigError=_FakeConfigError,
|
||||
FirebaseAuthError=_FakeAuthError,
|
||||
RemoteSyncError=_FakeRemoteError,
|
||||
)
|
||||
|
||||
def test_missing_dependency_yields_none(self) -> None:
|
||||
"""A machine without crdt_sync falls back instead of raising."""
|
||||
with patch("wake_alarm._sync._FIREBASE_AVAILABLE", new=False):
|
||||
assert _fetch_firebase_alarm() is None
|
||||
|
||||
def test_unconfigured_never_touches_the_network(self) -> None:
|
||||
"""With no config file, no client is ever constructed."""
|
||||
client = MagicMock()
|
||||
with self._patched(client=client, configured=False):
|
||||
assert _fetch_firebase_alarm() is None
|
||||
client.get_file_text.assert_not_called()
|
||||
|
||||
def test_success(self) -> None:
|
||||
"""A published alarm parses to its (hour, minute) pair."""
|
||||
client = MagicMock()
|
||||
client.get_file_text.return_value = _log(6, 15)
|
||||
with self._patched(client=client):
|
||||
assert _fetch_firebase_alarm() == (6, 15)
|
||||
client.get_file_text.assert_called_once_with(SYNC_ALARM_PATH)
|
||||
|
||||
def test_absent_value_yields_none(self) -> None:
|
||||
"""Nothing published yet is a miss, not a crash."""
|
||||
client = MagicMock()
|
||||
client.get_file_text.return_value = None
|
||||
with self._patched(client=client):
|
||||
assert _fetch_firebase_alarm() is None
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exc",
|
||||
[
|
||||
_FakeConfigError("bad config"),
|
||||
_FakeAuthError("rejected"),
|
||||
_FakeRemoteError("database down"),
|
||||
OSError("no route to host"),
|
||||
],
|
||||
)
|
||||
def test_failures_yield_none(self, exc: Exception) -> None:
|
||||
"""Every Firebase failure degrades to None, never raises."""
|
||||
client = MagicMock()
|
||||
client.get_file_text.side_effect = exc
|
||||
with self._patched(client=client):
|
||||
assert _fetch_firebase_alarm() is None
|
||||
|
||||
|
||||
class TestOptionalCrdtSyncImport:
|
||||
"""Cover the ImportError branch of the optional ``crdt_sync`` import."""
|
||||
|
||||
def test_module_sets_firebase_unavailable_when_import_fails(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Reloading with crdt_sync blocked sets _FIREBASE_AVAILABLE=False."""
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
from wake_alarm import _sync
|
||||
|
||||
for name in (
|
||||
"crdt_sync",
|
||||
"crdt_sync._config",
|
||||
"crdt_sync._firebase_auth",
|
||||
"crdt_sync._remote",
|
||||
):
|
||||
monkeypatch.setitem(sys.modules, name, None)
|
||||
try:
|
||||
reloaded = importlib.reload(_sync)
|
||||
assert reloaded._FIREBASE_AVAILABLE is False
|
||||
finally:
|
||||
monkeypatch.undo()
|
||||
importlib.reload(_sync)
|
||||
|
||||
|
||||
class TestFetchRemoteAlarmDispatch:
|
||||
"""Tests for the Firebase-first, GitHub-fallback ordering."""
|
||||
|
||||
def test_prefers_firebase(self) -> None:
|
||||
"""When Firebase answers, GitHub is never asked."""
|
||||
with (
|
||||
patch("wake_alarm._sync._fetch_firebase_alarm", return_value=(6, 15)),
|
||||
patch("wake_alarm._sync._fetch_github_alarm") as github,
|
||||
):
|
||||
assert fetch_remote_alarm() == (6, 15)
|
||||
github.assert_not_called()
|
||||
|
||||
def test_falls_back_to_github(self) -> None:
|
||||
"""A Firebase miss still resolves through the mirror."""
|
||||
with (
|
||||
patch("wake_alarm._sync._fetch_firebase_alarm", return_value=None),
|
||||
patch("wake_alarm._sync._fetch_github_alarm", return_value=(7, 30)),
|
||||
):
|
||||
assert fetch_remote_alarm() == (7, 30)
|
||||
|
||||
def test_both_missing_yields_none(self) -> None:
|
||||
"""With neither backend usable the caller degrades to the cache."""
|
||||
with (
|
||||
patch("wake_alarm._sync._fetch_firebase_alarm", return_value=None),
|
||||
patch("wake_alarm._sync._fetch_github_alarm", return_value=None),
|
||||
):
|
||||
assert fetch_remote_alarm() is None
|
||||
Loading…
Reference in New Issue
Block a user