Split _sync.py along the transport/resolution seam item 8 needs

371 -> 168. Token discovery and both backends move to _sync_transport.py;
_sync.py keeps resolution (cache, staleness, fallback), the Alarm dataclass
and the CLI.

The seam is chosen for item 8, not just for line count: that phase adds a
dismissal-record reader, which belongs beside the alarm reader in the
transport rather than inside the resolution chain.

ALARM_CACHE_FILE deliberately stays in _sync. The suite patches
`wake_alarm._sync.ALARM_CACHE_FILE` to keep tests off
~/.config/wake_alarm/alarm_cache.json — the file shutdown-wrapper.sh reads at
poweroff — and moving that binding would silently reconnect the tests to the
real one. That is the failure mode that once wrote 27 fake entries into a
live log while the suite still looked green.

The split broke 31 tests loudly, all of them patch targets that had to follow
the code: the three SYNC_* constants, the two fetchers, the patch.multiple on
the firebase names, and the reload test for the crdt_sync import guard. Every
one is repointed at _sync_transport; none was left silently patching nothing.

Verified after: 465 passed at 100% coverage, `python -m wake_alarm._sync
--show` prints, `--no-cache` still resolves the phone's real alarm through
Firebase, `_trigger --dry-run` prints, and wake_state.json is byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
This commit is contained in:
Krzysztof Rudnicki 2026-08-27 23:44:24 +02:00
parent 64ee81f077
commit 801177972b
7 changed files with 273 additions and 214 deletions

View File

@ -28,43 +28,40 @@ import json
import logging
import sys
import time
import urllib.error
import urllib.request
from wake_alarm._alarm_payload import in_range, newest_alarm, parse_log
from wake_alarm._alarm_payload import in_range
from wake_alarm._constants import (
ALARM_CACHE_FILE,
ALARM_CACHE_MAX_AGE_SECONDS,
FALLBACK_ALARM_HOUR,
SYNC_ALARM_FILENAME,
SYNC_ALARM_PATH,
SYNC_API_BASE,
SYNC_APP_NAME,
SYNC_CONFIG_FILE,
SYNC_DEVICES_PREFIX,
SYNC_HTTP_TIMEOUT_SECONDS,
SYNC_REPO_NAME,
SYNC_REPO_OWNER,
SYNC_TOKEN_FALLBACK_FILES,
)
from wake_alarm._logging_setup import configure_logging
from wake_alarm._sync_transport import (
_FIREBASE_AVAILABLE,
_fetch_firebase_alarm,
_fetch_github_alarm,
_read_token,
_read_token_fallback,
fetch_remote_alarm,
)
# Re-imported under their original names: the suite patches
# `wake_alarm._sync._fetch_firebase_alarm` and friends, and a moved symbol
# makes patch() stop intercepting *silently*. __all__ stops ruff deleting
# them as unused.
__all__ = [
"_FIREBASE_AVAILABLE",
"Alarm",
"_fetch_firebase_alarm",
"_fetch_github_alarm",
"_read_token",
"_read_token_fallback",
"fetch_remote_alarm",
"get_alarm",
]
_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")
@dataclass(frozen=True)
class Alarm:
@ -87,173 +84,6 @@ class Alarm:
return f"{self.hour:02d}:{self.minute:02d} ({self.source})"
def _read_token_config() -> str | None:
"""Read the token from this package's own JSON config.
Returns:
The token, or None if the file is missing, unreadable, malformed, or
holds no usable token.
"""
try:
with SYNC_CONFIG_FILE.open() as handle:
config = json.load(handle)
except (OSError, json.JSONDecodeError):
return None
if not isinstance(config, dict):
return None
token = config.get("token")
if not isinstance(token, str) or not token:
return None
return token
def _read_token_fallback() -> str | None:
"""Read the token from a sibling app's raw single-line token file.
Returns:
The first usable token found, or None if none of the fallback files
exists or holds one.
"""
for path in SYNC_TOKEN_FALLBACK_FILES:
try:
token = path.read_text().strip()
except OSError:
continue
if token:
_logger.debug("Using sync token from %s", path)
return token
return None
def _read_token() -> str | None:
"""Read the GitHub token, preferring this package's own config.
Falls back to the sibling companion apps' token files so one repo-scoped
PAT covers every app syncing through ``kuhyx/syncs``.
Returns:
The token, or None if no configured source holds one.
"""
# No warning on a miss here: the caller reports "no token" once. Logging
# twice for one unconfigured file makes every tick noisy in the journal.
return _read_token_config() or _read_token_fallback()
def _fetch_firebase_alarm() -> tuple[int, int] | None:
"""Fetch the newest alarm any device has pushed to Firebase.
Discovers device directories rather than reading one fixed path: the
phone's device id is migrating from the constant "phone" to a per-install
uuid, and a reader pinned to the old path would stop seeing the alarm the
moment the phone migrated — falling back to FALLBACK_ALARM_HOUR, i.e.
waking the user at the wrong time. Listing covers both ids, and
SYNC_ALARM_PATH remains as a last resort for a backend that cannot list.
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)
published: list[tuple[str, str]] = []
for device_id in client.list_directory(SYNC_DEVICES_PREFIX):
raw = client.get_file_text(
f"{SYNC_DEVICES_PREFIX}/{device_id}/{SYNC_ALARM_FILENAME}"
)
if raw is not None:
published.append((device_id, raw))
if not published:
# No directories listed (or none held a file): still try the
# legacy fixed path, so a phone that has not migrated is read.
raw = client.get_file_text(SYNC_ALARM_PATH)
if raw is None:
_logger.info("No alarm published to Firebase yet")
return None
published.append(("phone", raw))
except (ConfigError, FirebaseAuthError, RemoteSyncError, OSError) as exc:
_logger.info("Cannot fetch the alarm from Firebase: %s", exc)
return None
return newest_alarm(published)
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
rather than base64-wrapped JSON.
Returns:
The (hour, minute) pair, or None if unconfigured, unreachable, or
malformed. Never raises — a failed read must degrade to the cache,
not crash the trigger.
"""
token = _read_token()
if token is None:
# INFO, not WARNING: get_alarm() runs on every one-minute trigger tick,
# so a persistent condition logged loudly here is 1440 identical
# journal lines a day. The trigger warns once, at the moment it
# actually fires on a degraded source — that is the actionable event.
_logger.info("No sync token configured at %s", SYNC_CONFIG_FILE)
return None
url = (
f"{SYNC_API_BASE}/repos/{SYNC_REPO_OWNER}/{SYNC_REPO_NAME}"
f"/contents/{SYNC_ALARM_PATH}"
)
request = urllib.request.Request(
url,
headers={
"Authorization": f"token {token}",
"Accept": "application/vnd.github.raw",
},
)
# The URL is built from module constants, never from remote input, but
# assert the scheme anyway: this call carries a credential, and an http://
# or file:// target would leak it. Belt and braces for a bad edit above.
if request.type != "https":
_logger.error("Refusing to send the sync token over %s", request.type)
return None
try:
with urllib.request.urlopen(
request, timeout=SYNC_HTTP_TIMEOUT_SECONDS
) as response:
raw = response.read().decode("utf-8")
except (urllib.error.URLError, OSError, UnicodeDecodeError) as exc:
# INFO for the same reason as the no-token path: a persistent failure
# (404 because the phone never pushed, an expired token, no network)
# would otherwise be one WARNING per cache expiry, forever.
_logger.info("Cannot fetch synced alarm: %s", exc)
return 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.

View File

@ -0,0 +1,218 @@
"""Fetching the alarm the phone pushed — Firebase first, GitHub as mirror.
Split out of ``_sync.py`` for the 250-line cap, along the seam item 8 needs:
this module is the **transport** (token discovery and the two backends), while
``_sync.py`` keeps **resolution** (cache, staleness, fallback) and the CLI.
Item 8 adds a dismissal-record reader, which belongs here beside the alarm
reader rather than inside the resolution chain.
``ALARM_CACHE_FILE`` deliberately does NOT live here: the suite patches
``wake_alarm._sync.ALARM_CACHE_FILE`` to keep tests off
``~/.config/wake_alarm/alarm_cache.json``, which ``shutdown-wrapper.sh`` reads
at poweroff, and moving that binding would silently reconnect the tests to the
real file.
"""
from __future__ import annotations
import json
import logging
import urllib.error
import urllib.request
from wake_alarm._alarm_payload import newest_alarm, parse_log
from wake_alarm._constants import (
SYNC_ALARM_FILENAME,
SYNC_ALARM_PATH,
SYNC_API_BASE,
SYNC_APP_NAME,
SYNC_CONFIG_FILE,
SYNC_DEVICES_PREFIX,
SYNC_HTTP_TIMEOUT_SECONDS,
SYNC_REPO_NAME,
SYNC_REPO_OWNER,
SYNC_TOKEN_FALLBACK_FILES,
)
_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")
def _read_token_config() -> str | None:
"""Read the token from this package's own JSON config.
Returns:
The token, or None if the file is missing, unreadable, malformed, or
holds no usable token.
"""
try:
with SYNC_CONFIG_FILE.open() as handle:
config = json.load(handle)
except (OSError, json.JSONDecodeError):
return None
if not isinstance(config, dict):
return None
token = config.get("token")
if not isinstance(token, str) or not token:
return None
return token
def _read_token_fallback() -> str | None:
"""Read the token from a sibling app's raw single-line token file.
Returns:
The first usable token found, or None if none of the fallback files
exists or holds one.
"""
for path in SYNC_TOKEN_FALLBACK_FILES:
try:
token = path.read_text().strip()
except OSError:
continue
if token:
_logger.debug("Using sync token from %s", path)
return token
return None
def _read_token() -> str | None:
"""Read the GitHub token, preferring this package's own config.
Falls back to the sibling companion apps' token files so one repo-scoped
PAT covers every app syncing through ``kuhyx/syncs``.
Returns:
The token, or None if no configured source holds one.
"""
# No warning on a miss here: the caller reports "no token" once. Logging
# twice for one unconfigured file makes every tick noisy in the journal.
return _read_token_config() or _read_token_fallback()
def _fetch_firebase_alarm() -> tuple[int, int] | None:
"""Fetch the newest alarm any device has pushed to Firebase.
Discovers device directories rather than reading one fixed path: the
phone's device id is migrating from the constant "phone" to a per-install
uuid, and a reader pinned to the old path would stop seeing the alarm the
moment the phone migrated — falling back to FALLBACK_ALARM_HOUR, i.e.
waking the user at the wrong time. Listing covers both ids, and
SYNC_ALARM_PATH remains as a last resort for a backend that cannot list.
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)
published: list[tuple[str, str]] = []
for device_id in client.list_directory(SYNC_DEVICES_PREFIX):
raw = client.get_file_text(
f"{SYNC_DEVICES_PREFIX}/{device_id}/{SYNC_ALARM_FILENAME}"
)
if raw is not None:
published.append((device_id, raw))
if not published:
# No directories listed (or none held a file): still try the
# legacy fixed path, so a phone that has not migrated is read.
raw = client.get_file_text(SYNC_ALARM_PATH)
if raw is None:
_logger.info("No alarm published to Firebase yet")
return None
published.append(("phone", raw))
except (ConfigError, FirebaseAuthError, RemoteSyncError, OSError) as exc:
_logger.info("Cannot fetch the alarm from Firebase: %s", exc)
return None
return newest_alarm(published)
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
rather than base64-wrapped JSON.
Returns:
The (hour, minute) pair, or None if unconfigured, unreachable, or
malformed. Never raises — a failed read must degrade to the cache,
not crash the trigger.
"""
token = _read_token()
if token is None:
# INFO, not WARNING: get_alarm() runs on every one-minute trigger tick,
# so a persistent condition logged loudly here is 1440 identical
# journal lines a day. The trigger warns once, at the moment it
# actually fires on a degraded source — that is the actionable event.
_logger.info("No sync token configured at %s", SYNC_CONFIG_FILE)
return None
url = (
f"{SYNC_API_BASE}/repos/{SYNC_REPO_OWNER}/{SYNC_REPO_NAME}"
f"/contents/{SYNC_ALARM_PATH}"
)
request = urllib.request.Request(
url,
headers={
"Authorization": f"token {token}",
"Accept": "application/vnd.github.raw",
},
)
# The URL is built from module constants, never from remote input, but
# assert the scheme anyway: this call carries a credential, and an http://
# or file:// target would leak it. Belt and braces for a bad edit above.
if request.type != "https":
_logger.error("Refusing to send the sync token over %s", request.type)
return None
try:
with urllib.request.urlopen(
request, timeout=SYNC_HTTP_TIMEOUT_SECONDS
) as response:
raw = response.read().decode("utf-8")
except (urllib.error.URLError, OSError, UnicodeDecodeError) as exc:
# INFO for the same reason as the no-token path: a persistent failure
# (404 because the phone never pushed, an expired token, no network)
# would otherwise be one WARNING per cache expiry, forever.
_logger.info("Cannot fetch synced alarm: %s", exc)
return 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()

View File

@ -61,9 +61,9 @@ def _patch_paths(
) -> None:
"""Redirect every on-disk path into tmp_path for every test."""
with (
patch("wake_alarm._sync.SYNC_CONFIG_FILE", config_file),
patch("wake_alarm._sync_transport.SYNC_CONFIG_FILE", config_file),
patch("wake_alarm._sync.ALARM_CACHE_FILE", cache_file),
patch("wake_alarm._sync.SYNC_TOKEN_FALLBACK_FILES", fallback_files),
patch("wake_alarm._sync_transport.SYNC_TOKEN_FALLBACK_FILES", fallback_files),
):
yield

View File

@ -60,9 +60,9 @@ def _patch_paths(
) -> None:
"""Redirect every on-disk path into tmp_path for every test."""
with (
patch("wake_alarm._sync.SYNC_CONFIG_FILE", config_file),
patch("wake_alarm._sync_transport.SYNC_CONFIG_FILE", config_file),
patch("wake_alarm._sync.ALARM_CACHE_FILE", cache_file),
patch("wake_alarm._sync.SYNC_TOKEN_FALLBACK_FILES", fallback_files),
patch("wake_alarm._sync_transport.SYNC_TOKEN_FALLBACK_FILES", fallback_files),
):
yield
@ -111,7 +111,7 @@ class TestFetchRemoteAlarm:
"""The token is never sent over a plaintext or file:// URL."""
self._with_token(config_file)
with (
patch("wake_alarm._sync.SYNC_API_BASE", "http://api.github.com"),
patch("wake_alarm._sync_transport.SYNC_API_BASE", "http://api.github.com"),
patch("urllib.request.urlopen") as urlopen,
):
assert _fetch_github_alarm() is None

View File

@ -64,7 +64,7 @@ class TestFetchFirebaseAlarm:
config = MagicMock()
config.is_file.return_value = configured
return patch.multiple(
"wake_alarm._sync",
"wake_alarm._sync_transport",
_FIREBASE_AVAILABLE=True,
FIREBASE_CONFIG_FILE=config,
firebase_client_for=MagicMock(
@ -77,7 +77,7 @@ class TestFetchFirebaseAlarm:
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):
with patch("wake_alarm._sync_transport._FIREBASE_AVAILABLE", new=False):
assert _fetch_firebase_alarm() is None
def test_unconfigured_never_touches_the_network(self) -> None:
@ -183,7 +183,7 @@ class TestOptionalCrdtSyncImport:
import importlib
import sys
from wake_alarm import _sync
from wake_alarm import _sync_transport
for name in (
"crdt_sync",
@ -193,11 +193,14 @@ class TestOptionalCrdtSyncImport:
):
monkeypatch.setitem(sys.modules, name, None)
try:
reloaded = importlib.reload(_sync)
reloaded = importlib.reload(_sync_transport)
assert reloaded._FIREBASE_AVAILABLE is False
finally:
monkeypatch.undo()
importlib.reload(_sync)
# Restore the real module for every later test: a reload left in
# the import-failed state would silently disable Firebase for the
# rest of the session.
importlib.reload(_sync_transport)
class TestFetchRemoteAlarmDispatch:
@ -206,8 +209,10 @@ class TestFetchRemoteAlarmDispatch:
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,
patch(
"wake_alarm._sync_transport._fetch_firebase_alarm", return_value=(6, 15)
),
patch("wake_alarm._sync_transport._fetch_github_alarm") as github,
):
assert fetch_remote_alarm() == (6, 15)
github.assert_not_called()
@ -215,15 +220,21 @@ class TestFetchRemoteAlarmDispatch:
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)),
patch(
"wake_alarm._sync_transport._fetch_firebase_alarm", return_value=None
),
patch(
"wake_alarm._sync_transport._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),
patch(
"wake_alarm._sync_transport._fetch_firebase_alarm", return_value=None
),
patch("wake_alarm._sync_transport._fetch_github_alarm", return_value=None),
):
assert fetch_remote_alarm() is None

View File

@ -61,9 +61,9 @@ def _patch_paths(
) -> None:
"""Redirect every on-disk path into tmp_path for every test."""
with (
patch("wake_alarm._sync.SYNC_CONFIG_FILE", config_file),
patch("wake_alarm._sync_transport.SYNC_CONFIG_FILE", config_file),
patch("wake_alarm._sync.ALARM_CACHE_FILE", cache_file),
patch("wake_alarm._sync.SYNC_TOKEN_FALLBACK_FILES", fallback_files),
patch("wake_alarm._sync_transport.SYNC_TOKEN_FALLBACK_FILES", fallback_files),
):
yield

View File

@ -58,9 +58,9 @@ def _patch_paths(
) -> None:
"""Redirect every on-disk path into tmp_path for every test."""
with (
patch("wake_alarm._sync.SYNC_CONFIG_FILE", config_file),
patch("wake_alarm._sync_transport.SYNC_CONFIG_FILE", config_file),
patch("wake_alarm._sync.ALARM_CACHE_FILE", cache_file),
patch("wake_alarm._sync.SYNC_TOKEN_FALLBACK_FILES", fallback_files),
patch("wake_alarm._sync_transport.SYNC_TOKEN_FALLBACK_FILES", fallback_files),
):
yield