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,686 @@
1
+ """Interactive reference UI for exploring the 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 json
20
+ import logging
21
+ import sys
22
+ from collections.abc import Callable, Iterator
23
+ from contextlib import contextmanager
24
+ from dataclasses import dataclass
25
+ from datetime import date, timedelta
26
+ from functools import partial
27
+ from pathlib import Path
28
+ from typing import Any, TextIO
29
+
30
+ import requests
31
+
32
+ from .client import GarminPersonalApiClient
33
+ from .commands import COMMANDS, TOKEN_REFRESH_COMMAND, CommandSpec
34
+ from .downloads import save_download
35
+ from .errors import GarminPersonalApiError
36
+
37
+ _ACTIVITY_PAGE_SIZE = 10
38
+ # Activity commands need an activity ID, so they are reached from the activity browser.
39
+ _MENU_COMMANDS = tuple(spec for spec in COMMANDS if spec.input_kind != "activity")
40
+ _MENU_COMMANDS_BY_NAME = {spec.command: spec for spec in _MENU_COMMANDS}
41
+ _INTERACTIVE_MENU_ITEMS = tuple(
42
+ (number, spec.label, spec.command) for number, spec in enumerate(_MENU_COMMANDS, start=1)
43
+ )
44
+ _INTERACTIVE_MENU_ITEMS += (
45
+ (len(_INTERACTIVE_MENU_ITEMS) + 1, "Reload Bearer Token", TOKEN_REFRESH_COMMAND),
46
+ )
47
+ _INTERACTIVE_MENU_ACTIONS = {str(number): command for number, _, command in _INTERACTIVE_MENU_ITEMS}
48
+
49
+
50
+ @dataclass
51
+ class _InteractiveDateState:
52
+ last_date: str
53
+ last_end_date: str
54
+
55
+
56
+ class _InteractiveBack(Exception):
57
+ """Return from the current interactive screen."""
58
+
59
+
60
+ class _InteractiveExit(Exception):
61
+ """Leave the interactive client from any screen."""
62
+
63
+
64
+ def run_interactive(
65
+ client: GarminPersonalApiClient,
66
+ *,
67
+ input_func: Callable[[str], str] | None = None,
68
+ output: TextIO | None = None,
69
+ error_output: TextIO | None = None,
70
+ color: bool | None = None,
71
+ show_header: bool = True,
72
+ refresh_token: Callable[[], None] | None = None,
73
+ ) -> int:
74
+ """Run the reference client's interactive menu."""
75
+ input_func = input if input_func is None else input_func
76
+ actual_output: TextIO = sys.stdout if output is None else output
77
+ actual_error_output: TextIO = sys.stderr if error_output is None else error_output
78
+ with _route_logs_to_status(output=actual_output, color=color):
79
+ return _run_interactive_session(
80
+ client,
81
+ input_func=input_func,
82
+ actual_output=actual_output,
83
+ actual_error_output=actual_error_output,
84
+ color=color,
85
+ show_header=show_header,
86
+ refresh_token=refresh_token,
87
+ )
88
+
89
+
90
+ def _run_interactive_session(
91
+ client: GarminPersonalApiClient,
92
+ *,
93
+ input_func: Callable[[str], str],
94
+ actual_output: TextIO,
95
+ actual_error_output: TextIO,
96
+ color: bool | None,
97
+ show_header: bool,
98
+ refresh_token: Callable[[], None] | None,
99
+ ) -> int:
100
+ today = date.today().isoformat()
101
+ date_state = _InteractiveDateState(last_date=today, last_end_date=today)
102
+
103
+ if show_header:
104
+ print_heading("Garmin Personal API", output=actual_output, width=56, color=color)
105
+
106
+ while True:
107
+ _print_interactive_menu(output=actual_output, color=color)
108
+ try:
109
+ choice = input_func("\nSelection: ").strip().lower()
110
+ if choice in {"q", "quit", "exit"}:
111
+ print("\nGoodbye.", file=actual_output)
112
+ return 0
113
+ if choice in {"b", "back"}:
114
+ print("Already at the main menu.", file=actual_error_output)
115
+ continue
116
+
117
+ command = _INTERACTIVE_MENU_ACTIONS.get(choice, choice)
118
+ if command == TOKEN_REFRESH_COMMAND:
119
+ if refresh_token is None:
120
+ print("Bearer token refresh is unavailable.", file=actual_error_output)
121
+ continue
122
+ refresh_token()
123
+ print("Bearer token refreshed.", file=actual_output)
124
+ continue
125
+ spec = _MENU_COMMANDS_BY_NAME.get(command)
126
+ if spec is None:
127
+ print(
128
+ f"Invalid selection. Choose 1 through {len(_INTERACTIVE_MENU_ITEMS)} or q.",
129
+ file=actual_error_output,
130
+ )
131
+ continue
132
+ if spec.input_kind == "activities":
133
+ _browse_activities(
134
+ client,
135
+ input_func=input_func,
136
+ output=actual_output,
137
+ error_output=actual_error_output,
138
+ color=color,
139
+ )
140
+ continue
141
+
142
+ result = _run_interactive_command(
143
+ client,
144
+ spec,
145
+ date_state=date_state,
146
+ input_func=input_func,
147
+ output=actual_output,
148
+ color=color,
149
+ )
150
+ except (EOFError, KeyboardInterrupt):
151
+ print("\nGoodbye.", file=actual_output)
152
+ return 0
153
+ except _InteractiveBack:
154
+ continue
155
+ except _InteractiveExit:
156
+ print("\nGoodbye.", file=actual_output)
157
+ return 0
158
+ except (GarminPersonalApiError, requests.RequestException) as exc:
159
+ print(f"Error: {exc}", file=actual_error_output)
160
+ continue
161
+
162
+ _print_json(result, output=actual_output, color=color)
163
+
164
+
165
+ def _run_interactive_command(
166
+ client: GarminPersonalApiClient,
167
+ spec: CommandSpec,
168
+ *,
169
+ date_state: _InteractiveDateState,
170
+ input_func: Callable[[str], str],
171
+ output: TextIO,
172
+ color: bool | None,
173
+ ) -> Any:
174
+ method = partial(spec.method, client)
175
+ if spec.input_kind == "date":
176
+ requested_date = _prompt_with_default(
177
+ input_func,
178
+ "Date",
179
+ date_state.last_date,
180
+ output=output,
181
+ )
182
+ date_state.last_date = requested_date
183
+ return _run_interactive_operation(
184
+ lambda: method(requested_date),
185
+ heading=f"{spec.label} | {requested_date}",
186
+ output=output,
187
+ color=color,
188
+ )
189
+
190
+ if spec.input_kind == "date_with_today":
191
+ requested_date = _prompt_with_default(
192
+ input_func,
193
+ "Date",
194
+ date_state.last_date,
195
+ output=output,
196
+ )
197
+ today_date = _prompt_with_default(
198
+ input_func,
199
+ "Today date",
200
+ date_state.last_end_date,
201
+ output=output,
202
+ )
203
+ date_state.last_date = requested_date
204
+ date_state.last_end_date = today_date
205
+ return _run_interactive_operation(
206
+ lambda: method(requested_date, today_date),
207
+ heading=f"{spec.label} | {requested_date} (today: {today_date})",
208
+ output=output,
209
+ color=color,
210
+ )
211
+
212
+ if spec.input_kind == "date_range":
213
+ start_date = _prompt_with_default(
214
+ input_func,
215
+ "Start date",
216
+ date_state.last_date,
217
+ output=output,
218
+ )
219
+ end_date = _prompt_with_default(
220
+ input_func,
221
+ "End date",
222
+ date_state.last_end_date,
223
+ output=output,
224
+ )
225
+ date_state.last_date = start_date
226
+ date_state.last_end_date = end_date
227
+ return _run_interactive_operation(
228
+ lambda: method(start_date, end_date),
229
+ heading=f"{spec.label} | {start_date} to {end_date}",
230
+ output=output,
231
+ color=color,
232
+ )
233
+
234
+ return _run_interactive_operation(method, heading=spec.label, output=output, color=color)
235
+
236
+
237
+ def _browse_activities(
238
+ client: GarminPersonalApiClient,
239
+ *,
240
+ input_func: Callable[[str], str],
241
+ output: TextIO,
242
+ error_output: TextIO,
243
+ color: bool | None,
244
+ ) -> None:
245
+ print_heading("Activities", output=output, width=56, color=color)
246
+ activities = _activity_records(client.search_activities(limit=30))
247
+ if not activities:
248
+ print("No activities found.", file=output)
249
+ return
250
+
251
+ page = 0
252
+ while True:
253
+ page_start = page * _ACTIVITY_PAGE_SIZE
254
+ page_end = min(page_start + _ACTIVITY_PAGE_SIZE, len(activities))
255
+ page_activities = activities[page_start:page_end]
256
+ _print_activity_table(
257
+ page_activities,
258
+ page_start=page_start,
259
+ total=len(activities),
260
+ output=output,
261
+ color=color,
262
+ )
263
+ navigation = ["number view activity", "b back"]
264
+ if page > 0:
265
+ navigation.append("p previous page")
266
+ if page_end < len(activities):
267
+ navigation.append("n next page")
268
+ navigation.append("q quit")
269
+ _print_navigation_line(navigation, output=output, color=color)
270
+
271
+ try:
272
+ choice = input_func("\nActivity: ").strip().lower()
273
+ except (EOFError, KeyboardInterrupt):
274
+ return
275
+ if choice in {"b", "back", "q", "quit", "exit"}:
276
+ if choice in {"q", "quit", "exit"}:
277
+ raise _InteractiveExit
278
+ return
279
+ if choice in {"n", "next"}:
280
+ if page_end < len(activities):
281
+ page += 1
282
+ else:
283
+ print("Already on the last page.", file=error_output)
284
+ continue
285
+ if choice in {"p", "previous", "prev"}:
286
+ if page > 0:
287
+ page -= 1
288
+ else:
289
+ print("Already on the first page.", file=error_output)
290
+ continue
291
+ try:
292
+ activity_index = page_start + int(choice) - 1
293
+ except ValueError:
294
+ print(
295
+ "Invalid selection. Choose an activity number, n, p, b, or q.",
296
+ file=error_output,
297
+ )
298
+ continue
299
+ if not page_start <= activity_index < page_end:
300
+ print(
301
+ "Invalid selection. Choose an activity number from this page.",
302
+ file=error_output,
303
+ )
304
+ continue
305
+ _show_activity(
306
+ client,
307
+ activities[activity_index],
308
+ input_func=input_func,
309
+ output=output,
310
+ error_output=error_output,
311
+ color=color,
312
+ )
313
+
314
+
315
+ def _show_activity(
316
+ client: GarminPersonalApiClient,
317
+ activity: dict[str, Any],
318
+ *,
319
+ input_func: Callable[[str], str],
320
+ output: TextIO,
321
+ error_output: TextIO,
322
+ color: bool | None,
323
+ ) -> None:
324
+ activity_id = activity.get("activityId")
325
+ if activity_id is None:
326
+ print("Selected activity does not contain an activityId.", file=error_output)
327
+ return
328
+
329
+ print_heading(
330
+ f"Activity | {_activity_date(activity)} | {_activity_title(activity)}",
331
+ output=output,
332
+ width=56,
333
+ color=color,
334
+ )
335
+ _print_json(activity, output=output, color=color)
336
+ actions = {
337
+ "1": ("Activity Details", client.get_activity),
338
+ "2": ("Activity Workouts", client.get_activity_workouts),
339
+ }
340
+
341
+ while True:
342
+ _print_navigation_line(
343
+ [
344
+ "1 activity details",
345
+ "2 activity workouts",
346
+ "3 download activity file",
347
+ "b back to activities",
348
+ "q quit",
349
+ ],
350
+ output=output,
351
+ color=color,
352
+ )
353
+ try:
354
+ choice = input_func("\nActivity option: ").strip().lower()
355
+ except (EOFError, KeyboardInterrupt):
356
+ return
357
+ if choice in {"b", "back", "q", "quit", "exit"}:
358
+ if choice in {"q", "quit", "exit"}:
359
+ raise _InteractiveExit
360
+ return
361
+ if choice == "3":
362
+ try:
363
+ entered = input_func(
364
+ f"Save as [default: activity-{activity_id}.zip] (b to cancel): "
365
+ ).strip()
366
+ if entered.lower() in {"q", "quit", "exit"}:
367
+ raise _InteractiveExit
368
+ if entered.lower() in {"b", "back"}:
369
+ continue
370
+ destination = Path(entered or f"activity-{activity_id}.zip").expanduser()
371
+ overwrite = False
372
+ if destination.exists() or destination.is_symlink():
373
+ answer = input_func(f"Replace {destination}? [y/N]: ").strip().lower()
374
+ if answer in {"q", "quit", "exit"}:
375
+ raise _InteractiveExit
376
+ if answer not in {"y", "yes"}:
377
+ print("Download cancelled.", file=output)
378
+ continue
379
+ overwrite = True
380
+ content = client.download_activity(activity_id)
381
+ saved = save_download(content, destination, overwrite=overwrite)
382
+ print(f"Saved {saved} ({len(content)} bytes)", file=output)
383
+ except (GarminPersonalApiError, requests.RequestException, OSError) as exc:
384
+ print(f"Error: {exc}", file=error_output)
385
+ except (EOFError, KeyboardInterrupt):
386
+ return
387
+ continue
388
+ if choice not in actions:
389
+ print("Invalid selection. Choose 1, 2, 3, b, or q.", file=error_output)
390
+ continue
391
+
392
+ title, method = actions[choice]
393
+ result = _run_interactive_operation(
394
+ lambda method=method: method(activity_id),
395
+ heading=title,
396
+ output=output,
397
+ color=color,
398
+ )
399
+ _print_json(result, output=output, color=color)
400
+
401
+
402
+ def print_heading(
403
+ title: str,
404
+ *,
405
+ output: TextIO,
406
+ width: int,
407
+ color: bool | None = None,
408
+ ) -> None:
409
+ separator = "=" * max(width, len(title))
410
+ console = _interactive_console(output=output, color=color)
411
+ console.print()
412
+ console.print(_rich_text(separator, style="dim"))
413
+ console.print(_rich_text(title, style="bold cyan"))
414
+ console.print(_rich_text(separator, style="dim"))
415
+
416
+
417
+ def _print_interactive_menu(*, output: TextIO, color: bool | None) -> None:
418
+ print_heading("Available data sets", output=output, width=56, color=color)
419
+ from rich.table import Table
420
+
421
+ table = Table(box=None, show_header=False, pad_edge=False, expand=False)
422
+ table.add_column("Selection")
423
+ for number, label, _ in _INTERACTIVE_MENU_ITEMS:
424
+ table.add_row(f"{number:<2} {label}")
425
+ table.add_row("q Quit", style="dim")
426
+ _interactive_console(output=output, color=color).print(table)
427
+
428
+
429
+ def _interactive_console(*, output: TextIO, color: bool | None) -> Any:
430
+ from rich.console import Console
431
+
432
+ return Console(
433
+ file=output,
434
+ force_terminal=color,
435
+ no_color=True if color is False else None,
436
+ highlight=False,
437
+ )
438
+
439
+
440
+ def _rich_text(value: str, *, style: str) -> Any:
441
+ from rich.text import Text
442
+
443
+ return Text(value, style=style)
444
+
445
+
446
+ def print_request_status(
447
+ message: str,
448
+ *,
449
+ output: TextIO,
450
+ color: bool | None,
451
+ ) -> None:
452
+ _interactive_console(output=output, color=color).print(
453
+ _rich_text(f"> {message}", style="dim"),
454
+ soft_wrap=True,
455
+ )
456
+
457
+
458
+ def _run_interactive_operation(
459
+ operation: Callable[[], Any],
460
+ *,
461
+ heading: str | None = None,
462
+ output: TextIO,
463
+ color: bool | None,
464
+ ) -> Any:
465
+ if heading is not None:
466
+ print_heading(heading, output=output, width=56, color=color)
467
+ return operation()
468
+
469
+
470
+ class _StatusLineLogHandler(logging.Handler):
471
+ """Render package log records as interactive status lines."""
472
+
473
+ def __init__(self, *, output: TextIO, color: bool | None) -> None:
474
+ super().__init__(logging.INFO)
475
+ self._output = output
476
+ self._color = color
477
+
478
+ def emit(self, record: logging.LogRecord) -> None:
479
+ print_request_status(record.getMessage(), output=self._output, color=self._color)
480
+
481
+
482
+ @contextmanager
483
+ def _route_logs_to_status(*, output: TextIO, color: bool | None) -> Iterator[None]:
484
+ logger = logging.getLogger(__package__)
485
+ if any(isinstance(existing, _StatusLineLogHandler) for existing in logger.handlers):
486
+ yield
487
+ return
488
+
489
+ handler = _StatusLineLogHandler(output=output, color=color)
490
+ previous_level, previous_propagate = logger.level, logger.propagate
491
+ logger.addHandler(handler)
492
+ logger.setLevel(logging.INFO)
493
+ logger.propagate = False
494
+ try:
495
+ yield
496
+ finally:
497
+ logger.removeHandler(handler)
498
+ logger.setLevel(previous_level)
499
+ logger.propagate = previous_propagate
500
+
501
+
502
+ def _prompt_with_default(
503
+ input_func: Callable[[str], str],
504
+ label: str,
505
+ default: str,
506
+ *,
507
+ output: TextIO | None = None,
508
+ ) -> str:
509
+ actual_output: TextIO = sys.stdout if output is None else output
510
+ if input_func is input and _can_use_arrow_date_prompt(actual_output):
511
+ return _prompt_date_with_arrows(label, default, output=actual_output)
512
+ entered = input_func(f"{label} [default: {default}]: ").strip()
513
+ if entered.lower() in {"q", "quit", "exit"}:
514
+ raise _InteractiveExit
515
+ if entered.lower() in {"b", "back"}:
516
+ raise _InteractiveBack
517
+ return entered or default
518
+
519
+
520
+ def _can_use_arrow_date_prompt(output: TextIO) -> bool:
521
+ # TODO: Add a Windows key-reading backend for cross-platform arrow navigation.
522
+ if sys.platform != "darwin":
523
+ return False
524
+ try:
525
+ return bool(sys.stdin.isatty() and output.isatty())
526
+ except (AttributeError, OSError):
527
+ return False
528
+
529
+
530
+ def _prompt_date_with_arrows(
531
+ label: str,
532
+ default: str,
533
+ *,
534
+ output: TextIO,
535
+ terminal_fd: int | None = None,
536
+ ) -> str:
537
+ import termios
538
+ import tty
539
+
540
+ prompt = f"{label} [up/down changes the date]: "
541
+ entered = ""
542
+ displayed = default
543
+ default_date = _try_parse_date(default)
544
+ file_descriptor = sys.stdin.fileno() if terminal_fd is None else terminal_fd
545
+ original_terminal = termios.tcgetattr(file_descriptor)
546
+ try:
547
+ tty.setraw(file_descriptor)
548
+ _render_date_prompt(output, prompt, displayed)
549
+ while True:
550
+ key = _read_terminal_key()
551
+ if key in {"\r", "\n"}:
552
+ output.write("\r\n")
553
+ output.flush()
554
+ return entered or default
555
+ if key == "\x03":
556
+ raise KeyboardInterrupt
557
+ if key == "\x04":
558
+ raise EOFError
559
+ if key.lower() == "q":
560
+ raise _InteractiveExit
561
+ if key.lower() == "b":
562
+ raise _InteractiveBack
563
+ if key in {"\x08", "\x7f"}:
564
+ entered = entered[:-1]
565
+ elif key == "\x15":
566
+ entered = ""
567
+ elif key in {"\x1b[A", "\x1bOA", "\x1b[B", "\x1bOB"}:
568
+ candidate = _try_parse_date(entered) or default_date
569
+ if candidate is not None:
570
+ change = 1 if key in {"\x1b[A", "\x1bOA"} else -1
571
+ entered = (candidate + timedelta(days=change)).isoformat()
572
+ elif len(key) == 1 and (key.isdigit() or key == "-"):
573
+ entered += key
574
+ else:
575
+ continue
576
+ displayed = entered or default
577
+ _render_date_prompt(output, prompt, displayed)
578
+ finally:
579
+ termios.tcsetattr(file_descriptor, termios.TCSADRAIN, original_terminal)
580
+
581
+
582
+ def _read_terminal_key() -> str:
583
+ key = sys.stdin.read(1)
584
+ if key != "\x1b":
585
+ return key
586
+ next_key = sys.stdin.read(1)
587
+ if next_key not in {"[", "O"}:
588
+ return key + next_key
589
+ return key + next_key + sys.stdin.read(1)
590
+
591
+
592
+ def _render_date_prompt(output: TextIO, prompt: str, value: str) -> None:
593
+ output.write(f"\r\x1b[2K{prompt}{value}")
594
+ output.flush()
595
+
596
+
597
+ def _try_parse_date(value: str) -> date | None:
598
+ try:
599
+ return date.fromisoformat(value)
600
+ except ValueError:
601
+ return None
602
+
603
+
604
+ def _print_activity_table(
605
+ activities: list[dict[str, Any]],
606
+ *,
607
+ page_start: int,
608
+ total: int,
609
+ output: TextIO,
610
+ color: bool | None,
611
+ ) -> None:
612
+ from rich.table import Table
613
+
614
+ page_end = page_start + len(activities)
615
+ print_heading(
616
+ f"Activities {page_start + 1}-{page_end} of {total}",
617
+ output=output,
618
+ width=56,
619
+ color=color,
620
+ )
621
+ table = Table(box=None, pad_edge=False, show_lines=False)
622
+ table.add_column("#", justify="right", style="bold")
623
+ table.add_column("Activity")
624
+ for index, activity in enumerate(activities, start=1):
625
+ table.add_row(str(index), f"{_activity_date(activity)} | {_activity_title(activity)}")
626
+ _interactive_console(output=output, color=color).print(table)
627
+
628
+
629
+ def _print_navigation_line(
630
+ actions: list[str],
631
+ *,
632
+ output: TextIO,
633
+ color: bool | None,
634
+ ) -> None:
635
+ _interactive_console(output=output, color=color).print(
636
+ _rich_text(" " + " ".join(actions), style="dim")
637
+ )
638
+
639
+
640
+ def _activity_records(value: Any) -> list[dict[str, Any]]:
641
+ if isinstance(value, list):
642
+ return [record for record in value if isinstance(record, dict)]
643
+ if isinstance(value, dict):
644
+ for key in ("activities", "activityList"):
645
+ nested = value.get(key)
646
+ if isinstance(nested, list):
647
+ return [record for record in nested if isinstance(record, dict)]
648
+ return []
649
+
650
+
651
+ def _activity_date(activity: dict[str, Any]) -> str:
652
+ for key in ("startTimeLocal", "startTimeGMT", "activityDate", "date"):
653
+ value = activity.get(key)
654
+ if value is not None and str(value).strip():
655
+ return str(value)
656
+ return "Unknown date"
657
+
658
+
659
+ def _activity_title(activity: dict[str, Any]) -> str:
660
+ for key in ("activityName", "title", "name"):
661
+ value = activity.get(key)
662
+ if value is not None and str(value).strip():
663
+ return str(value)
664
+ return "Untitled activity"
665
+
666
+
667
+ def _print_json(
668
+ value: Any,
669
+ *,
670
+ output: TextIO | None = None,
671
+ color: bool | None = None,
672
+ ) -> None:
673
+ actual_output: TextIO = sys.stdout if output is None else output
674
+ serialized = json.dumps(value, indent=2, ensure_ascii=False, sort_keys=True)
675
+ from rich.syntax import Syntax
676
+
677
+ console = _interactive_console(output=actual_output, color=color)
678
+ console.print(
679
+ Syntax(
680
+ serialized,
681
+ "json",
682
+ theme="ansi_dark",
683
+ background_color="default",
684
+ word_wrap=False,
685
+ )
686
+ )
File without changes