utils/crdt-sync/crdt_sync/_github.py
Krzysztof kuhy Rudnicki 3589d166cd
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
crdt-sync: split five source files — package now fully under the cap
_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
2026-08-22 15:30:52 +02:00

239 lines
8.5 KiB
Python

"""Minimal GitHub Contents API client used as dumb file storage.
GitHub is used purely as file storage via the REST Contents API, not a git
clone -- there is no working tree and no git-level merge; the only merge is
the domain-level one in :mod:`crdt_sync._log`. Extracted from diet_guard's
``_sync_github.py`` (itself ported in spirit from ``todo``'s sync
transport), generalized to be domain-agnostic.
"""
from __future__ import annotations
import base64
import requests as _requests
from crdt_sync._gherrors import GitHubSyncError, RepoNotFoundError
from crdt_sync._http import new_session
from crdt_sync._remote import RemoteNotFoundError, RemoteSyncError
# Bound to the name ``requests`` so the call sites below -- and the tests
# that patch ``<module>.requests.<verb>`` -- are unchanged by pooling.
requests = new_session()
_API_BASE = "https://api.github.com"
_HTTP_NOT_FOUND = 404
_DEFAULT_TIMEOUT_SECONDS = 15
class GitHubSyncClient:
"""Thin wrapper around the subset of the Contents API sync needs."""
def __init__(
self,
owner: str,
repo: str,
token: str,
*,
timeout_seconds: float = _DEFAULT_TIMEOUT_SECONDS,
) -> None:
"""Create a client scoped to one repo, authenticated with ``token``.
Args:
owner: The repo owner/org (e.g. ``"kuhyx"``).
repo: The repo name (e.g. ``"my-app-sync"``).
token: A GitHub PAT with contents read/write on that repo.
timeout_seconds: Per-request network timeout.
"""
self._owner = owner
self._repo = repo
self._timeout_seconds = timeout_seconds
self._headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
}
def _contents_url(self, path: str) -> str:
return f"{_API_BASE}/repos/{self._owner}/{self._repo}/contents/{path}"
def _get(self, path: str) -> _requests.Response:
try:
return requests.get(
self._contents_url(path),
headers=self._headers,
timeout=self._timeout_seconds,
)
except _requests.RequestException as exc:
msg = f"network error reading {path}"
raise GitHubSyncError(msg) from exc
def _repo_exists(self) -> bool:
try:
response = requests.get(
f"{_API_BASE}/repos/{self._owner}/{self._repo}",
headers=self._headers,
timeout=self._timeout_seconds,
)
except _requests.RequestException:
return False
return response.ok
def _raise_for_missing_path(self, path: str) -> None:
"""Raise :class:`RepoNotFoundError` only if the repo is unreachable.
A 404 on a path within a reachable repo just means nothing has been
pushed there yet, which is not an error worth raising on.
"""
if not self._repo_exists():
msg = (
f"{self._owner}/{self._repo} not found, private without "
f"access, or the token lacks contents permission "
f"(while reading {path})"
)
raise RepoNotFoundError(msg)
def get_file_text(self, path: str) -> str | None:
"""Return the decoded text content at ``path``, or None if unused.
Args:
path: A repo-relative file path, e.g. ``"devices/pc/log.json"``.
Returns:
The file's text content, or None if nothing has been pushed
there yet (but the repo itself is reachable).
Raises:
RepoNotFoundError: If the repo itself is unreachable.
GitHubSyncError: For any other non-2xx response or network error.
"""
response = self._get(path)
if response.status_code == _HTTP_NOT_FOUND:
self._raise_for_missing_path(path)
return None
if not response.ok:
msg = f"GET {path} failed: {response.status_code}"
raise GitHubSyncError(msg)
data = response.json()
content = data.get("content", "") if isinstance(data, dict) else ""
return base64.b64decode(content).decode("utf-8")
def _existing_sha(self, path: str) -> str | None:
response = self._get(path)
if response.status_code == _HTTP_NOT_FOUND:
self._raise_for_missing_path(path)
return None
if not response.ok:
msg = f"GET {path} (for sha) failed: {response.status_code}"
raise GitHubSyncError(msg)
data = response.json()
sha = data.get("sha") if isinstance(data, dict) else None
return sha if isinstance(sha, str) else None
def put_file_text(self, path: str, text: str, *, message: str) -> None:
"""Create or update the file at ``path`` with ``text``.
Args:
path: A repo-relative file path.
text: The full new content (this device's complete merged log).
message: The commit message for this push.
Raises:
GitHubSyncError: On any non-2xx response or network error.
"""
sha = self._existing_sha(path)
payload: dict[str, object] = {
"message": message,
"content": base64.b64encode(text.encode("utf-8")).decode("ascii"),
}
if sha is not None:
payload["sha"] = sha
try:
response = requests.put(
self._contents_url(path),
headers=self._headers,
json=payload,
timeout=self._timeout_seconds,
)
except _requests.RequestException as exc:
msg = f"network error pushing {path}"
raise GitHubSyncError(msg) from exc
if not response.ok:
msg = f"PUT {path} failed: {response.status_code}"
raise GitHubSyncError(msg)
def list_directory(self, path: str) -> list[str]:
"""Return the entry names directly under ``path`` (empty if unused).
Args:
path: A repo-relative directory path, e.g. ``"devices"``.
Raises:
RepoNotFoundError: If the repo itself is unreachable.
GitHubSyncError: For any other non-2xx response or network error.
"""
response = self._get(path)
if response.status_code == _HTTP_NOT_FOUND:
self._raise_for_missing_path(path)
return []
if not response.ok:
msg = f"GET {path} (list) failed: {response.status_code}"
raise GitHubSyncError(msg)
data = response.json()
if not isinstance(data, list):
return []
return [
item["name"]
for item in data
if isinstance(item, dict) and isinstance(item.get("name"), str)
]
def can_access_repo(self) -> bool:
"""Return whether the token can read this repo (a connection test).
Hits the bare repo endpoint, so it succeeds even before any file has
been pushed. Never raises -- a network failure or missing repo is
reported as ``False``.
The GitHub-era name for :meth:`can_access_remote`; kept because app
settings screens call it. Both do the same probe.
"""
return self._repo_exists()
def can_access_remote(self) -> bool:
"""Return whether the token can reach the remote (a connection test).
The :class:`crdt_sync.RemoteStore` spelling of
:meth:`can_access_repo`.
"""
return self._repo_exists()
def delete_file(self, path: str, *, message: str = "crdt_sync: delete") -> None:
"""Delete the file at ``path``, resolving its current sha itself.
A no-op if ``path`` does not exist, so a repeated cleanup is safe.
Args:
path: A repo-relative file path.
message: The commit message for the deletion.
Raises:
GitHubSyncError: On any non-2xx response or network error.
"""
sha = self._existing_sha(path)
if sha is None:
return
try:
response = requests.delete(
self._contents_url(path),
headers=self._headers,
json={"message": message, "sha": sha},
timeout=self._timeout_seconds,
)
except _requests.RequestException as exc:
msg = f"network error deleting {path}"
raise GitHubSyncError(msg) from exc
if not response.ok:
msg = f"DELETE {path} failed: {response.status_code}"
raise GitHubSyncError(msg)