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.
- garmin_connect_personal_api_python-1.0.0.dist-info/METADATA +400 -0
- garmin_connect_personal_api_python-1.0.0.dist-info/RECORD +20 -0
- garmin_connect_personal_api_python-1.0.0.dist-info/WHEEL +5 -0
- garmin_connect_personal_api_python-1.0.0.dist-info/entry_points.txt +2 -0
- garmin_connect_personal_api_python-1.0.0.dist-info/licenses/LICENSE +201 -0
- garmin_connect_personal_api_python-1.0.0.dist-info/top_level.txt +1 -0
- garmin_personal_api/__init__.py +35 -0
- garmin_personal_api/__main__.py +20 -0
- garmin_personal_api/_credentials.py +41 -0
- garmin_personal_api/_version.py +17 -0
- garmin_personal_api/api_keys.py +64 -0
- garmin_personal_api/auth.py +188 -0
- garmin_personal_api/cli.py +330 -0
- garmin_personal_api/client.py +288 -0
- garmin_personal_api/commands.py +119 -0
- garmin_personal_api/constants.py +25 -0
- garmin_personal_api/downloads.py +48 -0
- garmin_personal_api/errors.py +42 -0
- garmin_personal_api/interactive.py +686 -0
- garmin_personal_api/py.typed +0 -0
|
@@ -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
|