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,20 @@
1
+ """Run the package CLI with ``python -m 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 .cli import main
18
+
19
+ if __name__ == "__main__":
20
+ raise SystemExit(main())
@@ -0,0 +1,41 @@
1
+ """Shared credential validation and file-permission checks."""
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 pathlib import Path
22
+
23
+ from .errors import GarminPersonalApiError
24
+
25
+ _LOGGER = logging.getLogger(__name__)
26
+
27
+
28
+ def _require_nonempty_key(value: str, source: str) -> str:
29
+ key = value.strip()
30
+ if not key:
31
+ raise GarminPersonalApiError(f"The value from {source} is empty.")
32
+ return key
33
+
34
+
35
+ def _warn_if_readable_by_others(path: Path, description: str) -> None:
36
+ if os.name == "posix" and path.stat().st_mode & 0o077:
37
+ _LOGGER.warning(
38
+ "%s %s is readable by group or other users; restrict it with chmod 600",
39
+ description,
40
+ path,
41
+ )
@@ -0,0 +1,17 @@
1
+ """Package version used by both runtime metadata and the build backend."""
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
+ __version__ = "1.0.0"
@@ -0,0 +1,64 @@
1
+ """Personal API key discovery."""
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 os
20
+ from collections.abc import Mapping
21
+ from pathlib import Path
22
+
23
+ from ._credentials import _require_nonempty_key, _warn_if_readable_by_others
24
+ from .constants import DEFAULT_API_KEY_ENV_VAR, DEFAULT_API_KEY_FILE
25
+ from .errors import GarminPersonalApiError
26
+
27
+
28
+ def resolve_api_key(
29
+ api_key: str | None = None,
30
+ *,
31
+ env: Mapping[str, str] | None = None,
32
+ env_var: str = DEFAULT_API_KEY_ENV_VAR,
33
+ key_file: str | Path | None = None,
34
+ ) -> str:
35
+ """Resolve an explicit key, explicit file, environment, then default file.
36
+
37
+ Pass the result to ``PersonalApiAuthenticator.obtain_token()`` to opt into
38
+ discovery. An explicitly supplied ``key_file`` overrides the environment,
39
+ even when it names the default file. Invalid explicit sources do not fall
40
+ back to other sources.
41
+ """
42
+ if api_key is not None:
43
+ return _require_nonempty_key(api_key, "the explicit API key")
44
+
45
+ if key_file is None:
46
+ environment = os.environ if env is None else env
47
+ environment_value = environment.get(env_var)
48
+ if environment_value is not None:
49
+ return _require_nonempty_key(environment_value, f"environment variable {env_var}")
50
+
51
+ path = Path(DEFAULT_API_KEY_FILE if key_file is None else key_file).expanduser()
52
+ try:
53
+ file_value = path.read_text(encoding="utf-8")
54
+ except FileNotFoundError as exc:
55
+ if key_file is not None:
56
+ raise GarminPersonalApiError(
57
+ f"Personal API key file {path} does not exist."
58
+ ) from exc
59
+ raise GarminPersonalApiError(
60
+ "No personal API key was provided. Pass --api-key/api_key, set "
61
+ f"{env_var}, or create {path}."
62
+ ) from exc
63
+ _warn_if_readable_by_others(path, "Personal API key file")
64
+ return _require_nonempty_key(file_value, f"personal API key file {path}")
@@ -0,0 +1,188 @@
1
+ """Bearer-token authentication and caching."""
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 base64
20
+ import hashlib
21
+ import json
22
+ import logging
23
+ import os
24
+ from collections.abc import Callable
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+ from time import time
28
+
29
+ import requests
30
+
31
+ from ._credentials import _require_nonempty_key, _warn_if_readable_by_others
32
+ from .constants import (
33
+ BASE_URL_ENV_VAR,
34
+ DEFAULT_BASE_URL,
35
+ DEFAULT_BEARER_TOKEN_CACHE_FILE,
36
+ DEFAULT_TIMEOUT_SECONDS,
37
+ TOKEN_PATH,
38
+ )
39
+ from .errors import GarminPersonalApiError, HttpError
40
+
41
+ _BEARER_TOKEN_REFRESH_WINDOW_SECONDS = 60 * 60
42
+ _LOGGER = logging.getLogger(__name__)
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class BearerToken:
47
+ """A bearer token and its expiration time in seconds since the epoch."""
48
+
49
+ access_token: str = field(repr=False)
50
+ expires_at: float
51
+
52
+
53
+ class PersonalApiAuthenticator:
54
+ """Cache bearer tokens and exchange a Garmin personal API key when needed.
55
+
56
+ Key discovery is separate from HTTP authentication. Pass :meth:`obtain_token`
57
+ a key from :func:`garmin_personal_api.api_keys.resolve_api_key` or from
58
+ another secret source.
59
+ """
60
+
61
+ def __init__(
62
+ self,
63
+ *,
64
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
65
+ session: requests.Session | None = None,
66
+ bearer_token_cache_file: str | Path = DEFAULT_BEARER_TOKEN_CACHE_FILE,
67
+ clock: Callable[[], float] = time,
68
+ ) -> None:
69
+ if timeout <= 0:
70
+ raise GarminPersonalApiError("timeout must be greater than zero")
71
+ self.base_url = os.environ.get(BASE_URL_ENV_VAR, DEFAULT_BASE_URL).rstrip("/")
72
+ self.timeout = timeout
73
+ self._session = session or requests.Session()
74
+ self.bearer_token_cache_file = Path(bearer_token_cache_file).expanduser()
75
+ self._clock = clock
76
+
77
+ def close(self) -> None:
78
+ """Close the underlying HTTP session."""
79
+ self._session.close()
80
+
81
+ def __enter__(self) -> PersonalApiAuthenticator:
82
+ return self
83
+
84
+ def __exit__(self, *exc_info: object) -> None:
85
+ self.close()
86
+
87
+ def __repr__(self) -> str:
88
+ return f"{type(self).__name__}(base_url={self.base_url!r}, timeout={self.timeout!r})"
89
+
90
+ def obtain_token(
91
+ self,
92
+ api_key: str,
93
+ *,
94
+ force_refresh: bool = False,
95
+ ) -> BearerToken:
96
+ """Return a cached bearer token or exchange the key for a new bearer token."""
97
+ if not isinstance(api_key, str):
98
+ raise GarminPersonalApiError(
99
+ "An API key string is required; use resolve_api_key() to discover one."
100
+ )
101
+ key = _require_nonempty_key(api_key, "the explicit API key")
102
+ # Cached tokens are only reused for the same API key and environment.
103
+ fingerprint = hashlib.sha256(f"{self.base_url}\0{key}".encode()).hexdigest()
104
+ if not force_refresh:
105
+ cached_token = self._read_cached_token(fingerprint)
106
+ if cached_token is not None:
107
+ if cached_token.expires_at - self._clock() > _BEARER_TOKEN_REFRESH_WINDOW_SECONDS:
108
+ _LOGGER.info("Using cached bearer token")
109
+ return cached_token
110
+ _LOGGER.info(
111
+ "Cached bearer token is expired or expires within the 1-hour refresh "
112
+ "window; requesting a new token"
113
+ )
114
+
115
+ url = f"{self.base_url}{TOKEN_PATH}"
116
+ _LOGGER.info("POST %s", TOKEN_PATH)
117
+ response = self._session.post(
118
+ url,
119
+ headers={"Accept": "*/*", "Authorization": f"ApiKey {key}"},
120
+ timeout=self.timeout,
121
+ )
122
+ if not response.ok:
123
+ raise HttpError(
124
+ status_code=response.status_code,
125
+ method="POST",
126
+ url=url,
127
+ response_text=response.text,
128
+ )
129
+
130
+ access_token = response.text.strip()
131
+ token = BearerToken(access_token=access_token, expires_at=_jwt_expiration(access_token))
132
+ self._write_cached_token(fingerprint, token)
133
+ _LOGGER.info("Received and cached a new bearer token")
134
+ return token
135
+
136
+ def _read_cached_token(self, fingerprint: str) -> BearerToken | None:
137
+ path = self.bearer_token_cache_file
138
+ try:
139
+ serialized = path.read_text(encoding="utf-8")
140
+ except FileNotFoundError:
141
+ _LOGGER.info(
142
+ "No cached bearer token found; requesting a new token"
143
+ )
144
+ return None
145
+ _warn_if_readable_by_others(path, "Bearer-token cache file")
146
+ try:
147
+ cache = json.loads(serialized)
148
+ if cache["fingerprint"] != fingerprint:
149
+ _LOGGER.info(
150
+ "Cached bearer token fingerprint does not match the current API key or "
151
+ "base URL; requesting a new token"
152
+ )
153
+ return None
154
+ return BearerToken(
155
+ access_token=cache["access_token"],
156
+ expires_at=float(cache["expires_at"]),
157
+ )
158
+ except (KeyError, TypeError, ValueError):
159
+ _LOGGER.warning("Ignoring invalid bearer-token cache file %s", path)
160
+ return None
161
+
162
+ def _write_cached_token(self, fingerprint: str, token: BearerToken) -> None:
163
+ path = self.bearer_token_cache_file
164
+ path.parent.mkdir(parents=True, exist_ok=True)
165
+ descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
166
+ with os.fdopen(descriptor, "w", encoding="utf-8") as cache_file:
167
+ json.dump(
168
+ {
169
+ "fingerprint": fingerprint,
170
+ "access_token": token.access_token,
171
+ "expires_at": token.expires_at,
172
+ },
173
+ cache_file,
174
+ )
175
+
176
+
177
+ def _jwt_expiration(access_token: str) -> float:
178
+ """Return the ``exp`` claim of a JWT without verifying its signature."""
179
+ try:
180
+ encoded_payload = access_token.split(".")[1]
181
+ payload = json.loads(
182
+ base64.urlsafe_b64decode(encoded_payload + "=" * (-len(encoded_payload) % 4))
183
+ )
184
+ return float(payload["exp"])
185
+ except (IndexError, KeyError, TypeError, ValueError) as exc:
186
+ raise GarminPersonalApiError(
187
+ "The token endpoint did not return a JWT with an exp claim."
188
+ ) from exc
@@ -0,0 +1,330 @@
1
+ """Command-line reference application for Garmin Personal API reads."""
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 argparse
20
+ import importlib
21
+ import json
22
+ import logging
23
+ import sys
24
+ from collections.abc import Sequence
25
+ from contextlib import nullcontext
26
+ from datetime import date, datetime
27
+ from functools import partial
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ import requests
32
+
33
+ from . import __version__
34
+ from .api_keys import resolve_api_key
35
+ from .auth import PersonalApiAuthenticator
36
+ from .client import GarminPersonalApiClient
37
+ from .commands import COMMANDS, COMMANDS_BY_NAME, TOKEN_REFRESH_COMMAND, CommandSpec
38
+ from .constants import (
39
+ DEFAULT_API_KEY_FILE,
40
+ DEFAULT_BEARER_TOKEN_CACHE_FILE,
41
+ DEFAULT_TIMEOUT_SECONDS,
42
+ )
43
+ from .downloads import save_download
44
+ from .errors import GarminPersonalApiError
45
+ from .interactive import (
46
+ _route_logs_to_status,
47
+ print_heading,
48
+ print_request_status,
49
+ run_interactive,
50
+ )
51
+
52
+
53
+ def build_parser() -> argparse.ArgumentParser:
54
+ """Build the CLI argument parser from the endpoint registry."""
55
+ parser = argparse.ArgumentParser(
56
+ prog="gc-personal",
57
+ description="Retrieve Garmin data with a personal API key.",
58
+ )
59
+ parser.add_argument("--version", action="version", version=f"gc-personal {__version__}")
60
+ parser.add_argument(
61
+ "--api-key",
62
+ help=(
63
+ "Personal API key. Prefer the environment variable or key file to avoid "
64
+ "shell-history and process-list exposure."
65
+ ),
66
+ )
67
+ parser.add_argument(
68
+ "--api-key-file",
69
+ type=Path,
70
+ help=(
71
+ "Plain-text API key file; overrides GARMIN_PERSONAL_API_KEY unless --api-key "
72
+ f"is given. Without either option, use the environment variable, then "
73
+ f"{DEFAULT_API_KEY_FILE}."
74
+ ),
75
+ )
76
+ parser.add_argument(
77
+ "--bearer-token-cache-file",
78
+ type=Path,
79
+ default=DEFAULT_BEARER_TOKEN_CACHE_FILE,
80
+ help=f"Bearer-token cache file (default: {DEFAULT_BEARER_TOKEN_CACHE_FILE}).",
81
+ )
82
+ parser.add_argument(
83
+ "--timeout",
84
+ type=float,
85
+ default=DEFAULT_TIMEOUT_SECONDS,
86
+ help=f"Request timeout in seconds (default: {DEFAULT_TIMEOUT_SECONDS:g}).",
87
+ )
88
+ parser.add_argument(
89
+ "--quiet",
90
+ action="store_true",
91
+ help="Suppress request logging for non-interactive commands.",
92
+ )
93
+ parser.add_argument(
94
+ "--no-color",
95
+ action="store_true",
96
+ help="Disable interactive terminal colors.",
97
+ )
98
+
99
+ subparsers = parser.add_subparsers(dest="command")
100
+ for spec in COMMANDS:
101
+ help_text = spec.description or (
102
+ f"{spec.label}." if spec.output_kind == "download" else f"Get {spec.label.lower()}."
103
+ )
104
+ subparser = subparsers.add_parser(spec.command, help=help_text, description=help_text)
105
+ _add_command_arguments(subparser, spec)
106
+ refresh_help = "Obtain and cache a new bearer token, replacing any cached token."
107
+ subparsers.add_parser(TOKEN_REFRESH_COMMAND, help=refresh_help, description=refresh_help)
108
+ return parser
109
+
110
+
111
+ def _add_command_arguments(
112
+ parser: argparse.ArgumentParser,
113
+ spec: CommandSpec,
114
+ ) -> None:
115
+ today = date.today().isoformat()
116
+ if spec.input_kind == "date":
117
+ parser.add_argument(
118
+ "--date",
119
+ metavar="DATE",
120
+ default=today,
121
+ help="Calendar date in YYYY-MM-DD format (default: today).",
122
+ )
123
+ elif spec.input_kind == "date_with_today":
124
+ parser.add_argument(
125
+ "--date",
126
+ metavar="DATE",
127
+ default=today,
128
+ help="Calendar date in YYYY-MM-DD format (default: today).",
129
+ )
130
+ parser.add_argument(
131
+ "--today-date",
132
+ metavar="DATE",
133
+ default=today,
134
+ help="Reference today date in YYYY-MM-DD format (default: today).",
135
+ )
136
+ elif spec.input_kind == "date_range":
137
+ parser.add_argument(
138
+ "--start-date",
139
+ metavar="START_DATE",
140
+ default=today,
141
+ help="Start date in YYYY-MM-DD format (default: today).",
142
+ )
143
+ parser.add_argument(
144
+ "--end-date",
145
+ metavar="END_DATE",
146
+ default=today,
147
+ help="End date in YYYY-MM-DD format (default: today).",
148
+ )
149
+ if spec.command == "body-battery":
150
+ parser.add_argument(
151
+ "--chunks",
152
+ type=int,
153
+ default=1,
154
+ help="Number of min/max body battery pairs per day.",
155
+ )
156
+ elif spec.input_kind == "activities":
157
+ parser.add_argument(
158
+ "--start",
159
+ type=int,
160
+ default=0,
161
+ help="Zero-based result offset (default: 0).",
162
+ )
163
+ parser.add_argument(
164
+ "--limit",
165
+ type=int,
166
+ default=20,
167
+ metavar="COUNT",
168
+ help="Maximum number of activities to return (default: 20).",
169
+ )
170
+ parser.add_argument(
171
+ "--start-date",
172
+ metavar="DATE",
173
+ help="Optional start date in YYYY-MM-DD format.",
174
+ )
175
+ parser.add_argument(
176
+ "--end-date",
177
+ metavar="DATE",
178
+ help="Optional end date in YYYY-MM-DD format.",
179
+ )
180
+ parser.add_argument(
181
+ "--activity-type",
182
+ metavar="TYPE",
183
+ help="Optional activity type filter, such as running or hiking.",
184
+ )
185
+ elif spec.input_kind == "activity":
186
+ parser.add_argument(
187
+ "activity_id",
188
+ metavar="ACTIVITY_ID",
189
+ help=(
190
+ "Activity ID to download."
191
+ if spec.output_kind == "download"
192
+ else "Activity ID to look up."
193
+ ),
194
+ )
195
+ if spec.output_kind == "download":
196
+ parser.add_argument(
197
+ "-o",
198
+ "--output",
199
+ metavar="PATH",
200
+ help="Save ZIP to PATH (default: activity-ID.zip); '-' writes binary stdout.",
201
+ )
202
+ parser.add_argument("--force", action="store_true", help="Replace an existing output file.")
203
+
204
+
205
+ def main(argv: Sequence[str] | None = None) -> int:
206
+ """Authenticate once, then execute a command or open the interactive menu."""
207
+ args = build_parser().parse_args(argv)
208
+ interactive = args.command is None
209
+ refresh_only = args.command == TOKEN_REFRESH_COMMAND
210
+ if interactive and not _ensure_interactive_dependency():
211
+ return 1
212
+ _configure_logging(quiet=args.quiet or interactive)
213
+ color = False if args.no_color else None
214
+
215
+ try:
216
+ download = (
217
+ not interactive
218
+ and not refresh_only
219
+ and COMMANDS_BY_NAME[args.command].output_kind == "download"
220
+ )
221
+ if download and args.output == "-" and sys.stdout.isatty():
222
+ raise GarminPersonalApiError("Refusing to write ZIP bytes to a terminal; use -o PATH")
223
+ log_output = (
224
+ _route_logs_to_status(output=sys.stdout, color=color) if interactive else nullcontext()
225
+ )
226
+ with log_output:
227
+ api_key = resolve_api_key(args.api_key, key_file=args.api_key_file)
228
+ with PersonalApiAuthenticator(
229
+ timeout=args.timeout,
230
+ bearer_token_cache_file=args.bearer_token_cache_file,
231
+ ) as authenticator:
232
+ if interactive:
233
+ print_heading("Garmin Personal API", output=sys.stdout, width=56, color=color)
234
+ print_request_status(
235
+ "Checking bearer-token cache",
236
+ output=sys.stdout,
237
+ color=color,
238
+ )
239
+ token = authenticator.obtain_token(api_key, force_refresh=refresh_only)
240
+ if refresh_only:
241
+ expires_at = datetime.fromtimestamp(token.expires_at).astimezone()
242
+ print(
243
+ "Cached a new bearer token that expires at "
244
+ f"{expires_at.isoformat(timespec='seconds')}",
245
+ file=sys.stderr,
246
+ )
247
+ return 0
248
+ with GarminPersonalApiClient(token.access_token, timeout=args.timeout) as client:
249
+ if interactive:
250
+
251
+ def refresh_token() -> None:
252
+ refreshed = authenticator.obtain_token(api_key, force_refresh=True)
253
+ client.update_bearer_token(refreshed.access_token)
254
+
255
+ return run_interactive(
256
+ client,
257
+ color=color,
258
+ show_header=False,
259
+ refresh_token=refresh_token,
260
+ )
261
+ result = _execute_command(client, args)
262
+ if download:
263
+ if not isinstance(result, bytes):
264
+ raise GarminPersonalApiError("Expected binary download content")
265
+ if args.output == "-":
266
+ sys.stdout.buffer.write(result)
267
+ sys.stdout.buffer.flush()
268
+ else:
269
+ destination = save_download(
270
+ result,
271
+ Path(args.output or f"activity-{args.activity_id}.zip"),
272
+ overwrite=args.force,
273
+ )
274
+ print(f"Saved {destination} ({len(result)} bytes)", file=sys.stderr)
275
+ return 0
276
+ except (GarminPersonalApiError, requests.RequestException, OSError) as exc:
277
+ print(f"Error: {exc}", file=sys.stderr)
278
+ return 1
279
+
280
+ _print_json(result)
281
+ return 0
282
+
283
+
284
+ def _execute_command(client: GarminPersonalApiClient, args: argparse.Namespace) -> Any:
285
+ spec = COMMANDS_BY_NAME[args.command]
286
+ method = partial(spec.method, client)
287
+ if spec.input_kind == "date":
288
+ return method(args.date)
289
+ if spec.input_kind == "date_with_today":
290
+ return method(args.date, args.today_date)
291
+ if spec.input_kind == "date_range":
292
+ if spec.command == "body-battery":
293
+ return method(args.start_date, args.end_date, chunks=args.chunks)
294
+ return method(args.start_date, args.end_date)
295
+ if spec.input_kind == "activities":
296
+ return method(
297
+ start=args.start,
298
+ limit=args.limit,
299
+ start_date=args.start_date,
300
+ end_date=args.end_date,
301
+ activity_type=args.activity_type,
302
+ )
303
+ if spec.input_kind == "activity":
304
+ return method(args.activity_id)
305
+ return method()
306
+
307
+
308
+ def _ensure_interactive_dependency() -> bool:
309
+ try:
310
+ importlib.import_module("rich")
311
+ except ImportError:
312
+ print(
313
+ "Interactive mode requires the optional 'rich' dependency. "
314
+ "Install it with: pip install 'garmin-connect-personal-api-python[interactive]'",
315
+ file=sys.stderr,
316
+ )
317
+ return False
318
+ return True
319
+
320
+
321
+ def _print_json(value: Any) -> None:
322
+ print(json.dumps(value, indent=2, ensure_ascii=False, sort_keys=True))
323
+
324
+
325
+ def _configure_logging(*, quiet: bool) -> None:
326
+ """Configure this package's logger without replacing application handlers."""
327
+ level = logging.WARNING if quiet else logging.INFO
328
+ logging.getLogger(__package__).setLevel(level)
329
+ if not logging.root.handlers:
330
+ logging.basicConfig(level=level, format="%(levelname)s %(message)s")