mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-11 14:07:46 +01:00
crdt-sync: split five source files — package now fully under the cap
Some checks are pending
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (3.10) (push) Waiting to run
crdt-sync tests / test (3.11) (push) Waiting to run
crdt-sync tests / test (3.12) (push) Waiting to run
gatelock pre-commit / pre-commit (push) Waiting to run
gatelock tests / test (3.10) (push) Waiting to run
gatelock tests / test (3.11) (push) Waiting to run
gatelock tests / test (3.12) (push) Waiting to run
palette drift / palette (push) Waiting to run
Some checks are pending
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (3.10) (push) Waiting to run
crdt-sync tests / test (3.11) (push) Waiting to run
crdt-sync tests / test (3.12) (push) Waiting to run
gatelock pre-commit / pre-commit (push) Waiting to run
gatelock tests / test (3.10) (push) Waiting to run
gatelock tests / test (3.11) (push) Waiting to run
gatelock tests / test (3.12) (push) Waiting to run
palette drift / palette (push) Waiting to run
_sync.py 456 -> 245 + _syncstate 131 + _pull 106 + _revisions 34 _firebase_auth 412 -> 242 + _credentials 146 + _autherrors 69 _firebase.py 336 -> 250 + _fbkeys 100 google_id_token 316 -> 232 + tool/_oauth_callback 105 _github.py 255 -> 238 + _gherrors 30 revision_of became its own leaf module because both the push and pull halves need it; without that the two would have imported each other. _fbkeys holds the RTDB key escaping — the escape tables moved with the functions that read them, since the pair has to round-trip exactly or a device silently stops seeing its own writes. crdt-sync/tool/ is outside the coverage source, so the split there has no test net: verified by running it the documented way instead (`python3 -m tool.google_id_token --help` prints its usage, and `from tool.google_id_token import TokenError, fetch_id_token` — what seed_session.py does — still resolves). An AST pass over each pair caught what the suite would not have: _is_revoked needs the error type, so it stayed with it rather than following the credentials; _TERMINAL_AUTH_REASONS and _CONSENT_TIMEOUT_SECONDS had to be carried to their new callers. 259 passed at 100% branch coverage. crdt-sync/tool/seed_session.py and firebase-debug.log are pre-existing local changes and are deliberately not in this commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
This commit is contained in:
parent
f5f9db21dc
commit
3589d166cd
69
crdt-sync/crdt_sync/_autherrors.py
Normal file
69
crdt-sync/crdt_sync/_autherrors.py
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
"""Turning a Firebase auth response into a message, and reading its verdict.
|
||||||
|
|
||||||
|
Split from :mod:`crdt_sync._firebase_auth`, which keeps the token provider.
|
||||||
|
identitytoolkit reports failures as a nested JSON error whose useful part is
|
||||||
|
buried; these turn that into something a log line can carry, and decide
|
||||||
|
whether the failure is permanent (the refresh token is dead and the user must
|
||||||
|
sign in again) or worth retrying.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from crdt_sync._remote import RemoteSyncError
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import requests as _requests
|
||||||
|
|
||||||
|
|
||||||
|
class FirebaseAuthError(RemoteSyncError):
|
||||||
|
"""Raised for an authentication failure the caller must not ignore.
|
||||||
|
|
||||||
|
A :class:`crdt_sync.RemoteSyncError` so callers that only care about
|
||||||
|
"sync is broken" can catch one type, while a settings screen can single
|
||||||
|
this out to say "your password is wrong" rather than "the network is
|
||||||
|
down".
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _reason(response: _requests.Response) -> str:
|
||||||
|
"""Return Google's machine-readable error reason, in parentheses.
|
||||||
|
|
||||||
|
Pulls ``INVALID_PASSWORD`` / ``TOKEN_EXPIRED`` / ``USER_DISABLED`` out of
|
||||||
|
the body so the raised message says what actually went wrong rather than
|
||||||
|
just reporting a status code.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
except ValueError:
|
||||||
|
# A non-JSON body (a proxy error page, say): the status code is all
|
||||||
|
# the detail there is.
|
||||||
|
return ""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return ""
|
||||||
|
error = data.get("error")
|
||||||
|
if isinstance(error, dict):
|
||||||
|
return f" ({error.get('message')})"
|
||||||
|
if isinstance(error, str):
|
||||||
|
return f" ({error})"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
#: Reasons that mean the refresh token is permanently dead. Only these clear
|
||||||
|
#: the session: a network error or a 5xx must not, because a device that signs
|
||||||
|
#: itself out whenever the wifi drops needs a manual sign-in to recover, which
|
||||||
|
#: is a worse failure than the stale banner this guards against.
|
||||||
|
_TERMINAL_AUTH_REASONS = (
|
||||||
|
"TOKEN_EXPIRED",
|
||||||
|
"USER_DISABLED",
|
||||||
|
"USER_NOT_FOUND",
|
||||||
|
"INVALID_REFRESH_TOKEN",
|
||||||
|
"INVALID_GRANT_TYPE",
|
||||||
|
"MISSING_REFRESH_TOKEN",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_revoked(error: FirebaseAuthError) -> bool:
|
||||||
|
"""Return whether ``error`` means the refresh token is permanently dead."""
|
||||||
|
message = str(error)
|
||||||
|
return any(reason in message for reason in _TERMINAL_AUTH_REASONS)
|
||||||
146
crdt-sync/crdt_sync/_credentials.py
Normal file
146
crdt-sync/crdt_sync/_credentials.py
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
"""Firebase credentials and the stores that persist them.
|
||||||
|
|
||||||
|
Split from :mod:`crdt_sync._firebase_auth`, which keeps the token provider.
|
||||||
|
A credential set is an ID token, a refresh token and an expiry; the stores
|
||||||
|
decide where it survives a restart -- memory for tests, a mode-0600 file for
|
||||||
|
real installs.
|
||||||
|
|
||||||
|
Re-exported from :mod:`crdt_sync._firebase_auth`, so existing imports work.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import datetime as dt
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Refresh this long before the token actually expires. An ID token lives ~1h;
|
||||||
|
# refreshing early means a tick that starts just under the wire still finishes
|
||||||
|
# with a valid token rather than 401ing halfway through a multi-file push.
|
||||||
|
_REFRESH_SKEW = dt.timedelta(minutes=5)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FirebaseCredentials:
|
||||||
|
"""One device's session: a short-lived ID token plus its refresh token.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
----------
|
||||||
|
id_token:
|
||||||
|
The bearer credential for Realtime Database requests. Expires fast.
|
||||||
|
refresh_token:
|
||||||
|
The long-lived credential. **This is the secret worth protecting.**
|
||||||
|
expires_at:
|
||||||
|
When ``id_token`` stops being accepted, in UTC.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id_token: str
|
||||||
|
refresh_token: str
|
||||||
|
expires_at: dt.datetime
|
||||||
|
|
||||||
|
def is_expired_at(self, now: dt.datetime) -> bool:
|
||||||
|
"""Return whether the token is expired, or close enough to it."""
|
||||||
|
return now + _REFRESH_SKEW >= self.expires_at
|
||||||
|
|
||||||
|
def to_json(self) -> dict[str, str]:
|
||||||
|
"""Return a JSON-serializable form."""
|
||||||
|
return {
|
||||||
|
"id_token": self.id_token,
|
||||||
|
"refresh_token": self.refresh_token,
|
||||||
|
"expires_at": self.expires_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_json(cls, data: dict[str, str]) -> FirebaseCredentials:
|
||||||
|
"""Rebuild credentials from :meth:`to_json` output."""
|
||||||
|
return cls(
|
||||||
|
id_token=data["id_token"],
|
||||||
|
refresh_token=data["refresh_token"],
|
||||||
|
expires_at=dt.datetime.fromisoformat(data["expires_at"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CredentialStore(Protocol):
|
||||||
|
"""Where a device keeps its credentials between runs."""
|
||||||
|
|
||||||
|
def load(self) -> FirebaseCredentials | None:
|
||||||
|
"""Return the stored credentials, or ``None`` if not signed in."""
|
||||||
|
|
||||||
|
def save(self, credentials: FirebaseCredentials) -> None:
|
||||||
|
"""Persist ``credentials``."""
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Forget any stored credentials."""
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryCredentialStore:
|
||||||
|
"""A :class:`CredentialStore` for tests and one-shot scripts."""
|
||||||
|
|
||||||
|
def __init__(self, credentials: FirebaseCredentials | None = None) -> None:
|
||||||
|
"""Start holding ``credentials`` (or nothing)."""
|
||||||
|
self._credentials = credentials
|
||||||
|
|
||||||
|
def load(self) -> FirebaseCredentials | None:
|
||||||
|
"""Return the held credentials."""
|
||||||
|
return self._credentials
|
||||||
|
|
||||||
|
def save(self, credentials: FirebaseCredentials) -> None:
|
||||||
|
"""Replace the held credentials."""
|
||||||
|
self._credentials = credentials
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Drop the held credentials."""
|
||||||
|
self._credentials = None
|
||||||
|
|
||||||
|
|
||||||
|
class FileCredentialStore:
|
||||||
|
"""A :class:`CredentialStore` backed by a ``0600`` JSON file.
|
||||||
|
|
||||||
|
Persistence is not an optimisation here: ``wake_alarm``'s PC side is a
|
||||||
|
fresh process every minute and ``diet_guard``'s every 15 minutes, so an
|
||||||
|
in-memory store would re-authenticate thousands of times a day.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, path: Path) -> None:
|
||||||
|
"""Store credentials at ``path``, creating parent dirs as needed."""
|
||||||
|
self._path = path
|
||||||
|
|
||||||
|
def load(self) -> FirebaseCredentials | None:
|
||||||
|
"""Return the stored credentials, or ``None`` if the file is absent.
|
||||||
|
|
||||||
|
A corrupt or truncated file (an interrupted write) reads as "not
|
||||||
|
signed in" rather than raising: the caller's next step is to sign in
|
||||||
|
again, which repairs it.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = json.loads(self._path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return FirebaseCredentials.from_json(data)
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def save(self, credentials: FirebaseCredentials) -> None:
|
||||||
|
"""Write ``credentials`` atomically, readable only by this user."""
|
||||||
|
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temp = self._path.with_suffix(f"{self._path.suffix}.tmp")
|
||||||
|
# Create with 0600 from the outset -- writing then chmod'ing would
|
||||||
|
# leave the refresh token world-readable for the gap in between.
|
||||||
|
temp.touch(mode=0o600)
|
||||||
|
temp.write_text(json.dumps(credentials.to_json()), encoding="utf-8")
|
||||||
|
temp.replace(self._path)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Delete the credentials file if it exists."""
|
||||||
|
self._path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow() -> dt.datetime:
|
||||||
|
return dt.datetime.now(dt.timezone.utc)
|
||||||
100
crdt-sync/crdt_sync/_fbkeys.py
Normal file
100
crdt-sync/crdt_sync/_fbkeys.py
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
"""Escaping arbitrary strings into Firebase Realtime Database keys.
|
||||||
|
|
||||||
|
Split from :mod:`crdt_sync._firebase`, which keeps the client. RTDB forbids
|
||||||
|
``.``, ``$``, ``#``, ``[``, ``]`` and ``/`` in a key, but the app stores
|
||||||
|
record ids and file paths that contain them, so every key is escaped on the
|
||||||
|
way in and unescaped on the way out. The pair has to round-trip exactly or a
|
||||||
|
device silently stops seeing its own writes.
|
||||||
|
|
||||||
|
Re-exported from :mod:`crdt_sync._firebase`, so existing imports keep working.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# Characters Realtime Database forbids in a key, plus ``~`` itself because it
|
||||||
|
# is this module's escape character. ``/`` is absent deliberately: it is the
|
||||||
|
# path separator, handled by splitting into segments before escaping.
|
||||||
|
_ESCAPES = {
|
||||||
|
"~": "~7E",
|
||||||
|
".": "~2E",
|
||||||
|
"$": "~24",
|
||||||
|
"#": "~23",
|
||||||
|
"[": "~5B",
|
||||||
|
"]": "~5D",
|
||||||
|
}
|
||||||
|
_UNESCAPES = {escape: char for char, escape in _ESCAPES.items()}
|
||||||
|
_ESCAPE_LENGTH = 3
|
||||||
|
|
||||||
|
|
||||||
|
def encode_key(segment: str) -> str:
|
||||||
|
"""Escape one path segment into a legal Realtime Database key.
|
||||||
|
|
||||||
|
RTDB rejects ``. $ # [ ] /`` in keys, and the REST API's trailing
|
||||||
|
``.json`` is a *format suffix* rather than part of the path -- so a
|
||||||
|
filename like ``log.json`` cannot be stored verbatim. The mapping is a
|
||||||
|
reversible ``~XX`` escape (``log.json`` -> ``log~2Ejson``) rather than a
|
||||||
|
lossy "strip the extension", because callers see these names again:
|
||||||
|
todo-app lists *filenames*, not device directories, and must keep getting
|
||||||
|
``<uuid>.json`` back.
|
||||||
|
|
||||||
|
``~`` is the escape character because it is legal in RTDB keys and
|
||||||
|
unreserved in URLs, so the escaped form needs no percent-encoding --
|
||||||
|
which the server would otherwise decode back into the illegal character.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
segment : str
|
||||||
|
One path segment, with no ``/`` in it.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
-------
|
||||||
|
str
|
||||||
|
The escaped key.
|
||||||
|
|
||||||
|
"""
|
||||||
|
return "".join(_ESCAPES.get(char, char) for char in segment)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_key(key: str) -> str:
|
||||||
|
"""Reverse :func:`encode_key`.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
key : str
|
||||||
|
An escaped Realtime Database key.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
-------
|
||||||
|
str
|
||||||
|
The original segment.
|
||||||
|
|
||||||
|
"""
|
||||||
|
out: list[str] = []
|
||||||
|
index = 0
|
||||||
|
while index < len(key):
|
||||||
|
escape = key[index : index + _ESCAPE_LENGTH]
|
||||||
|
char = _UNESCAPES.get(escape)
|
||||||
|
if char is None:
|
||||||
|
out.append(key[index])
|
||||||
|
index += 1
|
||||||
|
else:
|
||||||
|
out.append(char)
|
||||||
|
index += _ESCAPE_LENGTH
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def encode_path(path: str) -> str:
|
||||||
|
"""Escape every segment of a ``/``-separated logical path.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
path : str
|
||||||
|
A logical path such as ``"todo-sync/notes/abc.json"``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
-------
|
||||||
|
str
|
||||||
|
The path with each segment escaped, empty segments dropped.
|
||||||
|
|
||||||
|
"""
|
||||||
|
return "/".join(encode_key(part) for part in path.split("/") if part)
|
||||||
@ -19,6 +19,7 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
import requests as _requests
|
import requests as _requests
|
||||||
|
|
||||||
|
from crdt_sync._fbkeys import decode_key, encode_key, encode_path
|
||||||
from crdt_sync._http import new_session
|
from crdt_sync._http import new_session
|
||||||
from crdt_sync._remote import RemoteNotFoundError, RemoteSyncError
|
from crdt_sync._remote import RemoteNotFoundError, RemoteSyncError
|
||||||
|
|
||||||
@ -34,19 +35,6 @@ _DEFAULT_TIMEOUT_SECONDS = 15
|
|||||||
_HTTP_UNAUTHORIZED = 401
|
_HTTP_UNAUTHORIZED = 401
|
||||||
_HTTP_FORBIDDEN = 403
|
_HTTP_FORBIDDEN = 403
|
||||||
|
|
||||||
# Characters Realtime Database forbids in a key, plus ``~`` itself because it
|
|
||||||
# is this module's escape character. ``/`` is absent deliberately: it is the
|
|
||||||
# path separator, handled by splitting into segments before escaping.
|
|
||||||
_ESCAPES = {
|
|
||||||
"~": "~7E",
|
|
||||||
".": "~2E",
|
|
||||||
"$": "~24",
|
|
||||||
"#": "~23",
|
|
||||||
"[": "~5B",
|
|
||||||
"]": "~5D",
|
|
||||||
}
|
|
||||||
_UNESCAPES = {escape: char for char, escape in _ESCAPES.items()}
|
|
||||||
_ESCAPE_LENGTH = 3
|
|
||||||
|
|
||||||
|
|
||||||
class FirebaseSyncError(RemoteSyncError):
|
class FirebaseSyncError(RemoteSyncError):
|
||||||
@ -63,80 +51,6 @@ class DatabaseNotFoundError(FirebaseSyncError, RemoteNotFoundError):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def encode_key(segment: str) -> str:
|
|
||||||
"""Escape one path segment into a legal Realtime Database key.
|
|
||||||
|
|
||||||
RTDB rejects ``. $ # [ ] /`` in keys, and the REST API's trailing
|
|
||||||
``.json`` is a *format suffix* rather than part of the path -- so a
|
|
||||||
filename like ``log.json`` cannot be stored verbatim. The mapping is a
|
|
||||||
reversible ``~XX`` escape (``log.json`` -> ``log~2Ejson``) rather than a
|
|
||||||
lossy "strip the extension", because callers see these names again:
|
|
||||||
todo-app lists *filenames*, not device directories, and must keep getting
|
|
||||||
``<uuid>.json`` back.
|
|
||||||
|
|
||||||
``~`` is the escape character because it is legal in RTDB keys and
|
|
||||||
unreserved in URLs, so the escaped form needs no percent-encoding --
|
|
||||||
which the server would otherwise decode back into the illegal character.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
segment : str
|
|
||||||
One path segment, with no ``/`` in it.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
-------
|
|
||||||
str
|
|
||||||
The escaped key.
|
|
||||||
|
|
||||||
"""
|
|
||||||
return "".join(_ESCAPES.get(char, char) for char in segment)
|
|
||||||
|
|
||||||
|
|
||||||
def decode_key(key: str) -> str:
|
|
||||||
"""Reverse :func:`encode_key`.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
key : str
|
|
||||||
An escaped Realtime Database key.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
-------
|
|
||||||
str
|
|
||||||
The original segment.
|
|
||||||
|
|
||||||
"""
|
|
||||||
out: list[str] = []
|
|
||||||
index = 0
|
|
||||||
while index < len(key):
|
|
||||||
escape = key[index : index + _ESCAPE_LENGTH]
|
|
||||||
char = _UNESCAPES.get(escape)
|
|
||||||
if char is None:
|
|
||||||
out.append(key[index])
|
|
||||||
index += 1
|
|
||||||
else:
|
|
||||||
out.append(char)
|
|
||||||
index += _ESCAPE_LENGTH
|
|
||||||
return "".join(out)
|
|
||||||
|
|
||||||
|
|
||||||
def encode_path(path: str) -> str:
|
|
||||||
"""Escape every segment of a ``/``-separated logical path.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
path : str
|
|
||||||
A logical path such as ``"todo-sync/notes/abc.json"``.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
-------
|
|
||||||
str
|
|
||||||
The path with each segment escaped, empty segments dropped.
|
|
||||||
|
|
||||||
"""
|
|
||||||
return "/".join(encode_key(part) for part in path.split("/") if part)
|
|
||||||
|
|
||||||
|
|
||||||
class FirebaseSyncClient:
|
class FirebaseSyncClient:
|
||||||
"""Realtime Database seen through the same contract as GitHub.
|
"""Realtime Database seen through the same contract as GitHub.
|
||||||
|
|
||||||
|
|||||||
@ -19,8 +19,15 @@ from typing import TYPE_CHECKING, Protocol
|
|||||||
|
|
||||||
import requests as _requests
|
import requests as _requests
|
||||||
|
|
||||||
|
from crdt_sync._autherrors import FirebaseAuthError, _is_revoked, _reason
|
||||||
|
from crdt_sync._credentials import (
|
||||||
|
CredentialStore,
|
||||||
|
FileCredentialStore,
|
||||||
|
FirebaseCredentials,
|
||||||
|
MemoryCredentialStore,
|
||||||
|
_utcnow,
|
||||||
|
)
|
||||||
from crdt_sync._http import new_session
|
from crdt_sync._http import new_session
|
||||||
from crdt_sync._remote import RemoteSyncError
|
|
||||||
|
|
||||||
# Bound to the name ``requests`` so the call sites below -- and the tests
|
# Bound to the name ``requests`` so the call sites below -- and the tests
|
||||||
# that patch ``<module>.requests.<verb>`` -- are unchanged by pooling.
|
# that patch ``<module>.requests.<verb>`` -- are unchanged by pooling.
|
||||||
@ -34,160 +41,6 @@ _SIGN_IN_BASE = "https://identitytoolkit.googleapis.com/v1"
|
|||||||
_REFRESH_BASE = "https://securetoken.googleapis.com/v1"
|
_REFRESH_BASE = "https://securetoken.googleapis.com/v1"
|
||||||
_DEFAULT_TIMEOUT_SECONDS = 15
|
_DEFAULT_TIMEOUT_SECONDS = 15
|
||||||
|
|
||||||
# Refresh this long before the token actually expires. An ID token lives ~1h;
|
|
||||||
# refreshing early means a tick that starts just under the wire still finishes
|
|
||||||
# with a valid token rather than 401ing halfway through a multi-file push.
|
|
||||||
_REFRESH_SKEW = dt.timedelta(minutes=5)
|
|
||||||
|
|
||||||
|
|
||||||
class FirebaseAuthError(RemoteSyncError):
|
|
||||||
"""Raised for an authentication failure the caller must not ignore.
|
|
||||||
|
|
||||||
A :class:`crdt_sync.RemoteSyncError` so callers that only care about
|
|
||||||
"sync is broken" can catch one type, while a settings screen can single
|
|
||||||
this out to say "your password is wrong" rather than "the network is
|
|
||||||
down".
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class FirebaseCredentials:
|
|
||||||
"""One device's session: a short-lived ID token plus its refresh token.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
----------
|
|
||||||
id_token:
|
|
||||||
The bearer credential for Realtime Database requests. Expires fast.
|
|
||||||
refresh_token:
|
|
||||||
The long-lived credential. **This is the secret worth protecting.**
|
|
||||||
expires_at:
|
|
||||||
When ``id_token`` stops being accepted, in UTC.
|
|
||||||
"""
|
|
||||||
|
|
||||||
id_token: str
|
|
||||||
refresh_token: str
|
|
||||||
expires_at: dt.datetime
|
|
||||||
|
|
||||||
def is_expired_at(self, now: dt.datetime) -> bool:
|
|
||||||
"""Return whether the token is expired, or close enough to it."""
|
|
||||||
return now + _REFRESH_SKEW >= self.expires_at
|
|
||||||
|
|
||||||
def to_json(self) -> dict[str, str]:
|
|
||||||
"""Return a JSON-serializable form."""
|
|
||||||
return {
|
|
||||||
"id_token": self.id_token,
|
|
||||||
"refresh_token": self.refresh_token,
|
|
||||||
"expires_at": self.expires_at.isoformat(),
|
|
||||||
}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_json(cls, data: dict[str, str]) -> FirebaseCredentials:
|
|
||||||
"""Rebuild credentials from :meth:`to_json` output."""
|
|
||||||
return cls(
|
|
||||||
id_token=data["id_token"],
|
|
||||||
refresh_token=data["refresh_token"],
|
|
||||||
expires_at=dt.datetime.fromisoformat(data["expires_at"]),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CredentialStore(Protocol):
|
|
||||||
"""Where a device keeps its credentials between runs."""
|
|
||||||
|
|
||||||
def load(self) -> FirebaseCredentials | None:
|
|
||||||
"""Return the stored credentials, or ``None`` if not signed in."""
|
|
||||||
|
|
||||||
def save(self, credentials: FirebaseCredentials) -> None:
|
|
||||||
"""Persist ``credentials``."""
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
|
||||||
"""Forget any stored credentials."""
|
|
||||||
|
|
||||||
|
|
||||||
class MemoryCredentialStore:
|
|
||||||
"""A :class:`CredentialStore` for tests and one-shot scripts."""
|
|
||||||
|
|
||||||
def __init__(self, credentials: FirebaseCredentials | None = None) -> None:
|
|
||||||
"""Start holding ``credentials`` (or nothing)."""
|
|
||||||
self._credentials = credentials
|
|
||||||
|
|
||||||
def load(self) -> FirebaseCredentials | None:
|
|
||||||
"""Return the held credentials."""
|
|
||||||
return self._credentials
|
|
||||||
|
|
||||||
def save(self, credentials: FirebaseCredentials) -> None:
|
|
||||||
"""Replace the held credentials."""
|
|
||||||
self._credentials = credentials
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
|
||||||
"""Drop the held credentials."""
|
|
||||||
self._credentials = None
|
|
||||||
|
|
||||||
|
|
||||||
class FileCredentialStore:
|
|
||||||
"""A :class:`CredentialStore` backed by a ``0600`` JSON file.
|
|
||||||
|
|
||||||
Persistence is not an optimisation here: ``wake_alarm``'s PC side is a
|
|
||||||
fresh process every minute and ``diet_guard``'s every 15 minutes, so an
|
|
||||||
in-memory store would re-authenticate thousands of times a day.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, path: Path) -> None:
|
|
||||||
"""Store credentials at ``path``, creating parent dirs as needed."""
|
|
||||||
self._path = path
|
|
||||||
|
|
||||||
def load(self) -> FirebaseCredentials | None:
|
|
||||||
"""Return the stored credentials, or ``None`` if the file is absent.
|
|
||||||
|
|
||||||
A corrupt or truncated file (an interrupted write) reads as "not
|
|
||||||
signed in" rather than raising: the caller's next step is to sign in
|
|
||||||
again, which repairs it.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
data = json.loads(self._path.read_text(encoding="utf-8"))
|
|
||||||
except (OSError, ValueError):
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return FirebaseCredentials.from_json(data)
|
|
||||||
except (KeyError, TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def save(self, credentials: FirebaseCredentials) -> None:
|
|
||||||
"""Write ``credentials`` atomically, readable only by this user."""
|
|
||||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
temp = self._path.with_suffix(f"{self._path.suffix}.tmp")
|
|
||||||
# Create with 0600 from the outset -- writing then chmod'ing would
|
|
||||||
# leave the refresh token world-readable for the gap in between.
|
|
||||||
temp.touch(mode=0o600)
|
|
||||||
temp.write_text(json.dumps(credentials.to_json()), encoding="utf-8")
|
|
||||||
temp.replace(self._path)
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
|
||||||
"""Delete the credentials file if it exists."""
|
|
||||||
self._path.unlink(missing_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _utcnow() -> dt.datetime:
|
|
||||||
return dt.datetime.now(dt.timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
#: Reasons that mean the refresh token is permanently dead. Only these clear
|
|
||||||
#: the session: a network error or a 5xx must not, because a device that signs
|
|
||||||
#: itself out whenever the wifi drops needs a manual sign-in to recover, which
|
|
||||||
#: is a worse failure than the stale banner this guards against.
|
|
||||||
_TERMINAL_AUTH_REASONS = (
|
|
||||||
"TOKEN_EXPIRED",
|
|
||||||
"USER_DISABLED",
|
|
||||||
"USER_NOT_FOUND",
|
|
||||||
"INVALID_REFRESH_TOKEN",
|
|
||||||
"INVALID_GRANT_TYPE",
|
|
||||||
"MISSING_REFRESH_TOKEN",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_revoked(error: FirebaseAuthError) -> bool:
|
|
||||||
"""Return whether ``error`` means the refresh token is permanently dead."""
|
|
||||||
message = str(error)
|
|
||||||
return any(reason in message for reason in _TERMINAL_AUTH_REASONS)
|
|
||||||
|
|
||||||
|
|
||||||
class FirebaseTokenProvider:
|
class FirebaseTokenProvider:
|
||||||
@ -387,26 +240,3 @@ class FirebaseTokenProvider:
|
|||||||
msg = f"failed to {what}: HTTP {response.status_code}{reason}"
|
msg = f"failed to {what}: HTTP {response.status_code}{reason}"
|
||||||
raise FirebaseAuthError(msg)
|
raise FirebaseAuthError(msg)
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
def _reason(response: _requests.Response) -> str:
|
|
||||||
"""Return Google's machine-readable error reason, in parentheses.
|
|
||||||
|
|
||||||
Pulls ``INVALID_PASSWORD`` / ``TOKEN_EXPIRED`` / ``USER_DISABLED`` out of
|
|
||||||
the body so the raised message says what actually went wrong rather than
|
|
||||||
just reporting a status code.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
data = response.json()
|
|
||||||
except ValueError:
|
|
||||||
# A non-JSON body (a proxy error page, say): the status code is all
|
|
||||||
# the detail there is.
|
|
||||||
return ""
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
return ""
|
|
||||||
error = data.get("error")
|
|
||||||
if isinstance(error, dict):
|
|
||||||
return f" ({error.get('message')})"
|
|
||||||
if isinstance(error, str):
|
|
||||||
return f" ({error})"
|
|
||||||
return ""
|
|
||||||
|
|||||||
30
crdt-sync/crdt_sync/_gherrors.py
Normal file
30
crdt-sync/crdt_sync/_gherrors.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
"""The errors the GitHub sync backend raises.
|
||||||
|
|
||||||
|
Split from :mod:`crdt_sync._github` for the 250-line cap. Kept separate so a
|
||||||
|
caller can catch them without importing the client -- and so the client file
|
||||||
|
is the client, not its taxonomy.
|
||||||
|
|
||||||
|
Re-exported from :mod:`crdt_sync._github`, so existing imports keep working.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from crdt_sync._remote import RemoteNotFoundError, RemoteSyncError
|
||||||
|
|
||||||
|
|
||||||
|
class GitHubSyncError(RemoteSyncError):
|
||||||
|
"""Raised for a GitHub API failure the caller must not silently ignore."""
|
||||||
|
|
||||||
|
|
||||||
|
class RepoNotFoundError(GitHubSyncError, RemoteNotFoundError):
|
||||||
|
"""Raised when the configured repo itself is unreachable.
|
||||||
|
|
||||||
|
Distinguished from a path-404 (nothing pushed to that path yet, which is
|
||||||
|
benign -- it just means no other device has synced before) so the caller
|
||||||
|
can tell "the repo name is wrong or the token isn't scoped to it" apart
|
||||||
|
from "no other device has synced yet".
|
||||||
|
|
||||||
|
Derives from both so existing ``except GitHubSyncError`` handlers still
|
||||||
|
cover it *and* backend-neutral callers can catch "the remote itself is
|
||||||
|
missing" without naming GitHub.
|
||||||
|
"""
|
||||||
@ -13,6 +13,7 @@ import base64
|
|||||||
|
|
||||||
import requests as _requests
|
import requests as _requests
|
||||||
|
|
||||||
|
from crdt_sync._gherrors import GitHubSyncError, RepoNotFoundError
|
||||||
from crdt_sync._http import new_session
|
from crdt_sync._http import new_session
|
||||||
from crdt_sync._remote import RemoteNotFoundError, RemoteSyncError
|
from crdt_sync._remote import RemoteNotFoundError, RemoteSyncError
|
||||||
|
|
||||||
@ -26,24 +27,6 @@ _HTTP_NOT_FOUND = 404
|
|||||||
_DEFAULT_TIMEOUT_SECONDS = 15
|
_DEFAULT_TIMEOUT_SECONDS = 15
|
||||||
|
|
||||||
|
|
||||||
class GitHubSyncError(RemoteSyncError):
|
|
||||||
"""Raised for a GitHub API failure the caller must not silently ignore."""
|
|
||||||
|
|
||||||
|
|
||||||
class RepoNotFoundError(GitHubSyncError, RemoteNotFoundError):
|
|
||||||
"""Raised when the configured repo itself is unreachable.
|
|
||||||
|
|
||||||
Distinguished from a path-404 (nothing pushed to that path yet, which is
|
|
||||||
benign -- it just means no other device has synced before) so the caller
|
|
||||||
can tell "the repo name is wrong or the token isn't scoped to it" apart
|
|
||||||
from "no other device has synced yet".
|
|
||||||
|
|
||||||
Derives from both so existing ``except GitHubSyncError`` handlers still
|
|
||||||
cover it *and* backend-neutral callers can catch "the remote itself is
|
|
||||||
missing" without naming GitHub.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class GitHubSyncClient:
|
class GitHubSyncClient:
|
||||||
"""Thin wrapper around the subset of the Contents API sync needs."""
|
"""Thin wrapper around the subset of the Contents API sync needs."""
|
||||||
|
|
||||||
|
|||||||
109
crdt-sync/crdt_sync/_pull.py
Normal file
109
crdt-sync/crdt_sync/_pull.py
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
"""Downloading peers' logs, skipping the ones we already have.
|
||||||
|
|
||||||
|
Split from :mod:`crdt_sync._sync`, which keeps the push half and the
|
||||||
|
orchestration. The revision map is the optimisation that makes a tick cheap:
|
||||||
|
a peer whose published revision matches what we last merged is not fetched at
|
||||||
|
all, so a quiet device costs one small read instead of one read per peer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
from crdt_sync._log import Log
|
||||||
|
from crdt_sync._remote import RemoteStore
|
||||||
|
|
||||||
|
from crdt_sync._revisions import revision_of
|
||||||
|
from crdt_sync._syncstate import SyncState
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _remote_revs(client: RemoteStore, revs_path: str) -> dict[str, str]:
|
||||||
|
"""Return every peer's published revision, cheaply where possible.
|
||||||
|
|
||||||
|
Degrades to an empty map -- meaning "fetch everything", the old behaviour
|
||||||
|
-- on any backend without a bulk-map read, so correctness never depends
|
||||||
|
on the optimisation being available.
|
||||||
|
"""
|
||||||
|
get_string_map = getattr(client, "get_string_map", None)
|
||||||
|
if get_string_map is None:
|
||||||
|
return {}
|
||||||
|
return get_string_map(revs_path)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _PullContext:
|
||||||
|
"""Everything :func:`_pull_remote_logs` needs for one tick's pull.
|
||||||
|
|
||||||
|
A bundle rather than eight positional parameters: they are always passed
|
||||||
|
together, from one call site, and several share a type -- exactly the
|
||||||
|
shape where a positional mix-up is silent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
client: RemoteStore
|
||||||
|
own_ids: frozenset[str]
|
||||||
|
path_prefix: str
|
||||||
|
filename: str
|
||||||
|
decode: Callable[[str], Log]
|
||||||
|
remote_revs: dict[str, str]
|
||||||
|
state: SyncState
|
||||||
|
|
||||||
|
|
||||||
|
def _pull_remote_logs(
|
||||||
|
ctx: _PullContext,
|
||||||
|
seen_revs: dict[str, str],
|
||||||
|
) -> list[Log]:
|
||||||
|
"""Return every other device's last-pushed log, skipping this one.
|
||||||
|
|
||||||
|
A device whose pushed file is corrupt or unparsable (e.g. an interrupted
|
||||||
|
push) is logged and skipped, same as one that has never pushed at all --
|
||||||
|
the remote is an external system boundary, and one bad device's file must
|
||||||
|
not stall merging in every other device's.
|
||||||
|
|
||||||
|
``own_ids`` is a *set* rather than a single id because a device that has
|
||||||
|
migrated from a role constant to a persisted uuid still owns the log it
|
||||||
|
pushed under the old id. Matching only the current id would pull that
|
||||||
|
file back and re-merge this device's own pre-migration history as though
|
||||||
|
a peer had written it -- idempotent, but pure wasted transfer.
|
||||||
|
|
||||||
|
Records into ``seen_revs`` which peers are now merged in, so the next
|
||||||
|
tick can skip them.
|
||||||
|
"""
|
||||||
|
remote_logs: list[Log] = []
|
||||||
|
for other_device_id in ctx.client.list_directory(ctx.path_prefix):
|
||||||
|
if other_device_id in ctx.own_ids:
|
||||||
|
continue
|
||||||
|
remote_rev = ctx.remote_revs.get(other_device_id)
|
||||||
|
if remote_rev is not None and remote_rev == ctx.state.peer_revs.get(
|
||||||
|
other_device_id
|
||||||
|
):
|
||||||
|
# Unchanged since we last merged it, and that merge is already
|
||||||
|
# part of local_log -- so the (potentially hundreds of KB)
|
||||||
|
# download is pure waste. Carry the revision forward so it stays
|
||||||
|
# skipped next tick.
|
||||||
|
seen_revs[other_device_id] = remote_rev
|
||||||
|
continue
|
||||||
|
text = ctx.client.get_file_text(
|
||||||
|
f"{ctx.path_prefix}/{other_device_id}/{ctx.filename}"
|
||||||
|
)
|
||||||
|
if text is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
remote_logs.append(ctx.decode(text))
|
||||||
|
except (ValueError, KeyError, TypeError):
|
||||||
|
_logger.warning(
|
||||||
|
"Unparsable log pushed by device %r, skipping",
|
||||||
|
other_device_id,
|
||||||
|
)
|
||||||
|
# Deliberately not recorded as seen: a corrupt push must be
|
||||||
|
# retried next tick, not remembered as merged.
|
||||||
|
continue
|
||||||
|
seen_revs[other_device_id] = remote_rev or revision_of(text)
|
||||||
|
return remote_logs
|
||||||
36
crdt-sync/crdt_sync/_revisions.py
Normal file
36
crdt-sync/crdt_sync/_revisions.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
"""The revision string a device publishes alongside its log.
|
||||||
|
|
||||||
|
Split from :mod:`crdt_sync._sync` so both the push and pull halves can import
|
||||||
|
it without importing each other. Pure: a revision is a function of the encoded
|
||||||
|
log and nothing else, which is what lets two devices agree on whether they are
|
||||||
|
looking at the same bytes.
|
||||||
|
|
||||||
|
Re-exported from :mod:`crdt_sync._sync`, so existing imports keep working.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
def revision_of(encoded_log: str) -> str:
|
||||||
|
"""Return the revision of a serialized log: a content hash.
|
||||||
|
|
||||||
|
A hash rather than the log's maximum HLC because merging a peer's *older*
|
||||||
|
record changes the content without raising the maximum -- with three
|
||||||
|
devices in one namespace (diet-guard has ``pc``, ``phone`` and
|
||||||
|
``desktop``) a clock-based revision can miss a peer's merged state. It is
|
||||||
|
also the same value used to suppress no-op pushes, so the two
|
||||||
|
optimisations share one mechanism.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
encoded_log : str
|
||||||
|
The serialized log, exactly as it would be pushed.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
-------
|
||||||
|
str
|
||||||
|
A hex SHA-256 digest.
|
||||||
|
|
||||||
|
"""
|
||||||
|
return hashlib.sha256(encoded_log.encode("utf-8")).hexdigest()
|
||||||
@ -26,147 +26,20 @@ if TYPE_CHECKING:
|
|||||||
from crdt_sync._log import Log
|
from crdt_sync._log import Log
|
||||||
from crdt_sync._remote import RemoteStore
|
from crdt_sync._remote import RemoteStore
|
||||||
|
|
||||||
|
from crdt_sync._pull import _PullContext, _pull_remote_logs, _remote_revs
|
||||||
|
from crdt_sync._revisions import revision_of
|
||||||
|
from crdt_sync._syncstate import (
|
||||||
|
FileSyncStateStore,
|
||||||
|
MemorySyncStateStore,
|
||||||
|
SyncState,
|
||||||
|
SyncStateStore,
|
||||||
|
)
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_DEFAULT_FILENAME = "log.json"
|
_DEFAULT_FILENAME = "log.json"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class SyncState:
|
|
||||||
"""What one device remembers between ticks to skip needless traffic.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
----------
|
|
||||||
pushed_rev:
|
|
||||||
Hash of what this device last pushed, so an unchanged log is not
|
|
||||||
re-uploaded. 88% of the pushes in the GitHub-backed history this
|
|
||||||
replaces were byte-identical no-ops.
|
|
||||||
peer_revs:
|
|
||||||
The hash each peer had when we last downloaded it, so an unchanged
|
|
||||||
peer is not re-downloaded.
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
-----
|
|
||||||
**Must be stored next to the log itself and cleared with it.** Skipping
|
|
||||||
an unchanged peer is only sound because that peer's records are already
|
|
||||||
merged into the local log; a cache that outlived its log would skip peers
|
|
||||||
whose data had been lost.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
pushed_rev: str | None = None
|
|
||||||
peer_revs: dict[str, str] = field(default_factory=dict)
|
|
||||||
|
|
||||||
def to_json(self) -> dict[str, object]:
|
|
||||||
"""Return a JSON-serializable form."""
|
|
||||||
return {"pushed_rev": self.pushed_rev, "peer_revs": self.peer_revs}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_json(cls, data: dict[str, object]) -> SyncState:
|
|
||||||
"""Rebuild state from :meth:`to_json` output, ignoring junk.
|
|
||||||
|
|
||||||
A malformed cache degrades into "fetch everything" rather than
|
|
||||||
raising: it is an optimisation, never a source of failure.
|
|
||||||
"""
|
|
||||||
pushed = data.get("pushed_rev")
|
|
||||||
peers = data.get("peer_revs")
|
|
||||||
return cls(
|
|
||||||
pushed_rev=pushed if isinstance(pushed, str) else None,
|
|
||||||
peer_revs={
|
|
||||||
key: value
|
|
||||||
for key, value in (peers or {}).items()
|
|
||||||
if isinstance(value, str)
|
|
||||||
}
|
|
||||||
if isinstance(peers, dict)
|
|
||||||
else {},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SyncStateStore(Protocol):
|
|
||||||
"""Where :func:`sync_log` persists its :class:`SyncState` between runs."""
|
|
||||||
|
|
||||||
def load(self) -> SyncState:
|
|
||||||
"""Return the stored state, or a default one."""
|
|
||||||
|
|
||||||
def save(self, state: SyncState) -> None:
|
|
||||||
"""Persist ``state``."""
|
|
||||||
|
|
||||||
|
|
||||||
class MemorySyncStateStore:
|
|
||||||
"""A :class:`SyncStateStore` that forgets on exit, for tests.
|
|
||||||
|
|
||||||
Correct but pessimistic: every process re-downloads every peer and
|
|
||||||
re-pushes the local log, which is exactly the old behaviour.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
"""Start with empty state."""
|
|
||||||
self._state = SyncState()
|
|
||||||
|
|
||||||
def load(self) -> SyncState:
|
|
||||||
"""Return the held state."""
|
|
||||||
return self._state
|
|
||||||
|
|
||||||
def save(self, state: SyncState) -> None:
|
|
||||||
"""Replace the held state."""
|
|
||||||
self._state = state
|
|
||||||
|
|
||||||
|
|
||||||
class FileSyncStateStore:
|
|
||||||
"""A :class:`SyncStateStore` backed by a JSON file.
|
|
||||||
|
|
||||||
Persistence is what makes the saving real for short-lived callers:
|
|
||||||
``wake_alarm``'s PC side is a fresh process every minute and
|
|
||||||
``diet_guard``'s every 15 minutes, so an in-memory store would save
|
|
||||||
nothing at all.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, path: Path) -> None:
|
|
||||||
"""Keep state in the file at ``path``."""
|
|
||||||
self._path = path
|
|
||||||
|
|
||||||
def load(self) -> SyncState:
|
|
||||||
"""Return the stored state, or a default one if unreadable."""
|
|
||||||
try:
|
|
||||||
data = json.loads(self._path.read_text(encoding="utf-8"))
|
|
||||||
except (OSError, ValueError):
|
|
||||||
return SyncState()
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
return SyncState()
|
|
||||||
return SyncState.from_json(data)
|
|
||||||
|
|
||||||
def save(self, state: SyncState) -> None:
|
|
||||||
"""Write ``state`` atomically, so a crash cannot truncate it."""
|
|
||||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
temp = self._path.with_suffix(f"{self._path.suffix}.tmp")
|
|
||||||
temp.write_text(json.dumps(state.to_json()), encoding="utf-8")
|
|
||||||
temp.replace(self._path)
|
|
||||||
|
|
||||||
|
|
||||||
def revision_of(encoded_log: str) -> str:
|
|
||||||
"""Return the revision of a serialized log: a content hash.
|
|
||||||
|
|
||||||
A hash rather than the log's maximum HLC because merging a peer's *older*
|
|
||||||
record changes the content without raising the maximum -- with three
|
|
||||||
devices in one namespace (diet-guard has ``pc``, ``phone`` and
|
|
||||||
``desktop``) a clock-based revision can miss a peer's merged state. It is
|
|
||||||
also the same value used to suppress no-op pushes, so the two
|
|
||||||
optimisations share one mechanism.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
encoded_log : str
|
|
||||||
The serialized log, exactly as it would be pushed.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
-------
|
|
||||||
str
|
|
||||||
A hex SHA-256 digest.
|
|
||||||
|
|
||||||
"""
|
|
||||||
return hashlib.sha256(encoded_log.encode("utf-8")).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def default_revs_path(path_prefix: str) -> str:
|
def default_revs_path(path_prefix: str) -> str:
|
||||||
"""Return where revisions live for ``path_prefix``.
|
"""Return where revisions live for ``path_prefix``.
|
||||||
|
|
||||||
@ -189,90 +62,6 @@ def default_revs_path(path_prefix: str) -> str:
|
|||||||
return f"{head}/revs" if separator else f"{path_prefix}/revs"
|
return f"{head}/revs" if separator else f"{path_prefix}/revs"
|
||||||
|
|
||||||
|
|
||||||
def _remote_revs(client: RemoteStore, revs_path: str) -> dict[str, str]:
|
|
||||||
"""Return every peer's published revision, cheaply where possible.
|
|
||||||
|
|
||||||
Degrades to an empty map -- meaning "fetch everything", the old behaviour
|
|
||||||
-- on any backend without a bulk-map read, so correctness never depends
|
|
||||||
on the optimisation being available.
|
|
||||||
"""
|
|
||||||
get_string_map = getattr(client, "get_string_map", None)
|
|
||||||
if get_string_map is None:
|
|
||||||
return {}
|
|
||||||
return get_string_map(revs_path)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class _PullContext:
|
|
||||||
"""Everything :func:`_pull_remote_logs` needs for one tick's pull.
|
|
||||||
|
|
||||||
A bundle rather than eight positional parameters: they are always passed
|
|
||||||
together, from one call site, and several share a type -- exactly the
|
|
||||||
shape where a positional mix-up is silent.
|
|
||||||
"""
|
|
||||||
|
|
||||||
client: RemoteStore
|
|
||||||
own_ids: frozenset[str]
|
|
||||||
path_prefix: str
|
|
||||||
filename: str
|
|
||||||
decode: Callable[[str], Log]
|
|
||||||
remote_revs: dict[str, str]
|
|
||||||
state: SyncState
|
|
||||||
|
|
||||||
|
|
||||||
def _pull_remote_logs(
|
|
||||||
ctx: _PullContext,
|
|
||||||
seen_revs: dict[str, str],
|
|
||||||
) -> list[Log]:
|
|
||||||
"""Return every other device's last-pushed log, skipping this one.
|
|
||||||
|
|
||||||
A device whose pushed file is corrupt or unparsable (e.g. an interrupted
|
|
||||||
push) is logged and skipped, same as one that has never pushed at all --
|
|
||||||
the remote is an external system boundary, and one bad device's file must
|
|
||||||
not stall merging in every other device's.
|
|
||||||
|
|
||||||
``own_ids`` is a *set* rather than a single id because a device that has
|
|
||||||
migrated from a role constant to a persisted uuid still owns the log it
|
|
||||||
pushed under the old id. Matching only the current id would pull that
|
|
||||||
file back and re-merge this device's own pre-migration history as though
|
|
||||||
a peer had written it -- idempotent, but pure wasted transfer.
|
|
||||||
|
|
||||||
Records into ``seen_revs`` which peers are now merged in, so the next
|
|
||||||
tick can skip them.
|
|
||||||
"""
|
|
||||||
remote_logs: list[Log] = []
|
|
||||||
for other_device_id in ctx.client.list_directory(ctx.path_prefix):
|
|
||||||
if other_device_id in ctx.own_ids:
|
|
||||||
continue
|
|
||||||
remote_rev = ctx.remote_revs.get(other_device_id)
|
|
||||||
if remote_rev is not None and remote_rev == ctx.state.peer_revs.get(
|
|
||||||
other_device_id
|
|
||||||
):
|
|
||||||
# Unchanged since we last merged it, and that merge is already
|
|
||||||
# part of local_log -- so the (potentially hundreds of KB)
|
|
||||||
# download is pure waste. Carry the revision forward so it stays
|
|
||||||
# skipped next tick.
|
|
||||||
seen_revs[other_device_id] = remote_rev
|
|
||||||
continue
|
|
||||||
text = ctx.client.get_file_text(
|
|
||||||
f"{ctx.path_prefix}/{other_device_id}/{ctx.filename}"
|
|
||||||
)
|
|
||||||
if text is None:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
remote_logs.append(ctx.decode(text))
|
|
||||||
except (ValueError, KeyError, TypeError):
|
|
||||||
_logger.warning(
|
|
||||||
"Unparsable log pushed by device %r, skipping",
|
|
||||||
other_device_id,
|
|
||||||
)
|
|
||||||
# Deliberately not recorded as seen: a corrupt push must be
|
|
||||||
# retried next tick, not remembered as merged.
|
|
||||||
continue
|
|
||||||
seen_revs[other_device_id] = remote_rev or revision_of(text)
|
|
||||||
return remote_logs
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class SyncTarget:
|
class SyncTarget:
|
||||||
"""Which remote, which device, and where its logs live.
|
"""Which remote, which device, and where its logs live.
|
||||||
|
|||||||
131
crdt-sync/crdt_sync/_syncstate.py
Normal file
131
crdt-sync/crdt_sync/_syncstate.py
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
"""Where a device records what it has already pushed and pulled.
|
||||||
|
|
||||||
|
Split from :mod:`crdt_sync._sync`, which keeps the sync itself. The state is
|
||||||
|
what makes a tick cheap: without it every run re-downloads every peer, and
|
||||||
|
with a stale one a device silently stops seeing updates. Two stores ship --
|
||||||
|
in-memory for tests, and a JSON file for real installs.
|
||||||
|
|
||||||
|
Re-exported from :mod:`crdt_sync._sync`, so existing imports keep working.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SyncState:
|
||||||
|
"""What one device remembers between ticks to skip needless traffic.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
----------
|
||||||
|
pushed_rev:
|
||||||
|
Hash of what this device last pushed, so an unchanged log is not
|
||||||
|
re-uploaded. 88% of the pushes in the GitHub-backed history this
|
||||||
|
replaces were byte-identical no-ops.
|
||||||
|
peer_revs:
|
||||||
|
The hash each peer had when we last downloaded it, so an unchanged
|
||||||
|
peer is not re-downloaded.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
-----
|
||||||
|
**Must be stored next to the log itself and cleared with it.** Skipping
|
||||||
|
an unchanged peer is only sound because that peer's records are already
|
||||||
|
merged into the local log; a cache that outlived its log would skip peers
|
||||||
|
whose data had been lost.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
pushed_rev: str | None = None
|
||||||
|
peer_revs: dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_json(self) -> dict[str, object]:
|
||||||
|
"""Return a JSON-serializable form."""
|
||||||
|
return {"pushed_rev": self.pushed_rev, "peer_revs": self.peer_revs}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_json(cls, data: dict[str, object]) -> SyncState:
|
||||||
|
"""Rebuild state from :meth:`to_json` output, ignoring junk.
|
||||||
|
|
||||||
|
A malformed cache degrades into "fetch everything" rather than
|
||||||
|
raising: it is an optimisation, never a source of failure.
|
||||||
|
"""
|
||||||
|
pushed = data.get("pushed_rev")
|
||||||
|
peers = data.get("peer_revs")
|
||||||
|
return cls(
|
||||||
|
pushed_rev=pushed if isinstance(pushed, str) else None,
|
||||||
|
peer_revs={
|
||||||
|
key: value
|
||||||
|
for key, value in (peers or {}).items()
|
||||||
|
if isinstance(value, str)
|
||||||
|
}
|
||||||
|
if isinstance(peers, dict)
|
||||||
|
else {},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SyncStateStore(Protocol):
|
||||||
|
"""Where :func:`sync_log` persists its :class:`SyncState` between runs."""
|
||||||
|
|
||||||
|
def load(self) -> SyncState:
|
||||||
|
"""Return the stored state, or a default one."""
|
||||||
|
|
||||||
|
def save(self, state: SyncState) -> None:
|
||||||
|
"""Persist ``state``."""
|
||||||
|
|
||||||
|
|
||||||
|
class MemorySyncStateStore:
|
||||||
|
"""A :class:`SyncStateStore` that forgets on exit, for tests.
|
||||||
|
|
||||||
|
Correct but pessimistic: every process re-downloads every peer and
|
||||||
|
re-pushes the local log, which is exactly the old behaviour.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Start with empty state."""
|
||||||
|
self._state = SyncState()
|
||||||
|
|
||||||
|
def load(self) -> SyncState:
|
||||||
|
"""Return the held state."""
|
||||||
|
return self._state
|
||||||
|
|
||||||
|
def save(self, state: SyncState) -> None:
|
||||||
|
"""Replace the held state."""
|
||||||
|
self._state = state
|
||||||
|
|
||||||
|
|
||||||
|
class FileSyncStateStore:
|
||||||
|
"""A :class:`SyncStateStore` backed by a JSON file.
|
||||||
|
|
||||||
|
Persistence is what makes the saving real for short-lived callers:
|
||||||
|
``wake_alarm``'s PC side is a fresh process every minute and
|
||||||
|
``diet_guard``'s every 15 minutes, so an in-memory store would save
|
||||||
|
nothing at all.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, path: Path) -> None:
|
||||||
|
"""Keep state in the file at ``path``."""
|
||||||
|
self._path = path
|
||||||
|
|
||||||
|
def load(self) -> SyncState:
|
||||||
|
"""Return the stored state, or a default one if unreadable."""
|
||||||
|
try:
|
||||||
|
data = json.loads(self._path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return SyncState()
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return SyncState()
|
||||||
|
return SyncState.from_json(data)
|
||||||
|
|
||||||
|
def save(self, state: SyncState) -> None:
|
||||||
|
"""Write ``state`` atomically, so a crash cannot truncate it."""
|
||||||
|
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temp = self._path.with_suffix(f"{self._path.suffix}.tmp")
|
||||||
|
temp.write_text(json.dumps(state.to_json()), encoding="utf-8")
|
||||||
|
temp.replace(self._path)
|
||||||
109
crdt-sync/tool/_oauth_callback.py
Normal file
109
crdt-sync/tool/_oauth_callback.py
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
"""The one-shot local HTTP server that catches Google's OAuth redirect.
|
||||||
|
|
||||||
|
Split from :mod:`google_id_token`, which keeps the token exchange. Google
|
||||||
|
redirects the browser back to ``http://localhost:<port>`` with the code in the
|
||||||
|
query string, so something has to be listening on a free port for exactly one
|
||||||
|
request and then stop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import http.server
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import logging
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
from typing import TYPE_CHECKING, ClassVar
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_CONSENT_TIMEOUT_SECONDS = 300
|
||||||
|
|
||||||
|
|
||||||
|
class _CallbackHandler(http.server.BaseHTTPRequestHandler):
|
||||||
|
"""Captures the ``code`` (or ``error``) Google redirects back with."""
|
||||||
|
|
||||||
|
# Set by the server owner before serving. Class attributes because
|
||||||
|
# BaseHTTPRequestHandler is instantiated per request by the server, so
|
||||||
|
# there is no instance to hand them to.
|
||||||
|
expected_state: ClassVar[str] = ""
|
||||||
|
result: ClassVar[dict[str, str]] = {}
|
||||||
|
|
||||||
|
def do_GET(self) -> None:
|
||||||
|
"""Record the query parameters and show a closable page.
|
||||||
|
|
||||||
|
Requests that carry neither a ``code`` nor an ``error`` are answered
|
||||||
|
but *not* recorded: browsers fetch ``/favicon.ico`` against the
|
||||||
|
redirect origin, and letting that count as the callback ends the flow
|
||||||
|
before Google ever redirects, which surfaces as a consent timeout.
|
||||||
|
"""
|
||||||
|
query = urllib.parse.urlparse(self.path).query
|
||||||
|
params = {k: v[0] for k, v in urllib.parse.parse_qs(query).items()}
|
||||||
|
if "code" not in params and "error" not in params:
|
||||||
|
self.send_response(204)
|
||||||
|
self.send_header("Content-Length", "0")
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
type(self).result = params
|
||||||
|
|
||||||
|
ok = "code" in params and params.get("state") == self.expected_state
|
||||||
|
body = (
|
||||||
|
b"<html><body><h2>Done - you can close this tab.</h2></body></html>"
|
||||||
|
if ok
|
||||||
|
else b"<html><body><h2>Authorization failed.</h2></body></html>"
|
||||||
|
)
|
||||||
|
self.send_response(200 if ok else 400)
|
||||||
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def log_message(self, *args: object) -> None:
|
||||||
|
"""Silence the default stderr request logging."""
|
||||||
|
|
||||||
|
|
||||||
|
def _free_port() -> int:
|
||||||
|
with socket.socket() as sock:
|
||||||
|
sock.bind(("127.0.0.1", 0))
|
||||||
|
return int(sock.getsockname()[1])
|
||||||
|
|
||||||
|
|
||||||
|
def _start_callback_server(
|
||||||
|
port: int,
|
||||||
|
state: str,
|
||||||
|
) -> tuple[http.server.HTTPServer, threading.Thread]:
|
||||||
|
"""Listen on ``port`` until the OAuth callback arrives.
|
||||||
|
|
||||||
|
Serves in a loop rather than handling exactly one request: the first
|
||||||
|
request to reach this port is often a favicon fetch or a speculative
|
||||||
|
preconnect, and retiring the server on it leaves nothing listening when
|
||||||
|
Google finally redirects -- which surfaces as a consent timeout.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
port: The loopback port to bind.
|
||||||
|
state: The value the callback must echo back.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The server and the thread serving it, so the caller can join and close.
|
||||||
|
"""
|
||||||
|
_CallbackHandler.expected_state = state
|
||||||
|
_CallbackHandler.result = {}
|
||||||
|
server = http.server.HTTPServer(("127.0.0.1", port), _CallbackHandler)
|
||||||
|
# Bounded so handle_request() returns periodically instead of blocking
|
||||||
|
# forever; without it the loop could not notice the deadline and the
|
||||||
|
# daemon thread would sit on the socket after the caller gave up.
|
||||||
|
server.timeout = 1.0
|
||||||
|
deadline = time.monotonic() + _CONSENT_TIMEOUT_SECONDS
|
||||||
|
|
||||||
|
def _serve_until_callback() -> None:
|
||||||
|
while not _CallbackHandler.result and time.monotonic() < deadline:
|
||||||
|
server.handle_request()
|
||||||
|
|
||||||
|
thread = threading.Thread(target=_serve_until_callback, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
return server, thread
|
||||||
@ -62,99 +62,22 @@ _TOKEN_EXCHANGE_URL = f"{_OAUTH_HOST}/token"
|
|||||||
_SCOPES = "openid email profile"
|
_SCOPES = "openid email profile"
|
||||||
|
|
||||||
_TIMEOUT_SECONDS = 30
|
_TIMEOUT_SECONDS = 30
|
||||||
_CONSENT_TIMEOUT_SECONDS = 300
|
|
||||||
|
|
||||||
_logger = logging.getLogger("google_id_token")
|
_logger = logging.getLogger("google_id_token")
|
||||||
|
|
||||||
|
|
||||||
|
from tool._oauth_callback import (
|
||||||
|
_CONSENT_TIMEOUT_SECONDS,
|
||||||
|
_CallbackHandler,
|
||||||
|
_free_port,
|
||||||
|
_start_callback_server,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TokenError(Exception):
|
class TokenError(Exception):
|
||||||
"""The authorization flow did not produce an ID token."""
|
"""The authorization flow did not produce an ID token."""
|
||||||
|
|
||||||
|
|
||||||
class _CallbackHandler(http.server.BaseHTTPRequestHandler):
|
|
||||||
"""Captures the ``code`` (or ``error``) Google redirects back with."""
|
|
||||||
|
|
||||||
# Set by the server owner before serving. Class attributes because
|
|
||||||
# BaseHTTPRequestHandler is instantiated per request by the server, so
|
|
||||||
# there is no instance to hand them to.
|
|
||||||
expected_state: ClassVar[str] = ""
|
|
||||||
result: ClassVar[dict[str, str]] = {}
|
|
||||||
|
|
||||||
def do_GET(self) -> None:
|
|
||||||
"""Record the query parameters and show a closable page.
|
|
||||||
|
|
||||||
Requests that carry neither a ``code`` nor an ``error`` are answered
|
|
||||||
but *not* recorded: browsers fetch ``/favicon.ico`` against the
|
|
||||||
redirect origin, and letting that count as the callback ends the flow
|
|
||||||
before Google ever redirects, which surfaces as a consent timeout.
|
|
||||||
"""
|
|
||||||
query = urllib.parse.urlparse(self.path).query
|
|
||||||
params = {k: v[0] for k, v in urllib.parse.parse_qs(query).items()}
|
|
||||||
if "code" not in params and "error" not in params:
|
|
||||||
self.send_response(204)
|
|
||||||
self.send_header("Content-Length", "0")
|
|
||||||
self.end_headers()
|
|
||||||
return
|
|
||||||
type(self).result = params
|
|
||||||
|
|
||||||
ok = "code" in params and params.get("state") == self.expected_state
|
|
||||||
body = (
|
|
||||||
b"<html><body><h2>Done - you can close this tab.</h2></body></html>"
|
|
||||||
if ok
|
|
||||||
else b"<html><body><h2>Authorization failed.</h2></body></html>"
|
|
||||||
)
|
|
||||||
self.send_response(200 if ok else 400)
|
|
||||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
||||||
self.send_header("Content-Length", str(len(body)))
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(body)
|
|
||||||
|
|
||||||
def log_message(self, *args: object) -> None:
|
|
||||||
"""Silence the default stderr request logging."""
|
|
||||||
|
|
||||||
|
|
||||||
def _free_port() -> int:
|
|
||||||
with socket.socket() as sock:
|
|
||||||
sock.bind(("127.0.0.1", 0))
|
|
||||||
return int(sock.getsockname()[1])
|
|
||||||
|
|
||||||
|
|
||||||
def _start_callback_server(
|
|
||||||
port: int,
|
|
||||||
state: str,
|
|
||||||
) -> tuple[http.server.HTTPServer, threading.Thread]:
|
|
||||||
"""Listen on ``port`` until the OAuth callback arrives.
|
|
||||||
|
|
||||||
Serves in a loop rather than handling exactly one request: the first
|
|
||||||
request to reach this port is often a favicon fetch or a speculative
|
|
||||||
preconnect, and retiring the server on it leaves nothing listening when
|
|
||||||
Google finally redirects -- which surfaces as a consent timeout.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
port: The loopback port to bind.
|
|
||||||
state: The value the callback must echo back.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The server and the thread serving it, so the caller can join and close.
|
|
||||||
"""
|
|
||||||
_CallbackHandler.expected_state = state
|
|
||||||
_CallbackHandler.result = {}
|
|
||||||
server = http.server.HTTPServer(("127.0.0.1", port), _CallbackHandler)
|
|
||||||
# Bounded so handle_request() returns periodically instead of blocking
|
|
||||||
# forever; without it the loop could not notice the deadline and the
|
|
||||||
# daemon thread would sit on the socket after the caller gave up.
|
|
||||||
server.timeout = 1.0
|
|
||||||
deadline = time.monotonic() + _CONSENT_TIMEOUT_SECONDS
|
|
||||||
|
|
||||||
def _serve_until_callback() -> None:
|
|
||||||
while not _CallbackHandler.result and time.monotonic() < deadline:
|
|
||||||
server.handle_request()
|
|
||||||
|
|
||||||
thread = threading.Thread(target=_serve_until_callback, daemon=True)
|
|
||||||
thread.start()
|
|
||||||
return server, thread
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_id_token(
|
def fetch_id_token(
|
||||||
client_id: str,
|
client_id: str,
|
||||||
client_secret: str,
|
client_secret: str,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user