garmin-connect-api 0.2.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.
Files changed (41) hide show
  1. garmin_api/__init__.py +13 -0
  2. garmin_api/auth.py +296 -0
  3. garmin_api/catalogue.py +187 -0
  4. garmin_api/cli.py +357 -0
  5. garmin_api/errors.py +70 -0
  6. garmin_api/fit/__init__.py +12 -0
  7. garmin_api/fit/body.py +112 -0
  8. garmin_api/fit/decode.py +153 -0
  9. garmin_api/fit/encode.py +219 -0
  10. garmin_api/fit/exercises.py +95 -0
  11. garmin_api/http/__init__.py +5 -0
  12. garmin_api/http/app.py +119 -0
  13. garmin_api/http/routers/__init__.py +1 -0
  14. garmin_api/http/routers/activities.py +142 -0
  15. garmin_api/http/routers/exercises.py +44 -0
  16. garmin_api/http/routers/health.py +159 -0
  17. garmin_api/http/routers/sessions.py +92 -0
  18. garmin_api/http/routers/workouts.py +124 -0
  19. garmin_api/mcp/__init__.py +5 -0
  20. garmin_api/mcp/server.py +283 -0
  21. garmin_api/models/__init__.py +50 -0
  22. garmin_api/models/activities.py +237 -0
  23. garmin_api/models/common.py +102 -0
  24. garmin_api/models/health.py +399 -0
  25. garmin_api/models/laps.py +272 -0
  26. garmin_api/models/records.py +140 -0
  27. garmin_api/models/trends.py +134 -0
  28. garmin_api/models/workouts.py +293 -0
  29. garmin_api/py.typed +0 -0
  30. garmin_api/services/__init__.py +10 -0
  31. garmin_api/services/activities.py +285 -0
  32. garmin_api/services/health.py +198 -0
  33. garmin_api/services/trends.py +182 -0
  34. garmin_api/services/uploads.py +198 -0
  35. garmin_api/services/weight.py +77 -0
  36. garmin_api/services/workouts.py +402 -0
  37. garmin_connect_api-0.2.0.dist-info/METADATA +292 -0
  38. garmin_connect_api-0.2.0.dist-info/RECORD +41 -0
  39. garmin_connect_api-0.2.0.dist-info/WHEEL +4 -0
  40. garmin_connect_api-0.2.0.dist-info/entry_points.txt +3 -0
  41. garmin_connect_api-0.2.0.dist-info/licenses/LICENSE +21 -0
garmin_api/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """A read/write API for Garmin Connect.
2
+
3
+ Three faces over one core:
4
+
5
+ * the library -- :mod:`garmin_api.services`, plain typed functions;
6
+ * the HTTP service -- :mod:`garmin_api.http`, FastAPI over those functions;
7
+ * the MCP server -- :mod:`garmin_api.mcp`, generated from the HTTP app.
8
+
9
+ Everything talks to Garmin through :mod:`garmin_api.auth`, which owns the one
10
+ authenticated client and the token store.
11
+ """
12
+
13
+ __version__ = "0.2.0"
garmin_api/auth.py ADDED
@@ -0,0 +1,296 @@
1
+ """The authenticated Garmin client, and the token store behind it.
2
+
3
+ The rules this module exists to enforce:
4
+
5
+ * A password is typed in exactly one place, ``garmin login``, at a terminal.
6
+ The HTTP service and the MCP server never see one and never prompt; without
7
+ a usable token they raise :class:`NotAuthenticated` and say to run the CLI.
8
+ * Tokens are reused. Garmin put Cloudflare fingerprinting and per-account rate
9
+ buckets in front of the SSO endpoints in March 2026, and a run of failed
10
+ logins locks the account out for a day or more. Logging in is rare and
11
+ deliberate; refreshing is the library's job.
12
+ * One client, one lock. Concurrent HTTP or MCP calls share a single
13
+ ``Garmin`` instance rather than racing to authenticate separately.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import os
20
+ import threading
21
+ from collections.abc import Callable
22
+ from datetime import UTC, datetime
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ from garminconnect import Garmin
27
+
28
+ from .errors import (
29
+ Duplicate,
30
+ GarminUnavailable,
31
+ InvalidRequest,
32
+ NotAuthenticated,
33
+ NotFound,
34
+ RateLimited,
35
+ )
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ DEFAULT_TOKEN_DIR = Path.home() / ".garmin-api" / "default"
40
+
41
+
42
+ def token_dir() -> Path:
43
+ """Where tokens live.
44
+
45
+ ``GARMINTOKENS`` is honoured because python-garminconnect and the various
46
+ community tools all read it; point it at the same directory and one login
47
+ serves all of them.
48
+ """
49
+ env = os.getenv("GARMINTOKENS")
50
+ return Path(env).expanduser() if env else DEFAULT_TOKEN_DIR
51
+
52
+
53
+ def _token_files() -> list[Path]:
54
+ """This package's token file, if present.
55
+
56
+ python-garminconnect 0.3 keeps everything in one ``garmin_tokens.json``,
57
+ and its own ``token_file_path`` is asked where that is so the two cannot
58
+ disagree. The older garth files (``oauth1_token.json``, ``oauth2_token.json``)
59
+ are deliberately not counted: 0.3.19 refuses them, so reporting them as a
60
+ login would say "authenticated" while every call fails.
61
+ """
62
+ from garminconnect.client import token_file_path
63
+
64
+ try:
65
+ current = token_file_path(str(token_dir()))
66
+ except ValueError:
67
+ return []
68
+ return [current] if current.is_file() else []
69
+
70
+
71
+ def legacy_tokens() -> bool:
72
+ """Whether only old garth-format tokens are present, which need a fresh login."""
73
+ directory = token_dir()
74
+ return directory.is_dir() and any(directory.glob("oauth*_token.json")) and not _token_files()
75
+
76
+
77
+ def has_tokens() -> bool:
78
+ return bool(_token_files())
79
+
80
+
81
+ def token_age_days() -> float | None:
82
+ """Days since the tokens were last written, or None if there are none.
83
+
84
+ Garmin issues a short-lived access token with a longer refresh token, and
85
+ the library rewrites the file when it refreshes, so a large number here
86
+ means the tokens have not been used in a long while rather than that they
87
+ have expired.
88
+ """
89
+ files = _token_files()
90
+ if not files:
91
+ return None
92
+ newest = max(f.stat().st_mtime for f in files)
93
+ return (datetime.now(UTC).timestamp() - newest) / 86400
94
+
95
+
96
+ def _status_of(exc: BaseException) -> int | None:
97
+ """The HTTP status behind a library error, if it carries one.
98
+
99
+ python-garminconnect keeps the original response on ``.response`` and also
100
+ writes the status into the message, as in "client error (404)".
101
+ """
102
+ import re
103
+
104
+ # Walk the cause chain: the library re-raises upload failures as
105
+ # "Import error: ..." from the original, which is where the status lives.
106
+ seen: BaseException | None = exc
107
+ for _ in range(5):
108
+ if seen is None:
109
+ break
110
+ response = getattr(seen, "response", None)
111
+ status = getattr(response, "status_code", None)
112
+ if isinstance(status, int):
113
+ return status
114
+ match = re.search(r"\((\d{3})\)|API Error (\d{3})", str(seen))
115
+ if match:
116
+ return int(match.group(1) or match.group(2))
117
+ seen = seen.__cause__ or seen.__context__
118
+ return None
119
+
120
+
121
+ def translate(exc: Exception) -> Exception:
122
+ """Map a library or transport exception onto this package's vocabulary.
123
+
124
+ The distinction that matters most to a caller is between Garmin answering
125
+ "no" and Garmin not answering at all. A missing activity is a 404 and a
126
+ malformed request a 400; only a network failure means Garmin could not be
127
+ reached, and only a 5xx means Garmin itself is having trouble.
128
+ """
129
+ from garminconnect import (
130
+ GarminConnectAuthenticationError,
131
+ GarminConnectConnectionError,
132
+ GarminConnectInvalidFileFormatError,
133
+ GarminConnectNotFoundError,
134
+ GarminConnectTooManyRequestsError,
135
+ )
136
+
137
+ if isinstance(exc, GarminConnectTooManyRequestsError):
138
+ return RateLimited(
139
+ "Garmin is rate limiting this account.",
140
+ detail=(
141
+ "Wait before trying again -- repeated failures against the login "
142
+ "endpoints can lock an account out for 24-72 hours. Do not retry in a loop."
143
+ ),
144
+ )
145
+ if isinstance(exc, GarminConnectAuthenticationError):
146
+ return NotAuthenticated(
147
+ "Garmin rejected the stored credentials.",
148
+ detail="Run `garmin login` to authenticate again.",
149
+ )
150
+ if isinstance(exc, GarminConnectNotFoundError):
151
+ return NotFound("Garmin has no such item.", detail=str(exc))
152
+ if isinstance(exc, GarminConnectInvalidFileFormatError):
153
+ return InvalidRequest("Garmin does not accept this file format.", detail=str(exc))
154
+ if isinstance(exc, GarminConnectConnectionError):
155
+ if "already exists (duplicate)" in str(exc):
156
+ return Duplicate("Garmin already has this activity.", detail=str(exc))
157
+ status = _status_of(exc)
158
+ # Writes go through the library's transport directly rather than its
159
+ # error mapper, so a 429 or 401 on a write arrives here as a plain
160
+ # connection error carrying the status. They must still mean "stop".
161
+ if status == 429:
162
+ return translate(GarminConnectTooManyRequestsError(str(exc)))
163
+ if status == 401:
164
+ return translate(GarminConnectAuthenticationError(str(exc)))
165
+ if status == 404:
166
+ return NotFound("Garmin has no such item.", detail=str(exc))
167
+ if status is not None and 400 <= status < 500:
168
+ return InvalidRequest(f"Garmin rejected the request ({status}).", detail=str(exc))
169
+ if status is not None and status >= 500:
170
+ return GarminUnavailable(
171
+ f"Garmin answered with a server error ({status}).",
172
+ detail="The library already retried. Try again later.",
173
+ )
174
+ return GarminUnavailable("Could not reach Garmin Connect.", detail=str(exc))
175
+ if isinstance(exc, (ValueError, TypeError)):
176
+ # Raised by the library's own input checks before any request is made.
177
+ return InvalidRequest("Garmin's client refused the input.", detail=str(exc))
178
+ return exc
179
+
180
+
181
+ class Session:
182
+ """Holds the one authenticated client and hands it out under a lock."""
183
+
184
+ def __init__(self, tokens: Path | None = None) -> None:
185
+ # Resolved on use rather than captured here, so the location has one
186
+ # source of truth: the same call has_tokens() makes.
187
+ self._tokens_override = tokens
188
+ self._client: Garmin | None = None
189
+ self._lock = threading.RLock()
190
+
191
+ @property
192
+ def _tokens(self) -> Path:
193
+ return self._tokens_override or token_dir()
194
+
195
+ @property
196
+ def token_path(self) -> Path:
197
+ return self._tokens
198
+
199
+ def client(self) -> Garmin:
200
+ """Return the authenticated client, resuming from stored tokens.
201
+
202
+ Never prompts and never uses a password: a missing or rejected token is
203
+ an error the caller has to resolve with ``garmin login``.
204
+ """
205
+ with self._lock:
206
+ if self._client is not None:
207
+ return self._client
208
+ if not has_tokens():
209
+ raise NotAuthenticated(
210
+ "No Garmin tokens found.",
211
+ detail=f"Run `garmin login` first. Tokens are stored in {self._tokens}.",
212
+ )
213
+ client = Garmin()
214
+ try:
215
+ client.login(tokenstore=str(self._tokens))
216
+ except Exception as exc: # noqa: BLE001 -- re-raised as our own type
217
+ raise translate(exc) from exc
218
+ self._client = client
219
+ return client
220
+
221
+ def call(self, fn_name: str, *args: Any, **kwargs: Any) -> Any:
222
+ """Run one Garmin call by method name, translating failures.
223
+
224
+ A 401 mid-session means the cached client went stale, so it is rebuilt
225
+ from the stored tokens and the call is made once more -- on the new
226
+ client, which is why the method is looked up by name each time. A
227
+ second failure is reported rather than retried.
228
+ """
229
+ try:
230
+ return getattr(self.client(), fn_name)(*args, **kwargs)
231
+ except Exception as exc: # noqa: BLE001
232
+ if _is_unauthorized(exc):
233
+ with self._lock:
234
+ self._client = None
235
+ logger.info("Garmin returned 401; reloading tokens and retrying once")
236
+ try:
237
+ return getattr(self.client(), fn_name)(*args, **kwargs)
238
+ except Exception as retry_exc: # noqa: BLE001
239
+ raise translate(retry_exc) from retry_exc
240
+ raise translate(exc) from exc
241
+
242
+ def reset(self) -> None:
243
+ with self._lock:
244
+ self._client = None
245
+
246
+
247
+ def _is_unauthorized(exc: Exception) -> bool:
248
+ response = getattr(exc, "response", None)
249
+ if response is not None and getattr(response, "status_code", None) == 401:
250
+ return True
251
+ return "401" in str(exc) and "unauthor" in str(exc).lower()
252
+
253
+
254
+ _session: Session | None = None
255
+ _session_lock = threading.Lock()
256
+
257
+
258
+ def session() -> Session:
259
+ """The process-wide session."""
260
+ global _session
261
+ with _session_lock:
262
+ if _session is None:
263
+ _session = Session()
264
+ return _session
265
+
266
+
267
+ def client() -> Garmin:
268
+ return session().client()
269
+
270
+
271
+ def call(fn_name: str, *args: Any, **kwargs: Any) -> Any:
272
+ """Call a python-garminconnect method by name, with error translation.
273
+
274
+ Services use this rather than touching the client directly, so every
275
+ Garmin call goes through the same locking and error mapping.
276
+ """
277
+ return session().call(fn_name, *args, **kwargs)
278
+
279
+
280
+ def interactive_login(email: str, password: str, prompt_mfa: Callable[[], str]) -> str:
281
+ """Log in with a password and store the tokens. Only the CLI calls this.
282
+
283
+ Returns the display name so the caller can confirm whose account it is.
284
+ """
285
+ target = token_dir()
286
+ target.mkdir(parents=True, exist_ok=True)
287
+ garmin = Garmin(email=email, password=password, prompt_mfa=prompt_mfa)
288
+ try:
289
+ # Passing the token store makes the library persist the tokens itself
290
+ # once the login succeeds, including through the MFA round trip.
291
+ garmin.login(tokenstore=str(target))
292
+ name = garmin.get_full_name() or email
293
+ except Exception as exc: # noqa: BLE001
294
+ raise translate(exc) from exc
295
+ session().reset()
296
+ return str(name)
@@ -0,0 +1,187 @@
1
+ """Garmin's strength exercise taxonomy.
2
+
3
+ Every strength exercise Garmin knows is a ``(category, exercise_name)`` pair,
4
+ such as ``("BENCH_PRESS", "BARBELL_BENCH_PRESS")``. The same pairs appear in
5
+ workout steps, in the ``exerciseSets`` of a completed activity and in FIT set
6
+ messages, and Garmin rejects anything outside the list with an unhelpful 400.
7
+ So every write validates against this first, and says what it would accept.
8
+
9
+ python-garminconnect bundles the catalogue (1,527 exercises, 47 categories)
10
+ scraped from the Connect workout editor, which is the same data served
11
+ publicly at connect.garmin.com/web-data/exercises/Exercises.json. Using the
12
+ bundled copy keeps the package working offline.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import difflib
18
+ from functools import lru_cache
19
+ from typing import NamedTuple
20
+
21
+ from garminconnect import exercises as _catalogue
22
+
23
+ from .errors import InvalidRequest
24
+
25
+
26
+ class Exercise(NamedTuple):
27
+ """One catalogue entry."""
28
+
29
+ name: str
30
+ """Display name, as Garmin Connect shows it, e.g. "Barbell Bench Press"."""
31
+ category: str
32
+ """Category enum, e.g. ``BENCH_PRESS``."""
33
+ exercise: str
34
+ """Variant enum, e.g. ``BARBELL_BENCH_PRESS``. May be empty."""
35
+
36
+
37
+ @lru_cache(maxsize=1)
38
+ def all_exercises() -> tuple[Exercise, ...]:
39
+ return tuple(
40
+ Exercise(name=e["name"], category=e["category"], exercise=e.get("exercise", ""))
41
+ for e in _catalogue.EXERCISES
42
+ )
43
+
44
+
45
+ @lru_cache(maxsize=1)
46
+ def categories() -> tuple[str, ...]:
47
+ return tuple(sorted({e.category for e in all_exercises()}))
48
+
49
+
50
+ @lru_cache(maxsize=1)
51
+ def _by_pair() -> dict[tuple[str, str], Exercise]:
52
+ return {(e.category, e.exercise): e for e in all_exercises()}
53
+
54
+
55
+ @lru_cache(maxsize=1)
56
+ def _by_lower_name() -> dict[str, Exercise]:
57
+ return {e.name.lower(): e for e in all_exercises()}
58
+
59
+
60
+ # Gym names Garmin's catalogue does not use, mapped to the words it does.
61
+ # Each target was checked to find the intended exercise.
62
+ ALIASES = {
63
+ "rdl": "romanian deadlift",
64
+ "ohp": "shoulder press",
65
+ "overhead press": "shoulder press",
66
+ "skull crusher": "lying triceps extension",
67
+ "skullcrusher": "lying triceps extension",
68
+ "chinup": "chin up",
69
+ }
70
+
71
+
72
+ def _words(value: str) -> list[str]:
73
+ """Lowercase alphanumeric words: "Lat Pull-down" -> ["lat", "pull", "down"]."""
74
+ import re
75
+
76
+ return re.findall(r"[a-z0-9]+", value.lower())
77
+
78
+
79
+ def _starts(word: str, token: str) -> bool:
80
+ """A query word starts a name word, tolerating a plural: "dips" finds "Dip"."""
81
+ if word.startswith(token):
82
+ return True
83
+ return len(token) >= 3 and token.endswith("s") and word.startswith(token[:-1])
84
+
85
+
86
+ def search(term: str, limit: int = 20) -> list[Exercise]:
87
+ """Exercises matching ``term``, best match first.
88
+
89
+ A name matches when every word of the query starts one of its words, so
90
+ "pull up" finds "Pull-up" and "lat pulldown" finds "Lat Pull-down", while
91
+ "rdl" no longer matches inside "Hurdle". A query of five or more letters
92
+ also matches with spaces and hyphens ignored, so "pullup" works too.
93
+ Common gym names Garmin does not use, like "skull crusher", are aliased.
94
+ """
95
+ query = " ".join(_words(term))
96
+ query = ALIASES.get(query, ALIASES.get(query.replace(" ", ""), query))
97
+ tokens = _words(query)
98
+ if not tokens:
99
+ return []
100
+ joined = "".join(tokens)
101
+
102
+ scored: list[tuple[int, int, str, Exercise]] = []
103
+ for exercise in all_exercises():
104
+ words = _words(exercise.name)
105
+ flat = "".join(words)
106
+ if words == tokens:
107
+ rank = 0
108
+ elif all(any(_starts(w, tok) for w in words) for tok in tokens):
109
+ rank = 1
110
+ elif len(joined) >= 5 and joined in flat:
111
+ rank = 2
112
+ else:
113
+ continue
114
+ scored.append((rank, len(words), exercise.name, exercise))
115
+ scored.sort(key=lambda row: row[:3])
116
+ return [row[3] for row in scored[:limit]]
117
+
118
+
119
+ def resolve(name: str) -> Exercise | None:
120
+ """Find an exercise by display name.
121
+
122
+ Tries the exact name, then the name with punctuation and spacing
123
+ normalised, so "bench press" and "Bench Press" both land.
124
+ """
125
+ exact = _by_lower_name().get(name.lower().strip())
126
+ if exact:
127
+ return exact
128
+ normalised = _normalise(name)
129
+ for e in all_exercises():
130
+ if _normalise(e.name) == normalised:
131
+ return e
132
+ return None
133
+
134
+
135
+ def validate(category: str, exercise_name: str = "") -> Exercise:
136
+ """Check a ``(category, exercise_name)`` pair, or explain what is wrong.
137
+
138
+ Raises :class:`InvalidRequest` with near-miss suggestions rather than
139
+ letting Garmin answer with a bare 400.
140
+ """
141
+ cat = category.upper().strip()
142
+ ex = (exercise_name or "").upper().strip()
143
+
144
+ if cat not in categories():
145
+ close = difflib.get_close_matches(cat, categories(), n=5, cutoff=0.4)
146
+ raise InvalidRequest(
147
+ f"Unknown exercise category {category!r}.",
148
+ detail=("Did you mean: " + ", ".join(close) + "?")
149
+ if close
150
+ else "Call the exercise catalogue for the valid categories.",
151
+ )
152
+
153
+ found = _by_pair().get((cat, ex))
154
+ if found is None:
155
+ in_category = sorted(e.exercise for e in all_exercises() if e.category == cat)
156
+ close = difflib.get_close_matches(ex, in_category, n=5, cutoff=0.4)
157
+ raise InvalidRequest(
158
+ f"Unknown exercise {exercise_name!r} in category {cat}.",
159
+ detail=(
160
+ ("Did you mean: " + ", ".join(close) + "?")
161
+ if close
162
+ else f"Valid names in {cat}: " + ", ".join(in_category[:15])
163
+ ),
164
+ )
165
+ return found
166
+
167
+
168
+ def match(name: str) -> Exercise | None:
169
+ """Best effort: map a free-text exercise name onto the catalogue.
170
+
171
+ For callers holding names from somewhere else ("Barbell Bench Press",
172
+ "bench press"). Exact and normalised matches first, then the closest
173
+ fuzzy match above a conservative threshold, so a bad guess returns None
174
+ rather than silently logging the wrong lift.
175
+ """
176
+ direct = resolve(name)
177
+ if direct:
178
+ return direct
179
+ # Compare lowercased, or "bench press" scores badly against the catalogue's
180
+ # "Bench Press" purely on capitalisation.
181
+ lookup = _by_lower_name()
182
+ close = difflib.get_close_matches(name.strip().lower(), list(lookup), n=1, cutoff=0.82)
183
+ return lookup[close[0]] if close else None
184
+
185
+
186
+ def _normalise(value: str) -> str:
187
+ return "".join(ch for ch in value.lower() if ch.isalnum())