gcpapi-sync 0.1.20261022__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gcpapi_sync-0.1.20261022/LICENSE +21 -0
- gcpapi_sync-0.1.20261022/PKG-INFO +341 -0
- gcpapi_sync-0.1.20261022/README.md +304 -0
- gcpapi_sync-0.1.20261022/pyproject.toml +219 -0
- gcpapi_sync-0.1.20261022/pyproject.toml.orig +186 -0
- gcpapi_sync-0.1.20261022/src/gcpapi_sync/__init__.py +62 -0
- gcpapi_sync-0.1.20261022/src/gcpapi_sync/__main__.py +7 -0
- gcpapi_sync-0.1.20261022/src/gcpapi_sync/_async_client.py +537 -0
- gcpapi_sync-0.1.20261022/src/gcpapi_sync/_auth.py +350 -0
- gcpapi_sync-0.1.20261022/src/gcpapi_sync/_client.py +516 -0
- gcpapi_sync-0.1.20261022/src/gcpapi_sync/_compat.py +10 -0
- gcpapi_sync-0.1.20261022/src/gcpapi_sync/_constants.py +41 -0
- gcpapi_sync-0.1.20261022/src/gcpapi_sync/_core.py +234 -0
- gcpapi_sync-0.1.20261022/src/gcpapi_sync/_dates.py +58 -0
- gcpapi_sync-0.1.20261022/src/gcpapi_sync/_endpoints.py +608 -0
- gcpapi_sync-0.1.20261022/src/gcpapi_sync/_files.py +80 -0
- gcpapi_sync-0.1.20261022/src/gcpapi_sync/_retry.py +74 -0
- gcpapi_sync-0.1.20261022/src/gcpapi_sync/_version.py +8 -0
- gcpapi_sync-0.1.20261022/src/gcpapi_sync/cli.py +470 -0
- gcpapi_sync-0.1.20261022/src/gcpapi_sync/exceptions.py +203 -0
- gcpapi_sync-0.1.20261022/src/gcpapi_sync/models/__init__.py +246 -0
- gcpapi_sync-0.1.20261022/src/gcpapi_sync/models/_base.py +128 -0
- gcpapi_sync-0.1.20261022/src/gcpapi_sync/models/_generated.py +4628 -0
- gcpapi_sync-0.1.20261022/src/gcpapi_sync/models/_series.py +232 -0
- gcpapi_sync-0.1.20261022/src/gcpapi_sync/py.typed +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Thomas Camminady
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gcpapi-sync
|
|
3
|
+
Version: 0.1.20261022
|
|
4
|
+
Summary: Typed sync and async Python client for the Garmin Connect Personal API.
|
|
5
|
+
Keywords: garmin,garmin-connect,garmin-api,personal-api,api-client,fitness,health-data,fit-files,sleep,hrv,running,cycling,pydantic,httpx,asyncio,cli
|
|
6
|
+
Author: Thomas Camminady
|
|
7
|
+
Author-email: Thomas Camminady <thomas@camminady.dev>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Framework :: AsyncIO
|
|
12
|
+
Classifier: Framework :: Pydantic :: 2
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Dist: anyio>=4.4
|
|
25
|
+
Requires-Dist: fire>=0.7
|
|
26
|
+
Requires-Dist: httpx>=0.27,<1
|
|
27
|
+
Requires-Dist: pydantic>=2.11,<3
|
|
28
|
+
Requires-Dist: typing-extensions>=4.12 ; python_full_version < '3.12'
|
|
29
|
+
Requires-Python: >=3.11
|
|
30
|
+
Project-URL: Homepage, https://github.com/thomascamminady/gcpapi-sync
|
|
31
|
+
Project-URL: Documentation, https://camminady.dev/gcpapi-sync/
|
|
32
|
+
Project-URL: Repository, https://github.com/thomascamminady/gcpapi-sync
|
|
33
|
+
Project-URL: Issues, https://github.com/thomascamminady/gcpapi-sync/issues
|
|
34
|
+
Project-URL: Changelog, https://github.com/thomascamminady/gcpapi-sync/blob/main/CHANGELOG.md
|
|
35
|
+
Project-URL: Garmin API docs, https://developer.garmin.com/gc-developer-program/personal/swagger-docs/
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
# gcpapi-sync
|
|
39
|
+
|
|
40
|
+
[](https://pypi.org/project/gcpapi-sync/)
|
|
41
|
+
[](https://pypi.org/project/gcpapi-sync/)
|
|
42
|
+
[](https://github.com/thomascamminady/gcpapi-sync/actions/workflows/ci.yml)
|
|
43
|
+
[](https://github.com/thomascamminady/gcpapi-sync/actions/workflows/drift.yml)
|
|
44
|
+
[](https://camminady.dev/gcpapi-sync/)
|
|
45
|
+

|
|
46
|
+
|
|
47
|
+
A typed Python client and command line tool for the
|
|
48
|
+
[Garmin Connect Personal API](https://developer.garmin.com/gc-developer-program/personal/personal-faq/),
|
|
49
|
+
the official API that gives you programmatic access to **your own** Garmin data:
|
|
50
|
+
activities and their FIT files, sleep, heart rate, HRV, stress, Body Battery, SpO2,
|
|
51
|
+
steps, weight, nutrition and more.
|
|
52
|
+
|
|
53
|
+
> **Disclaimer.** This is a personal, private project. It is not affiliated with,
|
|
54
|
+
> endorsed by, or developed on behalf of my employer, Wahoo Fitness, and it is not
|
|
55
|
+
> affiliated with or endorsed by Garmin. "Garmin" and "Garmin Connect" are trademarks
|
|
56
|
+
> of Garmin Ltd. or its subsidiaries. Use of the API is subject to
|
|
57
|
+
> [Garmin's terms](https://www.garmin.com/en-US/legal/terms-of-use/).
|
|
58
|
+
|
|
59
|
+
## Highlights
|
|
60
|
+
|
|
61
|
+
- **Every endpoint**, as typed methods on a sync `GarminClient` and an async
|
|
62
|
+
`AsyncGarminClient` with identical signatures.
|
|
63
|
+
- **109 pydantic models**, generated from Garmin's OpenAPI document and *corrected
|
|
64
|
+
where the live API contradicts it* (see [API quirks](https://github.com/thomascamminady/gcpapi-sync#api-quirks)). No `dict`, no
|
|
65
|
+
`Any`: maps with known keys are models, intraday series have typed accessors.
|
|
66
|
+
- **Strictly typed** (`py.typed`), checked with `ty` (every rule as an error) and
|
|
67
|
+
`mypy --strict`, with no suppression comments anywhere in the code base.
|
|
68
|
+
- **Authentication handled for you**: API key discovery, the 24-hour token exchange,
|
|
69
|
+
caching (in memory or on disk, shared with Garmin's own client), renewal before
|
|
70
|
+
expiry and after a rejected token.
|
|
71
|
+
- **Resilient**: retries with exponential backoff and jitter, `Retry-After` support,
|
|
72
|
+
and a precise exception hierarchy.
|
|
73
|
+
- **No server limits to remember**: date ranges of any length are split to fit each
|
|
74
|
+
endpoint (concurrently with the async client); searches of any size are paged.
|
|
75
|
+
- **CLI** with everyday commands (`today`, `activities`, incremental FIT `sync`,
|
|
76
|
+
`export`) plus raw JSON for every endpoint.
|
|
77
|
+
- **Kept in sync with Garmin**: a weekly job regenerates the models from Garmin's
|
|
78
|
+
published spec and opens a pull request when anything changes.
|
|
79
|
+
|
|
80
|
+
## Installation
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
uv add gcpapi-sync # in a uv project
|
|
84
|
+
pip install gcpapi-sync # with pip
|
|
85
|
+
uv tool install gcpapi-sync # just the gcpapi command
|
|
86
|
+
uvx --from gcpapi-sync gcpapi today # run the CLI without installing
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Requires Python 3.11 or newer. API documentation:
|
|
90
|
+
[camminady.dev/gcpapi-sync](https://camminady.dev/gcpapi-sync/).
|
|
91
|
+
|
|
92
|
+
## Getting an API key
|
|
93
|
+
|
|
94
|
+
1. In [Garmin Connect](https://connect.garmin.com) open **Settings → Personal API**
|
|
95
|
+
and create a key. Garmin shows it only once; keys expire after 30 days to a year.
|
|
96
|
+
2. Store it:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
gcpapi setup # prompts for the key (hidden), verifies it, saves it with mode 600
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The client looks for the key in this order: the `api_key` argument, the `key_file`
|
|
103
|
+
argument, the `GARMIN_PERSONAL_API_KEY` environment variable, and
|
|
104
|
+
`~/.config/garmin/connect-personal-api-key` (where `gcpapi setup` and Garmin's own
|
|
105
|
+
client put it). Never commit the key; this repository's pre-commit hooks refuse to.
|
|
106
|
+
|
|
107
|
+
## Quick start
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
from gcpapi_sync import GarminClient
|
|
111
|
+
|
|
112
|
+
with GarminClient() as garmin:
|
|
113
|
+
for run in garmin.search_activities(limit=5, activity_type="running"):
|
|
114
|
+
km = (run.distance or 0) / 1000
|
|
115
|
+
print(f"{run.start_time_local:%Y-%m-%d} {run.activity_name} {km:.1f} km")
|
|
116
|
+
|
|
117
|
+
sleep = garmin.get_daily_sleep("2026-10-21")
|
|
118
|
+
if sleep.sleep_scores and sleep.sleep_scores.overall:
|
|
119
|
+
print("sleep score", sleep.sleep_scores.overall.value)
|
|
120
|
+
|
|
121
|
+
steps = garmin.get_daily_steps("2026-01-01", "2026-10-21") # 14 requests, one list
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The async client has the same methods as coroutines and fetches the windows of long
|
|
125
|
+
date ranges concurrently:
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
import asyncio
|
|
129
|
+
from gcpapi_sync import AsyncGarminClient
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def main() -> None:
|
|
133
|
+
async with AsyncGarminClient(max_concurrency=4) as garmin:
|
|
134
|
+
sleep, hrv, steps = await asyncio.gather(
|
|
135
|
+
garmin.get_daily_sleep_summaries("2026-01-01", "2026-10-21"),
|
|
136
|
+
garmin.get_daily_hrv_summaries("2026-01-01", "2026-10-21"),
|
|
137
|
+
garmin.get_daily_steps("2026-01-01", "2026-10-21"),
|
|
138
|
+
)
|
|
139
|
+
async for activity in garmin.iter_activities(start_date="2026-10-01"):
|
|
140
|
+
print(activity.activity_id, activity.activity_name)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
asyncio.run(main())
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Usage
|
|
147
|
+
|
|
148
|
+
### Endpoints
|
|
149
|
+
|
|
150
|
+
Dates are `datetime.date` objects or `YYYY-MM-DD` strings; ranges are inclusive.
|
|
151
|
+
|
|
152
|
+
| Method | Returns | Garmin's limit per request |
|
|
153
|
+
| --- | --- | --- |
|
|
154
|
+
| `search_activities(start=0, limit=20, **filters)` | `list[SummarizedActivity]` | ~45 per page (paged for you) |
|
|
155
|
+
| `iter_activities(page_size=20, **filters)` | iterator of `SummarizedActivity` | |
|
|
156
|
+
| `get_activity(activity_id)` | `ActivitySummary` | |
|
|
157
|
+
| `get_activity_workouts(activity_id)` | `list[ActivityWorkout]` | |
|
|
158
|
+
| `download_activity(activity_id)` | `ActivityFile` (original FIT/GPX/TCX) | |
|
|
159
|
+
| `download_activity_zip(activity_id)` | `bytes` (Garmin's zip) | |
|
|
160
|
+
| `get_daily_summary(day)` | `UserDailySummary` | |
|
|
161
|
+
| `get_daily_summaries(start, end)` | `list[UserDailySummaryV2]` | 31 days (split for you) |
|
|
162
|
+
| `get_daily_sleep(day)` | `DailySleep` | |
|
|
163
|
+
| `get_daily_sleep_summaries(start, end)` | `list[DailySleep]` | 31 days (split for you) |
|
|
164
|
+
| `get_daily_heart_rate(day)` | `DailyHeartRate` | |
|
|
165
|
+
| `get_heart_rate_zones()` | `list[HeartRateZones]` | |
|
|
166
|
+
| `get_daily_hrv(day)` | `HrvChart \| None` | |
|
|
167
|
+
| `get_daily_hrv_summaries(start, end)` | `list[HrvSummary]` | 365 days (split for you) |
|
|
168
|
+
| `get_daily_stress(start, end)` | `list[MultiValueWellnessChartItem]` | 28 days (split for you) |
|
|
169
|
+
| `get_daily_steps(start, end)` | `list[StepsDailyStat]` | 28 days (split for you) |
|
|
170
|
+
| `get_daily_hydration(start, end)` | `list[UserDailyHydration]` | 31 days (split for you) |
|
|
171
|
+
| `get_daily_body_battery(start, end, chunks=None)` | `list[BodyBatteryDailyReport]` | 31 days (split for you) |
|
|
172
|
+
| `get_daily_spo2(day)` | `DailySpO2AndAcclimation` | |
|
|
173
|
+
| `get_fitness_age(day)` | `FitnessAge` | |
|
|
174
|
+
| `get_weight_range(start, end, include_all=False)` | `WeightRange` (weights in grams) | about a year |
|
|
175
|
+
| `get_weight_entries(start, end)` | `list[DateWeight]` | currently broken server-side |
|
|
176
|
+
| `get_effective_weight_goals(start, end)` | `UserGoalsRange` | |
|
|
177
|
+
| `get_daily_food_logs(day)` | `DailyNutritionSummary` | |
|
|
178
|
+
| `get_menstrual_cycle_summary(day)` | `MenstrualCycleSummary \| None` | |
|
|
179
|
+
| `get_menstrual_cycle_day_view(day, today=None)` | `DetailedMenstrualCycleDay \| None` | |
|
|
180
|
+
| `get_wellness_activity_summaries(start, end)` | `list[WellnessActivitySummary]` | |
|
|
181
|
+
| `get_user_profile_settings()` | `UserSettings` | |
|
|
182
|
+
| `get_json(path, params)` | decoded JSON, for endpoints not wrapped yet | |
|
|
183
|
+
|
|
184
|
+
### Searching activities
|
|
185
|
+
|
|
186
|
+
`search_activities` and `iter_activities` accept every filter the API supports as
|
|
187
|
+
typed keyword arguments (see `ActivityFilters`): dates, activity and event types,
|
|
188
|
+
text search, favorites, distance/duration/elevation/power ranges, a geographic
|
|
189
|
+
`BoundingBox`, dive parameters, sorting and more.
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
from gcpapi_sync import BoundingBox
|
|
193
|
+
|
|
194
|
+
long_runs = garmin.search_activities(
|
|
195
|
+
limit=100,
|
|
196
|
+
activity_type="running",
|
|
197
|
+
min_distance=21_000, # meters
|
|
198
|
+
start_date="2026-01-01",
|
|
199
|
+
bounding_box=BoundingBox(north=50.8, east=7.2, south=50.6, west=7.0),
|
|
200
|
+
sort_by="distance",
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
for activity in garmin.iter_activities(activity_type="cycling"):
|
|
204
|
+
garmin.download_activity(activity.activity_id).save("~/garmin/fit")
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### Models
|
|
208
|
+
|
|
209
|
+
Every response is a pydantic model (`gcpapi_sync.models`):
|
|
210
|
+
|
|
211
|
+
- attributes are `snake_case`; Garmin's `camelCase` names are the aliases
|
|
212
|
+
(`model_dump(by_alias=True)` reproduces Garmin's JSON);
|
|
213
|
+
- every field is optional, because Garmin omits and nulls fields freely;
|
|
214
|
+
- unknown fields are kept in `model_extra`, so API additions never break parsing;
|
|
215
|
+
- timestamps are `datetime` objects: timezone-aware UTC for Garmin's `*GMT` fields
|
|
216
|
+
and naive wall-clock time for `*Local` fields, whether Garmin sent ISO strings or
|
|
217
|
+
epoch milliseconds; calendar days are `date`, times of day are `time`.
|
|
218
|
+
|
|
219
|
+
Intraday series come as compact rows; typed accessors resolve their columns:
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
heart_rate = garmin.get_daily_heart_rate("2026-10-21")
|
|
223
|
+
for sample in heart_rate.heart_rate_samples(): # list[HeartRateSample]
|
|
224
|
+
print(sample.timestamp, sample.heart_rate)
|
|
225
|
+
|
|
226
|
+
report = garmin.get_daily_body_battery("2026-10-21", "2026-10-21")[0]
|
|
227
|
+
levels = report.body_battery_samples() # list[BodyBatterySample]
|
|
228
|
+
spo2 = garmin.get_daily_spo2("2026-10-21").spo2_hourly_samples()
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### Errors
|
|
232
|
+
|
|
233
|
+
```text
|
|
234
|
+
GarminError
|
|
235
|
+
├── ConfigurationError no API key found, invalid options
|
|
236
|
+
└── APIError
|
|
237
|
+
├── APIConnectionError network failure (after retries)
|
|
238
|
+
│ └── APITimeoutError
|
|
239
|
+
├── APIStatusError non-2xx response (after retries); .status_code, .body, .error_id
|
|
240
|
+
│ ├── BadRequestError 400
|
|
241
|
+
│ ├── AuthenticationError 401: key or token invalid, expired or revoked
|
|
242
|
+
│ ├── PermissionDeniedError 403
|
|
243
|
+
│ ├── NotFoundError 404
|
|
244
|
+
│ ├── RateLimitError 429; .retry_after
|
|
245
|
+
│ └── InternalServerError 5xx
|
|
246
|
+
└── ResponseValidationError a 2xx response that does not match the models; .raw
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Invalid arguments (malformed dates, reversed ranges, unknown filters) raise
|
|
250
|
+
`ValueError`/`TypeError` before any request is made.
|
|
251
|
+
|
|
252
|
+
### Configuration
|
|
253
|
+
|
|
254
|
+
```python
|
|
255
|
+
import httpx
|
|
256
|
+
from gcpapi_sync import FileTokenCache, GarminClient, RetryConfig
|
|
257
|
+
|
|
258
|
+
garmin = GarminClient(
|
|
259
|
+
key_file="~/secrets/garmin.key",
|
|
260
|
+
token_cache=FileTokenCache(), # reuse tokens across processes
|
|
261
|
+
timeout=60,
|
|
262
|
+
retry=RetryConfig(max_retries=5, max_delay=30),
|
|
263
|
+
http_client=httpx.Client(proxy="http://proxy:3128"), # you close it
|
|
264
|
+
)
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Implement the `TokenCache` protocol to keep tokens elsewhere (a keyring, Redis, ...).
|
|
268
|
+
Logs go to the `gcpapi_sync` logger (requests at `DEBUG`, retries at `INFO`); keys and
|
|
269
|
+
tokens are never logged or shown in `repr`.
|
|
270
|
+
|
|
271
|
+
## Command line
|
|
272
|
+
|
|
273
|
+
```bash
|
|
274
|
+
gcpapi setup # store and verify your API key
|
|
275
|
+
gcpapi status # key source, token validity
|
|
276
|
+
gcpapi today # steps, HR, stress, Body Battery, sleep, HRV, activities
|
|
277
|
+
gcpapi today 2026-10-21 --as_json
|
|
278
|
+
gcpapi activities --limit 20 --activity_type running
|
|
279
|
+
gcpapi sync ~/garmin/fit --since 2026-01-01 # download new FIT files only
|
|
280
|
+
gcpapi export 2026-01-01 2026-10-21 ~/garmin/health
|
|
281
|
+
gcpapi get_daily_sleep 2026-10-21 # any client method, as JSON
|
|
282
|
+
gcpapi search_activities --limit 5 --min_distance 10000
|
|
283
|
+
gcpapi logout # delete the cached token
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Options go after the command (`gcpapi status --no_cache`). `gcpapi -- --help` and
|
|
287
|
+
`gcpapi <command> -- --help` describe everything; `python -m gcpapi_sync` works too.
|
|
288
|
+
|
|
289
|
+
## API quirks
|
|
290
|
+
|
|
291
|
+
The client hides a number of places where Garmin's service disagrees with its own
|
|
292
|
+
documentation (verified against the live API in October 2026):
|
|
293
|
+
|
|
294
|
+
- Timestamps are documented as strings but several arrive as epoch milliseconds
|
|
295
|
+
(sleep, weight); several documented objects arrive as plain strings (`ConnectDate`,
|
|
296
|
+
`ConnectTimestamp`, `LocalTime`); the fitness-age endpoint returns an object, not
|
|
297
|
+
the documented array; intraday series are arrays of numbers, not objects.
|
|
298
|
+
- Fields documented as non-nullable are frequently `null`, and some undocumented
|
|
299
|
+
fields are returned; both are modeled.
|
|
300
|
+
- Range limits differ from the documentation: daily summaries fail with HTTP 500 for
|
|
301
|
+
ranges over roughly 90 days although a year is documented; activity searches fail
|
|
302
|
+
with HTTP 500 for pages over about 45 activities, *including the server's own
|
|
303
|
+
default page size*.
|
|
304
|
+
- `GET /weight-service/weight/all/...` answers HTTP 400 for every input.
|
|
305
|
+
- Activity downloads require `Accept: application/x-zip-compressed`.
|
|
306
|
+
|
|
307
|
+
## Versioning
|
|
308
|
+
|
|
309
|
+
Versions are `0.MINOR.YYYYMMDD`. `MINOR` grows when this package changes (features,
|
|
310
|
+
fixes); the date records when the current state of Garmin's API was picked up and
|
|
311
|
+
changes only when Garmin changes something. So `0.1.20261022` is followed by
|
|
312
|
+
`0.1.20261029` if Garmin changes its API the next week, and by `0.2.20261022` if this
|
|
313
|
+
package gets a new feature first. Releases are automatic: every new version merged to
|
|
314
|
+
`main` is published to PyPI.
|
|
315
|
+
|
|
316
|
+
## Development
|
|
317
|
+
|
|
318
|
+
```bash
|
|
319
|
+
git clone git@github.com:thomascamminady/gcpapi-sync.git && cd gcpapi-sync
|
|
320
|
+
uv sync # environment with all dev tools
|
|
321
|
+
uv run pre-commit install # ruff, ty, mypy, secret scanning on commit; tests on push
|
|
322
|
+
uv run pytest # unit tests (no network)
|
|
323
|
+
uv run pytest -m live # against the real API with your key
|
|
324
|
+
uv run pre-commit run --all-files
|
|
325
|
+
uv run --group docs pdoc gcpapi_sync gcpapi_sync.exceptions gcpapi_sync.cli --docformat google
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
Models are generated; never edit `src/gcpapi_sync/models/_generated.py` by hand.
|
|
329
|
+
Corrections for the live API live in `scripts/generate_models.py`:
|
|
330
|
+
|
|
331
|
+
```bash
|
|
332
|
+
uv run scripts/generate_models.py # fetch Garmin's current spec and regenerate
|
|
333
|
+
uv run scripts/scrub_fixture.py response.json tests/fixtures/name.json # anonymize a fixture
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
See [CONTRIBUTING.md](https://github.com/thomascamminady/gcpapi-sync/blob/main/CONTRIBUTING.md)
|
|
337
|
+
for the full workflow and how API drift is detected.
|
|
338
|
+
|
|
339
|
+
## License
|
|
340
|
+
|
|
341
|
+
[MIT](https://github.com/thomascamminady/gcpapi-sync/blob/main/LICENSE)
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
# gcpapi-sync
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/gcpapi-sync/)
|
|
4
|
+
[](https://pypi.org/project/gcpapi-sync/)
|
|
5
|
+
[](https://github.com/thomascamminady/gcpapi-sync/actions/workflows/ci.yml)
|
|
6
|
+
[](https://github.com/thomascamminady/gcpapi-sync/actions/workflows/drift.yml)
|
|
7
|
+
[](https://camminady.dev/gcpapi-sync/)
|
|
8
|
+

|
|
9
|
+
|
|
10
|
+
A typed Python client and command line tool for the
|
|
11
|
+
[Garmin Connect Personal API](https://developer.garmin.com/gc-developer-program/personal/personal-faq/),
|
|
12
|
+
the official API that gives you programmatic access to **your own** Garmin data:
|
|
13
|
+
activities and their FIT files, sleep, heart rate, HRV, stress, Body Battery, SpO2,
|
|
14
|
+
steps, weight, nutrition and more.
|
|
15
|
+
|
|
16
|
+
> **Disclaimer.** This is a personal, private project. It is not affiliated with,
|
|
17
|
+
> endorsed by, or developed on behalf of my employer, Wahoo Fitness, and it is not
|
|
18
|
+
> affiliated with or endorsed by Garmin. "Garmin" and "Garmin Connect" are trademarks
|
|
19
|
+
> of Garmin Ltd. or its subsidiaries. Use of the API is subject to
|
|
20
|
+
> [Garmin's terms](https://www.garmin.com/en-US/legal/terms-of-use/).
|
|
21
|
+
|
|
22
|
+
## Highlights
|
|
23
|
+
|
|
24
|
+
- **Every endpoint**, as typed methods on a sync `GarminClient` and an async
|
|
25
|
+
`AsyncGarminClient` with identical signatures.
|
|
26
|
+
- **109 pydantic models**, generated from Garmin's OpenAPI document and *corrected
|
|
27
|
+
where the live API contradicts it* (see [API quirks](https://github.com/thomascamminady/gcpapi-sync#api-quirks)). No `dict`, no
|
|
28
|
+
`Any`: maps with known keys are models, intraday series have typed accessors.
|
|
29
|
+
- **Strictly typed** (`py.typed`), checked with `ty` (every rule as an error) and
|
|
30
|
+
`mypy --strict`, with no suppression comments anywhere in the code base.
|
|
31
|
+
- **Authentication handled for you**: API key discovery, the 24-hour token exchange,
|
|
32
|
+
caching (in memory or on disk, shared with Garmin's own client), renewal before
|
|
33
|
+
expiry and after a rejected token.
|
|
34
|
+
- **Resilient**: retries with exponential backoff and jitter, `Retry-After` support,
|
|
35
|
+
and a precise exception hierarchy.
|
|
36
|
+
- **No server limits to remember**: date ranges of any length are split to fit each
|
|
37
|
+
endpoint (concurrently with the async client); searches of any size are paged.
|
|
38
|
+
- **CLI** with everyday commands (`today`, `activities`, incremental FIT `sync`,
|
|
39
|
+
`export`) plus raw JSON for every endpoint.
|
|
40
|
+
- **Kept in sync with Garmin**: a weekly job regenerates the models from Garmin's
|
|
41
|
+
published spec and opens a pull request when anything changes.
|
|
42
|
+
|
|
43
|
+
## Installation
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
uv add gcpapi-sync # in a uv project
|
|
47
|
+
pip install gcpapi-sync # with pip
|
|
48
|
+
uv tool install gcpapi-sync # just the gcpapi command
|
|
49
|
+
uvx --from gcpapi-sync gcpapi today # run the CLI without installing
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Requires Python 3.11 or newer. API documentation:
|
|
53
|
+
[camminady.dev/gcpapi-sync](https://camminady.dev/gcpapi-sync/).
|
|
54
|
+
|
|
55
|
+
## Getting an API key
|
|
56
|
+
|
|
57
|
+
1. In [Garmin Connect](https://connect.garmin.com) open **Settings → Personal API**
|
|
58
|
+
and create a key. Garmin shows it only once; keys expire after 30 days to a year.
|
|
59
|
+
2. Store it:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
gcpapi setup # prompts for the key (hidden), verifies it, saves it with mode 600
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The client looks for the key in this order: the `api_key` argument, the `key_file`
|
|
66
|
+
argument, the `GARMIN_PERSONAL_API_KEY` environment variable, and
|
|
67
|
+
`~/.config/garmin/connect-personal-api-key` (where `gcpapi setup` and Garmin's own
|
|
68
|
+
client put it). Never commit the key; this repository's pre-commit hooks refuse to.
|
|
69
|
+
|
|
70
|
+
## Quick start
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from gcpapi_sync import GarminClient
|
|
74
|
+
|
|
75
|
+
with GarminClient() as garmin:
|
|
76
|
+
for run in garmin.search_activities(limit=5, activity_type="running"):
|
|
77
|
+
km = (run.distance or 0) / 1000
|
|
78
|
+
print(f"{run.start_time_local:%Y-%m-%d} {run.activity_name} {km:.1f} km")
|
|
79
|
+
|
|
80
|
+
sleep = garmin.get_daily_sleep("2026-10-21")
|
|
81
|
+
if sleep.sleep_scores and sleep.sleep_scores.overall:
|
|
82
|
+
print("sleep score", sleep.sleep_scores.overall.value)
|
|
83
|
+
|
|
84
|
+
steps = garmin.get_daily_steps("2026-01-01", "2026-10-21") # 14 requests, one list
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The async client has the same methods as coroutines and fetches the windows of long
|
|
88
|
+
date ranges concurrently:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
import asyncio
|
|
92
|
+
from gcpapi_sync import AsyncGarminClient
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def main() -> None:
|
|
96
|
+
async with AsyncGarminClient(max_concurrency=4) as garmin:
|
|
97
|
+
sleep, hrv, steps = await asyncio.gather(
|
|
98
|
+
garmin.get_daily_sleep_summaries("2026-01-01", "2026-10-21"),
|
|
99
|
+
garmin.get_daily_hrv_summaries("2026-01-01", "2026-10-21"),
|
|
100
|
+
garmin.get_daily_steps("2026-01-01", "2026-10-21"),
|
|
101
|
+
)
|
|
102
|
+
async for activity in garmin.iter_activities(start_date="2026-10-01"):
|
|
103
|
+
print(activity.activity_id, activity.activity_name)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
asyncio.run(main())
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Usage
|
|
110
|
+
|
|
111
|
+
### Endpoints
|
|
112
|
+
|
|
113
|
+
Dates are `datetime.date` objects or `YYYY-MM-DD` strings; ranges are inclusive.
|
|
114
|
+
|
|
115
|
+
| Method | Returns | Garmin's limit per request |
|
|
116
|
+
| --- | --- | --- |
|
|
117
|
+
| `search_activities(start=0, limit=20, **filters)` | `list[SummarizedActivity]` | ~45 per page (paged for you) |
|
|
118
|
+
| `iter_activities(page_size=20, **filters)` | iterator of `SummarizedActivity` | |
|
|
119
|
+
| `get_activity(activity_id)` | `ActivitySummary` | |
|
|
120
|
+
| `get_activity_workouts(activity_id)` | `list[ActivityWorkout]` | |
|
|
121
|
+
| `download_activity(activity_id)` | `ActivityFile` (original FIT/GPX/TCX) | |
|
|
122
|
+
| `download_activity_zip(activity_id)` | `bytes` (Garmin's zip) | |
|
|
123
|
+
| `get_daily_summary(day)` | `UserDailySummary` | |
|
|
124
|
+
| `get_daily_summaries(start, end)` | `list[UserDailySummaryV2]` | 31 days (split for you) |
|
|
125
|
+
| `get_daily_sleep(day)` | `DailySleep` | |
|
|
126
|
+
| `get_daily_sleep_summaries(start, end)` | `list[DailySleep]` | 31 days (split for you) |
|
|
127
|
+
| `get_daily_heart_rate(day)` | `DailyHeartRate` | |
|
|
128
|
+
| `get_heart_rate_zones()` | `list[HeartRateZones]` | |
|
|
129
|
+
| `get_daily_hrv(day)` | `HrvChart \| None` | |
|
|
130
|
+
| `get_daily_hrv_summaries(start, end)` | `list[HrvSummary]` | 365 days (split for you) |
|
|
131
|
+
| `get_daily_stress(start, end)` | `list[MultiValueWellnessChartItem]` | 28 days (split for you) |
|
|
132
|
+
| `get_daily_steps(start, end)` | `list[StepsDailyStat]` | 28 days (split for you) |
|
|
133
|
+
| `get_daily_hydration(start, end)` | `list[UserDailyHydration]` | 31 days (split for you) |
|
|
134
|
+
| `get_daily_body_battery(start, end, chunks=None)` | `list[BodyBatteryDailyReport]` | 31 days (split for you) |
|
|
135
|
+
| `get_daily_spo2(day)` | `DailySpO2AndAcclimation` | |
|
|
136
|
+
| `get_fitness_age(day)` | `FitnessAge` | |
|
|
137
|
+
| `get_weight_range(start, end, include_all=False)` | `WeightRange` (weights in grams) | about a year |
|
|
138
|
+
| `get_weight_entries(start, end)` | `list[DateWeight]` | currently broken server-side |
|
|
139
|
+
| `get_effective_weight_goals(start, end)` | `UserGoalsRange` | |
|
|
140
|
+
| `get_daily_food_logs(day)` | `DailyNutritionSummary` | |
|
|
141
|
+
| `get_menstrual_cycle_summary(day)` | `MenstrualCycleSummary \| None` | |
|
|
142
|
+
| `get_menstrual_cycle_day_view(day, today=None)` | `DetailedMenstrualCycleDay \| None` | |
|
|
143
|
+
| `get_wellness_activity_summaries(start, end)` | `list[WellnessActivitySummary]` | |
|
|
144
|
+
| `get_user_profile_settings()` | `UserSettings` | |
|
|
145
|
+
| `get_json(path, params)` | decoded JSON, for endpoints not wrapped yet | |
|
|
146
|
+
|
|
147
|
+
### Searching activities
|
|
148
|
+
|
|
149
|
+
`search_activities` and `iter_activities` accept every filter the API supports as
|
|
150
|
+
typed keyword arguments (see `ActivityFilters`): dates, activity and event types,
|
|
151
|
+
text search, favorites, distance/duration/elevation/power ranges, a geographic
|
|
152
|
+
`BoundingBox`, dive parameters, sorting and more.
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
from gcpapi_sync import BoundingBox
|
|
156
|
+
|
|
157
|
+
long_runs = garmin.search_activities(
|
|
158
|
+
limit=100,
|
|
159
|
+
activity_type="running",
|
|
160
|
+
min_distance=21_000, # meters
|
|
161
|
+
start_date="2026-01-01",
|
|
162
|
+
bounding_box=BoundingBox(north=50.8, east=7.2, south=50.6, west=7.0),
|
|
163
|
+
sort_by="distance",
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
for activity in garmin.iter_activities(activity_type="cycling"):
|
|
167
|
+
garmin.download_activity(activity.activity_id).save("~/garmin/fit")
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### Models
|
|
171
|
+
|
|
172
|
+
Every response is a pydantic model (`gcpapi_sync.models`):
|
|
173
|
+
|
|
174
|
+
- attributes are `snake_case`; Garmin's `camelCase` names are the aliases
|
|
175
|
+
(`model_dump(by_alias=True)` reproduces Garmin's JSON);
|
|
176
|
+
- every field is optional, because Garmin omits and nulls fields freely;
|
|
177
|
+
- unknown fields are kept in `model_extra`, so API additions never break parsing;
|
|
178
|
+
- timestamps are `datetime` objects: timezone-aware UTC for Garmin's `*GMT` fields
|
|
179
|
+
and naive wall-clock time for `*Local` fields, whether Garmin sent ISO strings or
|
|
180
|
+
epoch milliseconds; calendar days are `date`, times of day are `time`.
|
|
181
|
+
|
|
182
|
+
Intraday series come as compact rows; typed accessors resolve their columns:
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
heart_rate = garmin.get_daily_heart_rate("2026-10-21")
|
|
186
|
+
for sample in heart_rate.heart_rate_samples(): # list[HeartRateSample]
|
|
187
|
+
print(sample.timestamp, sample.heart_rate)
|
|
188
|
+
|
|
189
|
+
report = garmin.get_daily_body_battery("2026-10-21", "2026-10-21")[0]
|
|
190
|
+
levels = report.body_battery_samples() # list[BodyBatterySample]
|
|
191
|
+
spo2 = garmin.get_daily_spo2("2026-10-21").spo2_hourly_samples()
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Errors
|
|
195
|
+
|
|
196
|
+
```text
|
|
197
|
+
GarminError
|
|
198
|
+
├── ConfigurationError no API key found, invalid options
|
|
199
|
+
└── APIError
|
|
200
|
+
├── APIConnectionError network failure (after retries)
|
|
201
|
+
│ └── APITimeoutError
|
|
202
|
+
├── APIStatusError non-2xx response (after retries); .status_code, .body, .error_id
|
|
203
|
+
│ ├── BadRequestError 400
|
|
204
|
+
│ ├── AuthenticationError 401: key or token invalid, expired or revoked
|
|
205
|
+
│ ├── PermissionDeniedError 403
|
|
206
|
+
│ ├── NotFoundError 404
|
|
207
|
+
│ ├── RateLimitError 429; .retry_after
|
|
208
|
+
│ └── InternalServerError 5xx
|
|
209
|
+
└── ResponseValidationError a 2xx response that does not match the models; .raw
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Invalid arguments (malformed dates, reversed ranges, unknown filters) raise
|
|
213
|
+
`ValueError`/`TypeError` before any request is made.
|
|
214
|
+
|
|
215
|
+
### Configuration
|
|
216
|
+
|
|
217
|
+
```python
|
|
218
|
+
import httpx
|
|
219
|
+
from gcpapi_sync import FileTokenCache, GarminClient, RetryConfig
|
|
220
|
+
|
|
221
|
+
garmin = GarminClient(
|
|
222
|
+
key_file="~/secrets/garmin.key",
|
|
223
|
+
token_cache=FileTokenCache(), # reuse tokens across processes
|
|
224
|
+
timeout=60,
|
|
225
|
+
retry=RetryConfig(max_retries=5, max_delay=30),
|
|
226
|
+
http_client=httpx.Client(proxy="http://proxy:3128"), # you close it
|
|
227
|
+
)
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Implement the `TokenCache` protocol to keep tokens elsewhere (a keyring, Redis, ...).
|
|
231
|
+
Logs go to the `gcpapi_sync` logger (requests at `DEBUG`, retries at `INFO`); keys and
|
|
232
|
+
tokens are never logged or shown in `repr`.
|
|
233
|
+
|
|
234
|
+
## Command line
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
gcpapi setup # store and verify your API key
|
|
238
|
+
gcpapi status # key source, token validity
|
|
239
|
+
gcpapi today # steps, HR, stress, Body Battery, sleep, HRV, activities
|
|
240
|
+
gcpapi today 2026-10-21 --as_json
|
|
241
|
+
gcpapi activities --limit 20 --activity_type running
|
|
242
|
+
gcpapi sync ~/garmin/fit --since 2026-01-01 # download new FIT files only
|
|
243
|
+
gcpapi export 2026-01-01 2026-10-21 ~/garmin/health
|
|
244
|
+
gcpapi get_daily_sleep 2026-10-21 # any client method, as JSON
|
|
245
|
+
gcpapi search_activities --limit 5 --min_distance 10000
|
|
246
|
+
gcpapi logout # delete the cached token
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Options go after the command (`gcpapi status --no_cache`). `gcpapi -- --help` and
|
|
250
|
+
`gcpapi <command> -- --help` describe everything; `python -m gcpapi_sync` works too.
|
|
251
|
+
|
|
252
|
+
## API quirks
|
|
253
|
+
|
|
254
|
+
The client hides a number of places where Garmin's service disagrees with its own
|
|
255
|
+
documentation (verified against the live API in October 2026):
|
|
256
|
+
|
|
257
|
+
- Timestamps are documented as strings but several arrive as epoch milliseconds
|
|
258
|
+
(sleep, weight); several documented objects arrive as plain strings (`ConnectDate`,
|
|
259
|
+
`ConnectTimestamp`, `LocalTime`); the fitness-age endpoint returns an object, not
|
|
260
|
+
the documented array; intraday series are arrays of numbers, not objects.
|
|
261
|
+
- Fields documented as non-nullable are frequently `null`, and some undocumented
|
|
262
|
+
fields are returned; both are modeled.
|
|
263
|
+
- Range limits differ from the documentation: daily summaries fail with HTTP 500 for
|
|
264
|
+
ranges over roughly 90 days although a year is documented; activity searches fail
|
|
265
|
+
with HTTP 500 for pages over about 45 activities, *including the server's own
|
|
266
|
+
default page size*.
|
|
267
|
+
- `GET /weight-service/weight/all/...` answers HTTP 400 for every input.
|
|
268
|
+
- Activity downloads require `Accept: application/x-zip-compressed`.
|
|
269
|
+
|
|
270
|
+
## Versioning
|
|
271
|
+
|
|
272
|
+
Versions are `0.MINOR.YYYYMMDD`. `MINOR` grows when this package changes (features,
|
|
273
|
+
fixes); the date records when the current state of Garmin's API was picked up and
|
|
274
|
+
changes only when Garmin changes something. So `0.1.20261022` is followed by
|
|
275
|
+
`0.1.20261029` if Garmin changes its API the next week, and by `0.2.20261022` if this
|
|
276
|
+
package gets a new feature first. Releases are automatic: every new version merged to
|
|
277
|
+
`main` is published to PyPI.
|
|
278
|
+
|
|
279
|
+
## Development
|
|
280
|
+
|
|
281
|
+
```bash
|
|
282
|
+
git clone git@github.com:thomascamminady/gcpapi-sync.git && cd gcpapi-sync
|
|
283
|
+
uv sync # environment with all dev tools
|
|
284
|
+
uv run pre-commit install # ruff, ty, mypy, secret scanning on commit; tests on push
|
|
285
|
+
uv run pytest # unit tests (no network)
|
|
286
|
+
uv run pytest -m live # against the real API with your key
|
|
287
|
+
uv run pre-commit run --all-files
|
|
288
|
+
uv run --group docs pdoc gcpapi_sync gcpapi_sync.exceptions gcpapi_sync.cli --docformat google
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Models are generated; never edit `src/gcpapi_sync/models/_generated.py` by hand.
|
|
292
|
+
Corrections for the live API live in `scripts/generate_models.py`:
|
|
293
|
+
|
|
294
|
+
```bash
|
|
295
|
+
uv run scripts/generate_models.py # fetch Garmin's current spec and regenerate
|
|
296
|
+
uv run scripts/scrub_fixture.py response.json tests/fixtures/name.json # anonymize a fixture
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
See [CONTRIBUTING.md](https://github.com/thomascamminady/gcpapi-sync/blob/main/CONTRIBUTING.md)
|
|
300
|
+
for the full workflow and how API drift is detected.
|
|
301
|
+
|
|
302
|
+
## License
|
|
303
|
+
|
|
304
|
+
[MIT](https://github.com/thomascamminady/gcpapi-sync/blob/main/LICENSE)
|