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.
- garmin_api/__init__.py +13 -0
- garmin_api/auth.py +296 -0
- garmin_api/catalogue.py +187 -0
- garmin_api/cli.py +357 -0
- garmin_api/errors.py +70 -0
- garmin_api/fit/__init__.py +12 -0
- garmin_api/fit/body.py +112 -0
- garmin_api/fit/decode.py +153 -0
- garmin_api/fit/encode.py +219 -0
- garmin_api/fit/exercises.py +95 -0
- garmin_api/http/__init__.py +5 -0
- garmin_api/http/app.py +119 -0
- garmin_api/http/routers/__init__.py +1 -0
- garmin_api/http/routers/activities.py +142 -0
- garmin_api/http/routers/exercises.py +44 -0
- garmin_api/http/routers/health.py +159 -0
- garmin_api/http/routers/sessions.py +92 -0
- garmin_api/http/routers/workouts.py +124 -0
- garmin_api/mcp/__init__.py +5 -0
- garmin_api/mcp/server.py +283 -0
- garmin_api/models/__init__.py +50 -0
- garmin_api/models/activities.py +237 -0
- garmin_api/models/common.py +102 -0
- garmin_api/models/health.py +399 -0
- garmin_api/models/laps.py +272 -0
- garmin_api/models/records.py +140 -0
- garmin_api/models/trends.py +134 -0
- garmin_api/models/workouts.py +293 -0
- garmin_api/py.typed +0 -0
- garmin_api/services/__init__.py +10 -0
- garmin_api/services/activities.py +285 -0
- garmin_api/services/health.py +198 -0
- garmin_api/services/trends.py +182 -0
- garmin_api/services/uploads.py +198 -0
- garmin_api/services/weight.py +77 -0
- garmin_api/services/workouts.py +402 -0
- garmin_connect_api-0.2.0.dist-info/METADATA +292 -0
- garmin_connect_api-0.2.0.dist-info/RECORD +41 -0
- garmin_connect_api-0.2.0.dist-info/WHEEL +4 -0
- garmin_connect_api-0.2.0.dist-info/entry_points.txt +3 -0
- 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)
|
garmin_api/catalogue.py
ADDED
|
@@ -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())
|