From dd2b8ef1bd58fce5e0991796b100f56fe1628501 Mon Sep 17 00:00:00 2001 From: noisedestroyers Date: Fri, 12 Jun 2026 07:22:33 -0400 Subject: [PATCH] =?UTF-8?q?P1:=20garminconnect=20auth=20backend=20?= =?UTF-8?q?=E2=80=94=20live=20sync=20working=20again?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit garth's SSO login is Cloudflare-rate-limited (429) and garth is now deprecated upstream. New openrun.ingest.garminconnect_backend authenticates via python-garminconnect 0.3.5 DI Bearer tokens and shims garth's client surface (connectapi/download/username), so the existing sync pipeline runs unchanged. openrun-sync gains --backend {auto,garth,garminconnect}; auto prefers garminconnect when .secrets/garmin_tokens.json exists. Login: uv run python -m openrun.ingest.garminconnect_backend Synced: 7 activities (-> 2026-06-02) + splits + FITs, wellness through 2026-06-12, time-in-zone recomputed (364 activities). Co-Authored-By: Claude Fable 5 --- NEXT_SESSION.md | 18 +++- pyproject.toml | 1 + src/openrun/ingest/garmin_api.py | 22 ++++- src/openrun/ingest/garminconnect_backend.py | 93 +++++++++++++++++++++ uv.lock | 58 +++++++++++++ 5 files changed, 184 insertions(+), 8 deletions(-) create mode 100644 src/openrun/ingest/garminconnect_backend.py diff --git a/NEXT_SESSION.md b/NEXT_SESSION.md index c97113aa..3d8feda7 100644 --- a/NEXT_SESSION.md +++ b/NEXT_SESSION.md @@ -21,10 +21,20 @@ architecture and conventions. Delete this file when the list is done. timestamps, no FITs). Copied to `data/backups/vault-root-takeout-ingest-2026-06-08.db`; the original at `../data/` is redundant once live sync runs — user should delete it. -- **Sync is blocked on auth**: `.secrets/` is empty. User runs - `uv run openrun-auth` (password + MFA), then - `uv run openrun-sync --days 35` (covers the wellness gap since May 17; - activities + FITs are incremental automatically). +- **SYNC DONE (2026-06-12)** via new garminconnect backend. garth's SSO login + is Cloudflare-429-blocked AND garth is deprecated (matin/garth#222), so + `src/openrun/ingest/garminconnect_backend.py` now authenticates with + python-garminconnect 0.3.5 DI tokens (`.secrets/garmin_tokens.json`) and + shims garth's client surface; `openrun-sync --backend=auto` prefers it. + This is P3-lite, pulled forward. Login (once/year-ish): + `uv run python -m openrun.ingest.garminconnect_backend`. + Gotchas learned: usersummary endpoint needs UUID displayName, not userName + (403 otherwise); a parseable-but-dead tokenstore blocks the password + fallback in gc.login (main() parks it as .bak first). +- DB after sync: 385 activities (→ 2026-06-02), all ISO timestamps on new + rows, 356 FITs linked, 364 TIZ rows, wellness through 2026-06-12. + The 18 legacy epoch-ms rows remain (P2). Web Sync page still uses the + garth login flow — port it to the gc backend (or hide login) later. - `race_plan` table is EMPTY and `manual_activities` empty, but openrun.toml has races: 30K 2026-06-13, 50K 2026-07-25, 50 MILE 2026-09-12. - Known bugs: 18 activities have epoch-ms floats in `start_time_local` diff --git a/pyproject.toml b/pyproject.toml index 7eee7725..a557b10b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ license = { text = "MIT" } requires-python = ">=3.13" dependencies = [ "fitparse>=1.2.0", + "garminconnect>=0.3.5", "garth>=0.8.0", "ipykernel>=7.2.0", "jinja2>=3.1.6", diff --git a/src/openrun/ingest/garmin_api.py b/src/openrun/ingest/garmin_api.py index db130ecb..1baa065b 100644 --- a/src/openrun/ingest/garmin_api.py +++ b/src/openrun/ingest/garmin_api.py @@ -595,13 +595,27 @@ def main() -> None: help="Cap how many activities --fit-backfill pulls (newest first)") parser.add_argument("--skip-activities", action="store_true", help="Wellness only, no activity sync") + parser.add_argument("--backend", choices=("auto", "garth", "garminconnect"), + default="auto", + help="Auth backend. 'auto' picks garminconnect when " + ".secrets/garmin_tokens.json exists, else garth.") args = parser.parse_args() tok = _token_dir() - if not tok.exists() or not any(tok.iterdir()): - print(f"No tokens at {tok} — run `python -m openrun.ingest.auth` first.", file=sys.stderr) - sys.exit(1) - garth.resume(tok) + from . import garminconnect_backend + + backend = args.backend + if backend == "auto": + backend = "garminconnect" if garminconnect_backend.has_tokens(tok) else "garth" + + if backend == "garminconnect": + user = garminconnect_backend.activate(tok) + print(f"auth: garminconnect backend (user: {user})") + else: + if not tok.exists() or not any(tok.iterdir()): + print(f"No tokens at {tok} — run `python -m openrun.ingest.auth` first.", file=sys.stderr) + sys.exit(1) + garth.resume(tok) conn = connect() try: diff --git a/src/openrun/ingest/garminconnect_backend.py b/src/openrun/ingest/garminconnect_backend.py new file mode 100644 index 00000000..eddab802 --- /dev/null +++ b/src/openrun/ingest/garminconnect_backend.py @@ -0,0 +1,93 @@ +"""garminconnect-backed auth for openrun-sync. + +garth's password login is Cloudflare-rate-limited (429 on sso.garmin.com) and +garth itself is deprecated. python-garminconnect 0.3.x authenticates with +Garmin's newer DI Bearer tokens (`.secrets/garmin_tokens.json`) and refreshes +them against connectapi.garmin.com — no SSO involved after the first login. + +`activate()` logs in via garminconnect, then routes garth's API surface +(module-level `garth.connectapi`/`garth.download`, the `garth.http.client` +singleton used by `garth.stats`, and the `username` property) through the +garminconnect client, so all of `openrun.ingest.garmin_api` runs unchanged. + +This is the slim version of ROADMAP P3 (`openrun-sync --backend=garminconnect`). +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + + +def has_tokens(token_dir: Path) -> bool: + return (token_dir / "garmin_tokens.json").exists() + + +def activate(token_dir: Path) -> str: + """Authenticate from `token_dir` and patch garth. Returns the username.""" + import garth + from garth.http import Client as GarthClient + from garth.http import client as garth_singleton + from garminconnect import Garmin + + gc = Garmin() + gc.login(tokenstore=str(token_dir)) # loads DI tokens; auto-refreshes + re-saves + + def connectapi(path: str, **kwargs: Any) -> Any: + return gc.connectapi(path, **kwargs) + + def download(path: str, **kwargs: Any) -> bytes: + return gc.download(path, **kwargs) + + # Call-time lookups on the singleton (garth.stats.* resolve `http.client`). + garth_singleton.connectapi = connectapi # type: ignore[method-assign] + garth_singleton.download = download # type: ignore[method-assign] + # Module-level names were bound to the original methods at import time. + garth.connectapi = connectapi # type: ignore[assignment] + garth.download = download # type: ignore[assignment] + + # `Client.username` is a read-only property; replace it on the class. + # sync_resting_hr interpolates it into /usersummary-service/.../{username}, + # which requires the UUID displayName — userName ("o@...") gets a 403. + profile = gc.connectapi("userprofile-service/socialProfile") or {} + username = gc.display_name or profile.get("displayName") or profile.get("userName") or "" + GarthClient.username = username # type: ignore[assignment] + return profile.get("userName") or username + + +def main() -> None: + """One-time interactive login; saves DI tokens to ./.secrets/garmin_tokens.json. + + Uses garminconnect's cascading login strategies (mobile+cffi first, which + impersonates a browser TLS fingerprint) — gets past the Cloudflare 429 + that blocks garth's plain SSO login. + + uv run python -m openrun.ingest.garminconnect_backend + """ + import getpass + import os + + from garminconnect import Garmin + + td = Path.cwd() / ".secrets" + td.mkdir(exist_ok=True) + + # A parseable-but-dead tokenstore short-circuits the password fallback in + # garminconnect's login(); park any existing file so we get a fresh login. + stale = td / "garmin_tokens.json" + if stale.exists(): + stale.rename(td / "garmin_tokens.json.bak") + print(f"(moved old tokens to {stale.name}.bak)") + + email = os.environ.get("GARMIN_EMAIL") or input("Garmin email: ").strip() + password = os.environ.get("GARMIN_PASSWORD") or getpass.getpass("Garmin password: ") + + gc = Garmin(email=email, password=password, + prompt_mfa=lambda: input("MFA code: ").strip()) + gc.login(tokenstore=str(td)) # no tokens on disk now → password login, then dump + print(f"Logged in as {gc.display_name or email}. Tokens saved to {td}.") + print("Now run: uv run openrun-sync --days 35") + + +if __name__ == "__main__": + main() diff --git a/uv.lock b/uv.lock index cdb7e46f..4aa4e38a 100644 --- a/uv.lock +++ b/uv.lock @@ -356,6 +356,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764 }, ] +[[package]] +name = "curl-cffi" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "cffi" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/5b/89fcfebd3e5e85134147ac99e9f2b2271165fd4d71984fc65da5f17819b7/curl_cffi-0.15.0.tar.gz", hash = "sha256:ea0c67652bf6893d34ee0f82c944f37e488f6147e9421bef1771cc6545b02ded", size = 196437 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/42/54ddd442c795f30ce5dd4e49f87ce77505958d3777cd96a91567a3975d2a/curl_cffi-0.15.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:bda66404010e9ed743b1b83c20c86f24fe21a9a6873e17479d6e67e29d8ded28", size = 2795267 }, + { url = "https://files.pythonhosted.org/packages/83/2d/3915e238579b3c5a92cead5c79130c3b8d20caaba7616cc4d894650e1d6b/curl_cffi-0.15.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a25620d9bf989c9c029a7d1642999c4c265abb0bad811deb2f77b0b5b2b12e5b", size = 2573544 }, + { url = "https://files.pythonhosted.org/packages/2a/b3/9d2f1057749a1b07ba1989db3c1503ce8bed998310bae9aea2c43aa64f20/curl_cffi-0.15.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:582e570aa2586b96ed47cf4a17586b9a3c462cbe43f780487c3dc245c6ef1527", size = 10515369 }, + { url = "https://files.pythonhosted.org/packages/b5/1d/6d10dded5ce3fd8157e558ebd97d09e551b77a62cdc1c31e93d0a633cee5/curl_cffi-0.15.0-cp310-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:838e48212447d9c81364b04707a5c861daf08f8320f9ecb3406a8919d1d5c3b3", size = 10160045 }, + { url = "https://files.pythonhosted.org/packages/5c/12/c70b835487ace3b9ba1502631912e3440082b8ae3a162f60b59cb0b6444d/curl_cffi-0.15.0-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b6c847d86283b07ae69bb72c82eb8a59242277142aa35b89850f89e792a02fc", size = 11090433 }, + { url = "https://files.pythonhosted.org/packages/ea/0d/78edcc4f71934225db99df68197a107386d59080742fc7bf6bb4d007924f/curl_cffi-0.15.0-cp310-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e5e69eee735f659287e2c84444319d68a1fa68dd37abf228943a4074864283a", size = 10479178 }, + { url = "https://files.pythonhosted.org/packages/5b/84/1e101c1acb1ea2f0b4992f5c3024f596d8e21db0d53540b9d583f673c4e7/curl_cffi-0.15.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa1323950224db24f4c510d010b3affa02196ca853fb424191fa917a513d3f4b", size = 10317051 }, + { url = "https://files.pythonhosted.org/packages/28/42/8ef236b22a6c23d096c85a1dc507efe37bfdfc7a2f8a4b34efb590197369/curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:41f80170ba844009273b2660da1964ec31e99e5719d16b3422ada87177e32e13", size = 11299660 }, + { url = "https://files.pythonhosted.org/packages/1d/01/56aeb055d962da87a1be0d74c6c644e251c7e88129b5471dc44ac724e678/curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1977e1e12cfb5c11352cbb74acef1bed24eb7d226dab61ca57c168c21acd4d61", size = 11945049 }, + { url = "https://files.pythonhosted.org/packages/d8/8c/2abf99a38d6340d66cf0557e0c750ef3f8883dfc5d450087e01c85861343/curl_cffi-0.15.0-cp310-abi3-win_amd64.whl", hash = "sha256:5a0c1896a0d5a5ac1eb89cd24b008d2b718dd1df6fd2f75451b59ca66e49e572", size = 1661649 }, + { url = "https://files.pythonhosted.org/packages/3d/39/dfd54f2240d3a9b96d77bacc62b97813b35e2aa8ecf5cd5013c683f1ba96/curl_cffi-0.15.0-cp310-abi3-win_arm64.whl", hash = "sha256:a6d57f8389273a3a1f94370473c74897467bcc36af0a17336989780c507fa43d", size = 1410741 }, + { url = "https://files.pythonhosted.org/packages/19/6a/c24df8a4fc22fa84070dcd94abeba43c15e08cc09e35869565c0bad196fd/curl_cffi-0.15.0-cp313-abi3-android_24_arm64_v8a.whl", hash = "sha256:4682dc38d4336e0eb0b185374db90a760efde63cbea994b4e63f3521d44c4c92", size = 7190427 }, + { url = "https://files.pythonhosted.org/packages/11/56/132225cb3491d07cc6adcce5fe395e059bde87c68cff1ef87a31c88c7819/curl_cffi-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:967ad7355bd8e9586f8c2d02eaa99953747549e7ea4a9b25cd53353e6b67fe6d", size = 2795723 }, + { url = "https://files.pythonhosted.org/packages/07/8f/f4f83cd303bef7e8f1749512e5dd157e7e5d08b0a36c8211f9640a2757bf/curl_cffi-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7e63539d0d839d0a8c5eacf86229bc68c57803547f35e0db7ee0986328b478c3", size = 2573739 }, + { url = "https://files.pythonhosted.org/packages/e8/5c/643d65c7fc9acd742876aa55c2d7823c438cb7665810acd2e66c9976c4d9/curl_cffi-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08c799b89740b9bc49c09fbc3d5907f13ac1f845ca52620507ef9466d4639dd5", size = 10521046 }, + { url = "https://files.pythonhosted.org/packages/7f/0b/9b8037113c93f4c5323096163471fa7c35c7676c3f608eeaf1287cd99d58/curl_cffi-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b7a92767a888ee90147e18964b396d8435ff42737030d6fb00824ffd6094805", size = 11096115 }, + { url = "https://files.pythonhosted.org/packages/5f/96/fff2fcbd924ef4042e0d67379f751a8a4e3186a91e75e35a4cf218b306ee/curl_cffi-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:829cc357061ecb99cc2d406301f609a039e05665322f5c025ec67c38b0dc49ce", size = 11305346 }, + { url = "https://files.pythonhosted.org/packages/53/1b/304b253a45ab28691c8c5e8cca1e6cbb9cf8e46dfceae4648dd536f75e73/curl_cffi-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:408d6f14e346841cd889c2e0962832bb235ba3b6749ebf609f347f747da5e60f", size = 11949834 }, + { url = "https://files.pythonhosted.org/packages/5a/ff/4723d92f08259c707a974aba27a08d0a822b9555e35ca581bf18d055a364/curl_cffi-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b624c7ce087bfda967a013ed0a64702a525444e5b6e97d23534d567ccc6525aa", size = 1702771 }, + { url = "https://files.pythonhosted.org/packages/59/8c/36bbe06d66fa2b765e4a07199f643a59a9cd1a754207a96335402a9520f4/curl_cffi-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0b6c0543b993996670e9e4b78e305a2d60809d5681903ffb5568e21a387434d3", size = 1466312 }, +] + [[package]] name = "cycler" version = "0.12.1" @@ -439,6 +472,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/ba/56147c165442cc5ba7e82ecf301c9a68353cede498185869e6e02b4c264f/fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd", size = 1152647 }, ] +[[package]] +name = "garminconnect" +version = "0.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "curl-cffi" }, + { name = "requests" }, + { name = "ua-generator" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/d8/df118bbbf9a4634bf335cba7baf66f0618babbbf6982c7c43560f9d96d32/garminconnect-0.3.5.tar.gz", hash = "sha256:b4de13fa5e6c581348cbd8110afb9095dc68273d6cfb1284ed13cae7df0f6b02", size = 61692 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/bb/c35ed445156311b9408aa2a10b6c9199392ea1fa26144de947d544c5601a/garminconnect-0.3.5-py3-none-any.whl", hash = "sha256:7a34c408ab4b94a69f67baeda96e8c8aef674447389bbb39beea0e5e49c2a29d", size = 57331 }, +] + [[package]] name = "garth" version = "0.8.0" @@ -1003,6 +1050,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "fitparse" }, + { name = "garminconnect" }, { name = "garth" }, { name = "ipykernel" }, { name = "jinja2" }, @@ -1020,6 +1068,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "fitparse", specifier = ">=1.2.0" }, + { name = "garminconnect", specifier = ">=0.3.5" }, { name = "garth", specifier = ">=0.8.0" }, { name = "ipykernel", specifier = ">=7.2.0" }, { name = "jinja2", specifier = ">=3.1.6" }, @@ -1899,6 +1948,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321 }, ] +[[package]] +name = "ua-generator" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/4a/46aa28f8eb83969ef8e1e8ae521147de966916de85ae6729d2d97480be15/ua_generator-2.1.1.tar.gz", hash = "sha256:c01984c1055da4900c8a1f4c419f229fa454caf42d8642267aec1904110d1c8d", size = 29642 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/8c/a9f0161f2a34752987199425fec7f0068b012a02c98551615a9b82cbd70a/ua_generator-2.1.1-py3-none-any.whl", hash = "sha256:d8fd6e39e44dc057dd232d5f808fe0f61dab4a3163903cfe24446777ecdd2ec4", size = 32769 }, +] + [[package]] name = "urllib3" version = "2.7.0"