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.
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/gcpapi-sync)](https://pypi.org/project/gcpapi-sync/)
41
+ [![Python](https://img.shields.io/pypi/pyversions/gcpapi-sync)](https://pypi.org/project/gcpapi-sync/)
42
+ [![CI](https://github.com/thomascamminady/gcpapi-sync/actions/workflows/ci.yml/badge.svg)](https://github.com/thomascamminady/gcpapi-sync/actions/workflows/ci.yml)
43
+ [![API drift](https://github.com/thomascamminady/gcpapi-sync/actions/workflows/drift.yml/badge.svg)](https://github.com/thomascamminady/gcpapi-sync/actions/workflows/drift.yml)
44
+ [![Docs](https://img.shields.io/badge/docs-pdoc-blue)](https://camminady.dev/gcpapi-sync/)
45
+ ![Typed](https://img.shields.io/badge/typing-strict-brightgreen)
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
+ [![PyPI](https://img.shields.io/pypi/v/gcpapi-sync)](https://pypi.org/project/gcpapi-sync/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/gcpapi-sync)](https://pypi.org/project/gcpapi-sync/)
5
+ [![CI](https://github.com/thomascamminady/gcpapi-sync/actions/workflows/ci.yml/badge.svg)](https://github.com/thomascamminady/gcpapi-sync/actions/workflows/ci.yml)
6
+ [![API drift](https://github.com/thomascamminady/gcpapi-sync/actions/workflows/drift.yml/badge.svg)](https://github.com/thomascamminady/gcpapi-sync/actions/workflows/drift.yml)
7
+ [![Docs](https://img.shields.io/badge/docs-pdoc-blue)](https://camminady.dev/gcpapi-sync/)
8
+ ![Typed](https://img.shields.io/badge/typing-strict-brightgreen)
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)