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,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")
|