garmin-connect-personal-api-python 1.0.0__py3-none-any.whl

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,288 @@
1
+ """Endpoint wrappers for the Garmin Personal API."""
2
+
3
+ # Copyright 2026 Garmin Ltd. or its subsidiaries.
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import os
21
+ from collections.abc import Mapping
22
+ from datetime import date
23
+ from typing import Any
24
+ from urllib.parse import urlencode
25
+
26
+ import requests
27
+
28
+ from .constants import BASE_URL_ENV_VAR, DEFAULT_BASE_URL, DEFAULT_TIMEOUT_SECONDS
29
+ from .errors import GarminPersonalApiError, HttpError
30
+
31
+ JsonValue = dict[str, Any] | list[Any] | str | int | float | bool | None
32
+ QueryParams = Mapping[str, str | int | float | bool]
33
+ _LOGGER = logging.getLogger(__name__)
34
+
35
+
36
+ class GarminPersonalApiClient:
37
+ """Read Garmin data using an already-issued bearer token."""
38
+
39
+ def __init__(
40
+ self,
41
+ bearer_token: str,
42
+ *,
43
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
44
+ session: requests.Session | None = None,
45
+ ) -> None:
46
+ if timeout <= 0:
47
+ raise GarminPersonalApiError("timeout must be greater than zero")
48
+ self.base_url = os.environ.get(BASE_URL_ENV_VAR, DEFAULT_BASE_URL).rstrip("/")
49
+ self.timeout = timeout
50
+ self._session = session or requests.Session()
51
+ self.update_bearer_token(bearer_token)
52
+
53
+ def close(self) -> None:
54
+ """Close the underlying HTTP session."""
55
+ self._session.close()
56
+
57
+ def __enter__(self) -> GarminPersonalApiClient:
58
+ return self
59
+
60
+ def __exit__(self, *exc_info: object) -> None:
61
+ self.close()
62
+
63
+ def __repr__(self) -> str:
64
+ return f"{type(self).__name__}(base_url={self.base_url!r}, timeout={self.timeout!r})"
65
+
66
+ def update_bearer_token(self, bearer_token: str) -> None:
67
+ """Replace the token used for subsequent requests."""
68
+ self._headers = {
69
+ "Accept": "application/json",
70
+ "Authorization": f"Bearer {bearer_token}",
71
+ }
72
+
73
+ def get_daily_stress(self, start_date: str | date, end_date: str | date) -> JsonValue:
74
+ """Return daily stress statistics for an inclusive calendar date range."""
75
+ return self.get(
76
+ f"/usersummary-service/stats/stress/daily/{_iso(start_date)}/{_iso(end_date)}"
77
+ )
78
+
79
+ def get_daily_steps(self, start_date: str | date, end_date: str | date) -> JsonValue:
80
+ """Return daily step statistics for an inclusive calendar date range."""
81
+ return self.get(
82
+ f"/usersummary-service/stats/steps/daily/{_iso(start_date)}/{_iso(end_date)}"
83
+ )
84
+
85
+ def get_daily_hydration(self, start_date: str | date, end_date: str | date) -> JsonValue:
86
+ """Return daily hydration statistics for an inclusive calendar date range."""
87
+ return self.get(
88
+ f"/usersummary-service/stats/hydration/daily/{_iso(start_date)}/{_iso(end_date)}"
89
+ )
90
+
91
+ def get_effective_weight_goals(
92
+ self,
93
+ start_date: str | date,
94
+ end_date: str | date,
95
+ ) -> JsonValue:
96
+ """Return effective weight goals for an inclusive calendar date range."""
97
+ return self.get(
98
+ f"/goal-service/goal/user/effective/weightgoal/{_iso(start_date)}/{_iso(end_date)}"
99
+ )
100
+
101
+ def get_weight_measurements(self, start_date: str | date, end_date: str | date) -> JsonValue:
102
+ """Return all weight data for an inclusive calendar date range."""
103
+ return self.get(
104
+ f"/weight-service/weight/range/{_iso(start_date)}/{_iso(end_date)}",
105
+ params={"includeAll": "true"},
106
+ )
107
+
108
+ def get_daily_food_logs(self, calendar_date: str | date) -> JsonValue:
109
+ """Return nutrition food logs for a calendar date."""
110
+ return self.get(f"/nutrition-service/food/logs/{_iso(calendar_date)}")
111
+
112
+ def get_user_profile_settings(self) -> JsonValue:
113
+ """Return the current user's profile settings."""
114
+ return self.get("/userprofile-service/userprofile/settings")
115
+
116
+ def get_daily_sleep(self, calendar_date: str | date) -> JsonValue:
117
+ """Return daily sleep data for a calendar date."""
118
+ return self.get(
119
+ "/sleep-service/sleep/dailySleep",
120
+ params={"date": _iso(calendar_date)},
121
+ )
122
+
123
+ def get_daily_sleep_summaries(
124
+ self,
125
+ start_date: str | date,
126
+ end_date: str | date,
127
+ ) -> JsonValue:
128
+ """Return daily sleep summaries for an inclusive calendar date range."""
129
+ return self.get(
130
+ "/sleep-service/sleep/dailySleepSummariesByDate",
131
+ params={"startDate": _iso(start_date), "endDate": _iso(end_date)},
132
+ )
133
+
134
+ def get_daily_body_battery(
135
+ self,
136
+ start_date: str | date,
137
+ end_date: str | date,
138
+ *,
139
+ chunks: int | None = None,
140
+ ) -> JsonValue:
141
+ """Return daily Body Battery data for an inclusive calendar date range."""
142
+ params: dict[str, str | int] = {"startDate": _iso(start_date), "endDate": _iso(end_date)}
143
+ if chunks is not None:
144
+ params["chunks"] = chunks
145
+ return self.get("/wellness-service/wellness/bodyBattery/reports/daily", params=params)
146
+
147
+ def get_daily_summaries(
148
+ self,
149
+ start_date: str | date,
150
+ end_date: str | date,
151
+ ) -> JsonValue:
152
+ """Return user daily summaries for an inclusive calendar date range.
153
+
154
+ Use the same start and end date for a single day. The response preserves
155
+ the endpoint's object containing a data array, even for one day.
156
+ """
157
+ return self.get(
158
+ "/usersummary-service/v2/dailysummaries",
159
+ params={"startDate": _iso(start_date), "endDate": _iso(end_date)},
160
+ )
161
+
162
+ def get_daily_hrv(self, calendar_date: str | date) -> JsonValue:
163
+ """Return heart rate variability (HRV) summary and readings for a calendar date."""
164
+ return self.get(f"/hrv-service/hrv/{_iso(calendar_date)}")
165
+
166
+ def get_daily_hrv_summaries(self, start_date: str | date, end_date: str | date) -> JsonValue:
167
+ """Return daily heart rate variability (HRV) summaries for an inclusive date range."""
168
+ return self.get(f"/hrv-service/hrv/daily/{_iso(start_date)}/{_iso(end_date)}")
169
+
170
+ def get_heart_rate_zones(self) -> JsonValue:
171
+ """Return the current user's heart rate zones."""
172
+ return self.get("/biometric-service/heartRateZones/v2")
173
+
174
+ def get_fitness_age(self, calendar_date: str | date) -> JsonValue:
175
+ """Return fitness age data for a calendar date."""
176
+ return self.get(f"/fitnessage-service/fitnessage/v2/{_iso(calendar_date)}")
177
+
178
+ def get_daily_heart_rate(self, calendar_date: str | date) -> JsonValue:
179
+ """Return daily heart rate data for a calendar date."""
180
+ return self.get(
181
+ "/wellness-service/wellness/dailyHeartRate",
182
+ params={"date": _iso(calendar_date)},
183
+ )
184
+
185
+ def get_menstrual_cycle_summary(self, calendar_date: str | date) -> JsonValue:
186
+ """Return menstrual-cycle summary data for a calendar date."""
187
+ return self.get(f"/periodichealth-service/menstrualcycle/summary/{_iso(calendar_date)}")
188
+
189
+ def get_menstrual_cycle_day_view(
190
+ self,
191
+ calendar_date: str | date,
192
+ today_date: str | date,
193
+ ) -> JsonValue:
194
+ """Return the menstrual-cycle day view for a calendar date."""
195
+ return self.get(
196
+ f"/periodichealth-service/menstrualcycle/dayview/{_iso(calendar_date)}",
197
+ params={"todayDate": _iso(today_date)},
198
+ )
199
+
200
+ def get_daily_spo2(self, calendar_date: str | date) -> JsonValue:
201
+ """Return daily SpO2 and acclimation data for a calendar date."""
202
+ return self.get(f"/wellness-service/wellness/daily/spo2acclimation/{_iso(calendar_date)}")
203
+
204
+ def get_wellness_activity_summaries(
205
+ self,
206
+ start_date: str | date,
207
+ end_date: str | date,
208
+ ) -> JsonValue:
209
+ """Return wellness activity summaries for an inclusive calendar date range."""
210
+ return self.get(
211
+ f"/wellnessactivity-service/activity/summary/range/{_iso(start_date)}/{_iso(end_date)}"
212
+ )
213
+
214
+ def search_activities(
215
+ self,
216
+ *,
217
+ start: int = 0,
218
+ limit: int = 20,
219
+ start_date: str | date | None = None,
220
+ end_date: str | date | None = None,
221
+ activity_type: str | None = None,
222
+ ) -> JsonValue:
223
+ """Return summarized activities matching the supplied search filters."""
224
+ params: dict[str, str | int] = {"start": start, "limit": limit}
225
+ if start_date is not None:
226
+ params["startDate"] = _iso(start_date)
227
+ if end_date is not None:
228
+ params["endDate"] = _iso(end_date)
229
+ if activity_type is not None:
230
+ params["activityType"] = activity_type
231
+ return self.get("/activitylist-service/activities/search/activities", params=params)
232
+
233
+ def get_activity(self, activity_id: int | str) -> JsonValue:
234
+ """Return the activity summary/details for an activity ID."""
235
+ return self.get(f"/activity-service/activity/{activity_id}")
236
+
237
+ def get_activity_workouts(self, activity_id: int | str) -> JsonValue:
238
+ """Return workouts associated with an activity ID."""
239
+ return self.get(f"/activity-service/activity/{activity_id}/workouts")
240
+
241
+ def download_activity(self, activity_id: int | str) -> bytes:
242
+ """Return the original activity file as ZIP bytes, without extracting it."""
243
+ activity_id = str(activity_id)
244
+ if not activity_id.isascii() or not activity_id.isdecimal() or int(activity_id) <= 0:
245
+ raise GarminPersonalApiError("activity_id must be a positive integer")
246
+ response = self._get_response(
247
+ f"/download-service/files/activity/{activity_id}",
248
+ accept="application/zip, application/x-zip-compressed, application/octet-stream",
249
+ )
250
+ if not response.content:
251
+ raise GarminPersonalApiError("The original activity file response was empty")
252
+ return response.content
253
+
254
+ def get(self, path: str, *, params: QueryParams | None = None) -> JsonValue:
255
+ """GET a Personal API path and return decoded JSON, or ``None`` if empty."""
256
+ response = self._get_response(path, params=params)
257
+ if not response.content:
258
+ return None
259
+ return response.json()
260
+
261
+ def _get_response(
262
+ self,
263
+ path: str,
264
+ *,
265
+ params: QueryParams | None = None,
266
+ accept: str = "application/json",
267
+ ) -> requests.Response:
268
+ url = f"{self.base_url}{path}"
269
+ _LOGGER.info("GET %s", f"{path}?{urlencode(params)}" if params else path)
270
+ response = self._session.get(
271
+ url,
272
+ headers={**self._headers, "Accept": accept},
273
+ params=params,
274
+ timeout=self.timeout,
275
+ )
276
+ if not response.ok:
277
+ raise HttpError(
278
+ status_code=response.status_code,
279
+ method="GET",
280
+ url=url,
281
+ response_text=response.text,
282
+ )
283
+ return response
284
+
285
+
286
+ def _iso(value: str | date) -> str:
287
+ """Format a date as YYYY-MM-DD; strings are passed through to the API unchanged."""
288
+ return value.isoformat() if isinstance(value, date) else value
@@ -0,0 +1,119 @@
1
+ """The command list shared by the CLI and interactive menu."""
2
+
3
+ # Copyright 2026 Garmin Ltd. or its subsidiaries.
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections.abc import Callable
20
+ from dataclasses import dataclass
21
+ from typing import Literal
22
+
23
+ from .client import GarminPersonalApiClient as Client
24
+ from .client import JsonValue
25
+
26
+ InputKind = Literal["date", "date_range", "date_with_today", "none", "activities", "activity"]
27
+ # Shared by the CLI subcommand and the interactive menu action; not a data command.
28
+ TOKEN_REFRESH_COMMAND = "refresh-token"
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class CommandSpec:
33
+ """Describe how the CLI and interactive menu invoke one client method."""
34
+
35
+ command: str
36
+ label: str
37
+ method: Callable[..., JsonValue | bytes]
38
+ input_kind: InputKind
39
+ output_kind: Literal["json", "download"] = "json"
40
+ description: str | None = None
41
+
42
+
43
+ COMMANDS = (
44
+ CommandSpec("activities", "Activities", Client.search_activities, "activities"),
45
+ CommandSpec("stress", "Daily Stress", Client.get_daily_stress, "date_range"),
46
+ CommandSpec("steps", "Daily Steps", Client.get_daily_steps, "date_range"),
47
+ CommandSpec("hydration", "Daily Hydration", Client.get_daily_hydration, "date_range"),
48
+ CommandSpec(
49
+ "effective-weight-goals",
50
+ "Effective Weight Goals",
51
+ Client.get_effective_weight_goals,
52
+ "date_range",
53
+ ),
54
+ CommandSpec(
55
+ "weight-measurements", "Weight Measurements", Client.get_weight_measurements, "date_range"
56
+ ),
57
+ CommandSpec("food-logs", "Food Logs", Client.get_daily_food_logs, "date"),
58
+ CommandSpec(
59
+ "profile-settings", "User Profile Settings", Client.get_user_profile_settings, "none"
60
+ ),
61
+ CommandSpec("sleep", "Daily Sleep", Client.get_daily_sleep, "date"),
62
+ CommandSpec(
63
+ "sleep-summaries", "Daily Sleep Summaries", Client.get_daily_sleep_summaries, "date_range"
64
+ ),
65
+ CommandSpec("body-battery", "Daily Body Battery", Client.get_daily_body_battery, "date_range"),
66
+ CommandSpec(
67
+ "daily-summaries",
68
+ "User Daily Summaries",
69
+ Client.get_daily_summaries,
70
+ "date_range",
71
+ ),
72
+ CommandSpec("heart-rate", "Daily Heart Rate", Client.get_daily_heart_rate, "date"),
73
+ CommandSpec(
74
+ "hrv",
75
+ "Daily HRV",
76
+ Client.get_daily_hrv,
77
+ "date",
78
+ description="Get heart rate variability (HRV) summary and readings for a calendar date.",
79
+ ),
80
+ CommandSpec(
81
+ "hrv-summaries",
82
+ "Daily HRV Summaries",
83
+ Client.get_daily_hrv_summaries,
84
+ "date_range",
85
+ description="Get daily heart rate variability (HRV) summaries for an inclusive date range.",
86
+ ),
87
+ CommandSpec("heart-rate-zones", "Heart Rate Zones", Client.get_heart_rate_zones, "none"),
88
+ CommandSpec(
89
+ "menstrual-cycle-summary",
90
+ "Menstrual Cycle Summary",
91
+ Client.get_menstrual_cycle_summary,
92
+ "date",
93
+ ),
94
+ CommandSpec(
95
+ "menstrual-cycle-day-view",
96
+ "Menstrual Cycle Day View",
97
+ Client.get_menstrual_cycle_day_view,
98
+ "date_with_today",
99
+ ),
100
+ CommandSpec("fitness-age", "Fitness Age", Client.get_fitness_age, "date"),
101
+ CommandSpec("spo2", "Daily SpO2", Client.get_daily_spo2, "date"),
102
+ CommandSpec(
103
+ "wellness-activity-summaries",
104
+ "Wellness Activity Summaries",
105
+ Client.get_wellness_activity_summaries,
106
+ "date_range",
107
+ ),
108
+ CommandSpec("activity", "Activity Details", Client.get_activity, "activity"),
109
+ CommandSpec("activity-workouts", "Activity Workouts", Client.get_activity_workouts, "activity"),
110
+ CommandSpec(
111
+ "activity-download",
112
+ "Download original activity file",
113
+ Client.download_activity,
114
+ "activity",
115
+ "download",
116
+ ),
117
+ )
118
+
119
+ COMMANDS_BY_NAME = {spec.command: spec for spec in COMMANDS}
@@ -0,0 +1,25 @@
1
+ """Public defaults for Garmin Personal API access."""
2
+
3
+ # Copyright 2026 Garmin Ltd. or its subsidiaries.
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ from pathlib import Path
18
+
19
+ DEFAULT_BASE_URL = "https://personal.connectapi.garmin.com"
20
+ DEFAULT_API_KEY_FILE = Path("~/.config/garmin/connect-personal-api-key")
21
+ DEFAULT_BEARER_TOKEN_CACHE_FILE = Path("~/.config/garmin/connect-personal-api-bearer-token.json")
22
+ DEFAULT_API_KEY_ENV_VAR = "GARMIN_PERSONAL_API_KEY"
23
+ BASE_URL_ENV_VAR = "GARMIN_PERSONAL_API_BASE_URL"
24
+ DEFAULT_TIMEOUT_SECONDS = 30.0
25
+ TOKEN_PATH = "/personalapitoken-service/personal-api/personal-api-key/token"
@@ -0,0 +1,48 @@
1
+ """File output shared by direct and interactive downloads."""
2
+
3
+ # Copyright 2026 Garmin Ltd. or its subsidiaries.
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ import os
18
+ import tempfile
19
+ from pathlib import Path
20
+
21
+ from .errors import GarminPersonalApiError
22
+
23
+
24
+ def save_download(content: bytes, destination: Path, *, overwrite: bool = False) -> Path:
25
+ """Publish a complete file, without replacing an existing file unless requested."""
26
+ destination = destination.expanduser()
27
+ temporary: Path | None = None
28
+ try:
29
+ with tempfile.NamedTemporaryFile(
30
+ dir=destination.parent, prefix=f".{destination.name}.", delete=False
31
+ ) as output:
32
+ temporary = Path(output.name)
33
+ output.write(content)
34
+ if overwrite:
35
+ temporary.replace(destination)
36
+ else:
37
+ # Linking publishes atomically and refuses even a dangling destination symlink.
38
+ os.link(temporary, destination)
39
+ except FileExistsError as exc:
40
+ raise GarminPersonalApiError(
41
+ f"File already exists: {destination}. Choose another path or allow overwrite."
42
+ ) from exc
43
+ except OSError as exc:
44
+ raise GarminPersonalApiError(f"Cannot save {destination}: {exc}") from exc
45
+ finally:
46
+ if temporary is not None:
47
+ temporary.unlink(missing_ok=True)
48
+ return destination
@@ -0,0 +1,42 @@
1
+ """Exceptions raised by the Garmin Personal API client."""
2
+
3
+ # Copyright 2026 Garmin Ltd. or its subsidiaries.
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ from __future__ import annotations
18
+
19
+
20
+ class GarminPersonalApiError(Exception):
21
+ """Base class for all package-specific errors."""
22
+
23
+
24
+ class HttpError(GarminPersonalApiError):
25
+ """Raised when the Garmin API returns an unsuccessful HTTP response."""
26
+
27
+ def __init__(
28
+ self,
29
+ *,
30
+ status_code: int,
31
+ method: str,
32
+ url: str,
33
+ response_text: str,
34
+ ) -> None:
35
+ self.status_code = status_code
36
+ self.method = method
37
+ self.url = url
38
+ self.response_text = response_text
39
+ message = f"{method} {url} returned HTTP {status_code}"
40
+ if response_text.strip():
41
+ message = f"{message}: {response_text.strip()}"
42
+ super().__init__(message)