P1: garminconnect auth backend — live sync working again
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 <noreply@anthropic.com>
This commit is contained in:
@@ -21,10 +21,20 @@ architecture and conventions. Delete this file when the list is done.
|
|||||||
timestamps, no FITs). Copied to
|
timestamps, no FITs). Copied to
|
||||||
`data/backups/vault-root-takeout-ingest-2026-06-08.db`; the original at
|
`data/backups/vault-root-takeout-ingest-2026-06-08.db`; the original at
|
||||||
`../data/` is redundant once live sync runs — user should delete it.
|
`../data/` is redundant once live sync runs — user should delete it.
|
||||||
- **Sync is blocked on auth**: `.secrets/` is empty. User runs
|
- **SYNC DONE (2026-06-12)** via new garminconnect backend. garth's SSO login
|
||||||
`uv run openrun-auth` (password + MFA), then
|
is Cloudflare-429-blocked AND garth is deprecated (matin/garth#222), so
|
||||||
`uv run openrun-sync --days 35` (covers the wellness gap since May 17;
|
`src/openrun/ingest/garminconnect_backend.py` now authenticates with
|
||||||
activities + FITs are incremental automatically).
|
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
|
- `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.
|
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`
|
- Known bugs: 18 activities have epoch-ms floats in `start_time_local`
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ license = { text = "MIT" }
|
|||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fitparse>=1.2.0",
|
"fitparse>=1.2.0",
|
||||||
|
"garminconnect>=0.3.5",
|
||||||
"garth>=0.8.0",
|
"garth>=0.8.0",
|
||||||
"ipykernel>=7.2.0",
|
"ipykernel>=7.2.0",
|
||||||
"jinja2>=3.1.6",
|
"jinja2>=3.1.6",
|
||||||
|
|||||||
@@ -595,13 +595,27 @@ def main() -> None:
|
|||||||
help="Cap how many activities --fit-backfill pulls (newest first)")
|
help="Cap how many activities --fit-backfill pulls (newest first)")
|
||||||
parser.add_argument("--skip-activities", action="store_true",
|
parser.add_argument("--skip-activities", action="store_true",
|
||||||
help="Wellness only, no activity sync")
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
tok = _token_dir()
|
tok = _token_dir()
|
||||||
if not tok.exists() or not any(tok.iterdir()):
|
from . import garminconnect_backend
|
||||||
print(f"No tokens at {tok} — run `python -m openrun.ingest.auth` first.", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
backend = args.backend
|
||||||
garth.resume(tok)
|
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()
|
conn = connect()
|
||||||
try:
|
try:
|
||||||
|
|||||||
93
src/openrun/ingest/garminconnect_backend.py
Normal file
93
src/openrun/ingest/garminconnect_backend.py
Normal file
@@ -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()
|
||||||
58
uv.lock
generated
58
uv.lock
generated
@@ -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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "cycler"
|
name = "cycler"
|
||||||
version = "0.12.1"
|
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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "garth"
|
name = "garth"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -1003,6 +1050,7 @@ version = "0.1.0"
|
|||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "fitparse" },
|
{ name = "fitparse" },
|
||||||
|
{ name = "garminconnect" },
|
||||||
{ name = "garth" },
|
{ name = "garth" },
|
||||||
{ name = "ipykernel" },
|
{ name = "ipykernel" },
|
||||||
{ name = "jinja2" },
|
{ name = "jinja2" },
|
||||||
@@ -1020,6 +1068,7 @@ dev = [
|
|||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "fitparse", specifier = ">=1.2.0" },
|
{ name = "fitparse", specifier = ">=1.2.0" },
|
||||||
|
{ name = "garminconnect", specifier = ">=0.3.5" },
|
||||||
{ name = "garth", specifier = ">=0.8.0" },
|
{ name = "garth", specifier = ">=0.8.0" },
|
||||||
{ name = "ipykernel", specifier = ">=7.2.0" },
|
{ name = "ipykernel", specifier = ">=7.2.0" },
|
||||||
{ name = "jinja2", specifier = ">=3.1.6" },
|
{ 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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "urllib3"
|
name = "urllib3"
|
||||||
version = "2.7.0"
|
version = "2.7.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user