arccos-api 0.5.0__tar.gz → 0.5.2__tar.gz
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.
- {arccos_api-0.5.0 → arccos_api-0.5.2}/MANIFEST.in +1 -0
- {arccos_api-0.5.0/arccos_api.egg-info → arccos_api-0.5.2}/PKG-INFO +16 -7
- {arccos_api-0.5.0 → arccos_api-0.5.2}/README.md +11 -5
- {arccos_api-0.5.0 → arccos_api-0.5.2}/arccos/__init__.py +4 -0
- {arccos_api-0.5.0 → arccos_api-0.5.2}/arccos/_http.py +67 -11
- arccos_api-0.5.2/arccos/_validation.py +36 -0
- {arccos_api-0.5.0 → arccos_api-0.5.2}/arccos/auth.py +134 -14
- {arccos_api-0.5.0 → arccos_api-0.5.2}/arccos/cli/__init__.py +2 -1
- {arccos_api-0.5.0 → arccos_api-0.5.2}/arccos/cli/_helpers.py +8 -1
- arccos_api-0.5.2/arccos/cli/achievements.py +267 -0
- arccos_api-0.5.2/arccos/cli/analytics.py +290 -0
- arccos_api-0.5.2/arccos/cli/auth.py +145 -0
- arccos_api-0.5.2/arccos/cli/clubs.py +259 -0
- arccos_api-0.5.2/arccos/cli/courses.py +144 -0
- arccos_api-0.5.2/arccos/cli/handicap.py +92 -0
- arccos_api-0.5.2/arccos/cli/presentation.py +222 -0
- arccos_api-0.5.2/arccos/cli/stats.py +36 -0
- {arccos_api-0.5.0 → arccos_api-0.5.2}/arccos/client.py +28 -8
- {arccos_api-0.5.0 → arccos_api-0.5.2}/arccos/exceptions.py +8 -0
- {arccos_api-0.5.0 → arccos_api-0.5.2}/arccos/resources/clubs.py +20 -5
- {arccos_api-0.5.0 → arccos_api-0.5.2}/arccos/resources/courses.py +30 -5
- {arccos_api-0.5.0 → arccos_api-0.5.2}/arccos/resources/handicap.py +9 -3
- {arccos_api-0.5.0 → arccos_api-0.5.2}/arccos/resources/rounds.py +30 -19
- {arccos_api-0.5.0 → arccos_api-0.5.2}/arccos/resources/stats.py +16 -6
- {arccos_api-0.5.0 → arccos_api-0.5.2}/arccos/types.py +51 -0
- {arccos_api-0.5.0 → arccos_api-0.5.2/arccos_api.egg-info}/PKG-INFO +16 -7
- {arccos_api-0.5.0 → arccos_api-0.5.2}/arccos_api.egg-info/SOURCES.txt +14 -1
- arccos_api-0.5.2/arccos_api.egg-info/dependency_links.txt +1 -0
- {arccos_api-0.5.0 → arccos_api-0.5.2}/arccos_api.egg-info/requires.txt +5 -1
- {arccos_api-0.5.0 → arccos_api-0.5.2}/pyproject.toml +7 -2
- arccos_api-0.5.2/tests/fixtures/contracts/courses_played.json +11 -0
- arccos_api-0.5.2/tests/fixtures/contracts/personal_bests.json +13 -0
- arccos_api-0.5.2/tests/fixtures/contracts/rounds.json +15 -0
- arccos_api-0.5.2/tests/fixtures/contracts/smart_distances.json +9 -0
- {arccos_api-0.5.0 → arccos_api-0.5.2}/tests/test_auth.py +68 -1
- {arccos_api-0.5.0 → arccos_api-0.5.2}/tests/test_cli.py +10 -0
- {arccos_api-0.5.0 → arccos_api-0.5.2}/tests/test_client.py +16 -7
- arccos_api-0.5.2/tests/test_contracts.py +71 -0
- {arccos_api-0.5.0 → arccos_api-0.5.2}/tests/test_http.py +55 -2
- {arccos_api-0.5.0 → arccos_api-0.5.2}/tests/test_resources.py +31 -0
- {arccos_api-0.5.0 → arccos_api-0.5.2}/tests/test_rounds.py +24 -4
- arccos_api-0.5.0/arccos/cli/auth.py +0 -85
- arccos_api-0.5.0/arccos/cli/stats.py +0 -1186
- {arccos_api-0.5.0 → arccos_api-0.5.2}/LICENSE +0 -0
- {arccos_api-0.5.0 → arccos_api-0.5.2}/arccos/__main__.py +0 -0
- {arccos_api-0.5.0 → arccos_api-0.5.2}/arccos/cli/rounds.py +0 -0
- /arccos_api-0.5.0/arccos_api.egg-info/dependency_links.txt → /arccos_api-0.5.2/arccos/py.typed +0 -0
- {arccos_api-0.5.0 → arccos_api-0.5.2}/arccos/resources/__init__.py +0 -0
- {arccos_api-0.5.0 → arccos_api-0.5.2}/arccos_api.egg-info/entry_points.txt +0 -0
- {arccos_api-0.5.0 → arccos_api-0.5.2}/arccos_api.egg-info/top_level.txt +0 -0
- {arccos_api-0.5.0 → arccos_api-0.5.2}/setup.cfg +0 -0
- {arccos_api-0.5.0 → arccos_api-0.5.2}/tests/test_exceptions.py +0 -0
- {arccos_api-0.5.0 → arccos_api-0.5.2}/tests/test_security.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: arccos-api
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.2
|
|
4
4
|
Summary: Unofficial Python CLI and client library for the Arccos Golf API
|
|
5
5
|
Author: Paul Frederiksen
|
|
6
6
|
License-Expression: MIT
|
|
@@ -25,13 +25,16 @@ Description-Content-Type: text/markdown
|
|
|
25
25
|
License-File: LICENSE
|
|
26
26
|
Requires-Dist: requests<3,>=2.31
|
|
27
27
|
Requires-Dist: click<9,>=8.1
|
|
28
|
-
Requires-Dist: rich<
|
|
28
|
+
Requires-Dist: rich<16,>=13.0
|
|
29
|
+
Provides-Extra: keyring
|
|
30
|
+
Requires-Dist: keyring<27,>=25; extra == "keyring"
|
|
29
31
|
Provides-Extra: dev
|
|
30
32
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
31
33
|
Requires-Dist: pytest-cov; extra == "dev"
|
|
32
34
|
Requires-Dist: ruff; extra == "dev"
|
|
33
35
|
Requires-Dist: mypy; extra == "dev"
|
|
34
36
|
Requires-Dist: types-requests; extra == "dev"
|
|
37
|
+
Requires-Dist: openapi-spec-validator<0.8,>=0.7; extra == "dev"
|
|
35
38
|
Requires-Dist: build>=0.10.0; extra == "dev"
|
|
36
39
|
Requires-Dist: twine>=4.0.0; extra == "dev"
|
|
37
40
|
Dynamic: license-file
|
|
@@ -91,7 +94,7 @@ arccos clubs # smart club distances
|
|
|
91
94
|
|
|
92
95
|
| Command | Description |
|
|
93
96
|
|---------|-------------|
|
|
94
|
-
| `arccos login` | Authenticate and cache credentials
|
|
97
|
+
| `arccos login` | Authenticate and securely cache credentials |
|
|
95
98
|
| `arccos rounds` | List recent rounds with date, score, +/-, course name |
|
|
96
99
|
| `arccos round <id>` | Hole-by-hole detail (score, putts, FIR, GIR) with totals |
|
|
97
100
|
| `arccos handicap` | Handicap breakdown by category (overall, driving, approach, etc.) |
|
|
@@ -113,6 +116,7 @@ arccos clubs # smart club distances
|
|
|
113
116
|
| `arccos export` | Export rounds to JSON, CSV, or NDJSON (`--detail` for hole data) |
|
|
114
117
|
| `arccos completions <shell>` | Generate bash/zsh/fish completion script |
|
|
115
118
|
| `arccos logout` | Clear cached credentials |
|
|
119
|
+
| `arccos doctor` | Check configuration, credentials, and API connectivity |
|
|
116
120
|
|
|
117
121
|
Every command supports `--json` for raw JSON output and `--help` for usage info.
|
|
118
122
|
|
|
@@ -260,6 +264,8 @@ Other environment variables:
|
|
|
260
264
|
```bash
|
|
261
265
|
export ARCCOS_TIMEOUT=30 # request timeout in seconds (default: 15)
|
|
262
266
|
export ARCCOS_LOG_LEVEL=DEBUG # enable debug logging (DEBUG, INFO, WARNING)
|
|
267
|
+
export ARCCOS_PROFILE=work # select a named credential profile
|
|
268
|
+
export ARCCOS_KEYRING=true # use the operating-system keyring
|
|
263
269
|
```
|
|
264
270
|
|
|
265
271
|
> **Security note:** Environment variables may be visible in process listings (`ps`) and shell history. Prefer `arccos login` for interactive use.
|
|
@@ -270,7 +276,7 @@ export ARCCOS_LOG_LEVEL=DEBUG # enable debug logging (DEBUG, INFO, WARNING)
|
|
|
270
276
|
from arccos import ArccosClient
|
|
271
277
|
|
|
272
278
|
client = ArccosClient(email="you@example.com", password="your_password")
|
|
273
|
-
# Credentials cached in
|
|
279
|
+
# Credentials are cached in the platform config directory and refresh silently.
|
|
274
280
|
|
|
275
281
|
# Rounds
|
|
276
282
|
rounds = client.rounds.list(limit=10)
|
|
@@ -432,18 +438,21 @@ python3 -m venv .venv
|
|
|
432
438
|
source .venv/bin/activate
|
|
433
439
|
pip install -e ".[dev]"
|
|
434
440
|
|
|
435
|
-
pytest # tests with coverage (87%+,
|
|
441
|
+
pytest # tests with coverage (87%+, 203 tests)
|
|
436
442
|
ruff check . # lint
|
|
437
443
|
mypy arccos/ # type check
|
|
444
|
+
openapi-spec-validator docs/openapi.yaml
|
|
438
445
|
```
|
|
439
446
|
|
|
440
447
|
Coverage report: `htmlcov/index.html`. Minimum threshold: 80% (configured in `pyproject.toml`).
|
|
441
|
-
|
|
448
|
+
The suite includes sanitized response-contract fixtures so upstream schema drift
|
|
449
|
+
is caught without making live API calls.
|
|
442
450
|
|
|
443
451
|
## Security
|
|
444
452
|
|
|
445
453
|
- Data access is read-only: the client only sends `GET` requests to `api.arccosgolf.com`. The only `POST` requests are the required login/token-refresh calls to `authentication.arccosgolf.com`; no golf data is created, changed, or deleted.
|
|
446
|
-
- Credentials are cached in
|
|
454
|
+
- Credentials are cached in the platform configuration directory (mode `0600` — owner-only read/write). Existing `~/.arccos_creds.json` files migrate automatically. Named profiles are available through `ARCCOS_PROFILE` or `--profile`.
|
|
455
|
+
- Optional OS-keyring storage is available with `pip install 'arccos-api[keyring]'` and `ARCCOS_KEYRING=true` (or `--keyring` for login/logout/doctor).
|
|
447
456
|
- All API communication uses HTTPS with TLS certificate verification.
|
|
448
457
|
- `arccos logout` removes the local credential file but does **not** revoke server-side tokens. If you suspect your credentials were compromised, change your Arccos password.
|
|
449
458
|
- The export command restricts output paths to your home directory or current working directory.
|
|
@@ -53,7 +53,7 @@ arccos clubs # smart club distances
|
|
|
53
53
|
|
|
54
54
|
| Command | Description |
|
|
55
55
|
|---------|-------------|
|
|
56
|
-
| `arccos login` | Authenticate and cache credentials
|
|
56
|
+
| `arccos login` | Authenticate and securely cache credentials |
|
|
57
57
|
| `arccos rounds` | List recent rounds with date, score, +/-, course name |
|
|
58
58
|
| `arccos round <id>` | Hole-by-hole detail (score, putts, FIR, GIR) with totals |
|
|
59
59
|
| `arccos handicap` | Handicap breakdown by category (overall, driving, approach, etc.) |
|
|
@@ -75,6 +75,7 @@ arccos clubs # smart club distances
|
|
|
75
75
|
| `arccos export` | Export rounds to JSON, CSV, or NDJSON (`--detail` for hole data) |
|
|
76
76
|
| `arccos completions <shell>` | Generate bash/zsh/fish completion script |
|
|
77
77
|
| `arccos logout` | Clear cached credentials |
|
|
78
|
+
| `arccos doctor` | Check configuration, credentials, and API connectivity |
|
|
78
79
|
|
|
79
80
|
Every command supports `--json` for raw JSON output and `--help` for usage info.
|
|
80
81
|
|
|
@@ -222,6 +223,8 @@ Other environment variables:
|
|
|
222
223
|
```bash
|
|
223
224
|
export ARCCOS_TIMEOUT=30 # request timeout in seconds (default: 15)
|
|
224
225
|
export ARCCOS_LOG_LEVEL=DEBUG # enable debug logging (DEBUG, INFO, WARNING)
|
|
226
|
+
export ARCCOS_PROFILE=work # select a named credential profile
|
|
227
|
+
export ARCCOS_KEYRING=true # use the operating-system keyring
|
|
225
228
|
```
|
|
226
229
|
|
|
227
230
|
> **Security note:** Environment variables may be visible in process listings (`ps`) and shell history. Prefer `arccos login` for interactive use.
|
|
@@ -232,7 +235,7 @@ export ARCCOS_LOG_LEVEL=DEBUG # enable debug logging (DEBUG, INFO, WARNING)
|
|
|
232
235
|
from arccos import ArccosClient
|
|
233
236
|
|
|
234
237
|
client = ArccosClient(email="you@example.com", password="your_password")
|
|
235
|
-
# Credentials cached in
|
|
238
|
+
# Credentials are cached in the platform config directory and refresh silently.
|
|
236
239
|
|
|
237
240
|
# Rounds
|
|
238
241
|
rounds = client.rounds.list(limit=10)
|
|
@@ -394,18 +397,21 @@ python3 -m venv .venv
|
|
|
394
397
|
source .venv/bin/activate
|
|
395
398
|
pip install -e ".[dev]"
|
|
396
399
|
|
|
397
|
-
pytest # tests with coverage (87%+,
|
|
400
|
+
pytest # tests with coverage (87%+, 203 tests)
|
|
398
401
|
ruff check . # lint
|
|
399
402
|
mypy arccos/ # type check
|
|
403
|
+
openapi-spec-validator docs/openapi.yaml
|
|
400
404
|
```
|
|
401
405
|
|
|
402
406
|
Coverage report: `htmlcov/index.html`. Minimum threshold: 80% (configured in `pyproject.toml`).
|
|
403
|
-
|
|
407
|
+
The suite includes sanitized response-contract fixtures so upstream schema drift
|
|
408
|
+
is caught without making live API calls.
|
|
404
409
|
|
|
405
410
|
## Security
|
|
406
411
|
|
|
407
412
|
- Data access is read-only: the client only sends `GET` requests to `api.arccosgolf.com`. The only `POST` requests are the required login/token-refresh calls to `authentication.arccosgolf.com`; no golf data is created, changed, or deleted.
|
|
408
|
-
- Credentials are cached in
|
|
413
|
+
- Credentials are cached in the platform configuration directory (mode `0600` — owner-only read/write). Existing `~/.arccos_creds.json` files migrate automatically. Named profiles are available through `ARCCOS_PROFILE` or `--profile`.
|
|
414
|
+
- Optional OS-keyring storage is available with `pip install 'arccos-api[keyring]'` and `ARCCOS_KEYRING=true` (or `--keyring` for login/logout/doctor).
|
|
409
415
|
- All API communication uses HTTPS with TLS certificate verification.
|
|
410
416
|
- `arccos logout` removes the local credential file but does **not** revoke server-side tokens. If you suspect your credentials were compromised, change your Arccos password.
|
|
411
417
|
- The export command restricts output paths to your home directory or current working directory.
|
|
@@ -18,10 +18,12 @@ from .auth import ArccosAuth
|
|
|
18
18
|
from .client import ArccosClient
|
|
19
19
|
from .exceptions import (
|
|
20
20
|
ArccosAuthError,
|
|
21
|
+
ArccosConnectionError,
|
|
21
22
|
ArccosError,
|
|
22
23
|
ArccosForbiddenError,
|
|
23
24
|
ArccosNotFoundError,
|
|
24
25
|
ArccosRateLimitError,
|
|
26
|
+
ArccosResponseError,
|
|
25
27
|
)
|
|
26
28
|
|
|
27
29
|
|
|
@@ -70,7 +72,9 @@ __all__ = [
|
|
|
70
72
|
"ArccosAuth",
|
|
71
73
|
"ArccosError",
|
|
72
74
|
"ArccosAuthError",
|
|
75
|
+
"ArccosConnectionError",
|
|
73
76
|
"ArccosForbiddenError",
|
|
74
77
|
"ArccosNotFoundError",
|
|
75
78
|
"ArccosRateLimitError",
|
|
79
|
+
"ArccosResponseError",
|
|
76
80
|
]
|
|
@@ -11,12 +11,18 @@ import contextlib
|
|
|
11
11
|
import logging
|
|
12
12
|
import os
|
|
13
13
|
import time
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
from email.utils import parsedate_to_datetime
|
|
14
16
|
from typing import Any
|
|
15
17
|
|
|
16
18
|
import requests
|
|
17
19
|
|
|
18
20
|
from .auth import ArccosAuth, Credentials
|
|
19
|
-
from .exceptions import
|
|
21
|
+
from .exceptions import (
|
|
22
|
+
ArccosConnectionError,
|
|
23
|
+
ArccosResponseError,
|
|
24
|
+
raise_for_status,
|
|
25
|
+
)
|
|
20
26
|
|
|
21
27
|
logger = logging.getLogger(__name__)
|
|
22
28
|
|
|
@@ -24,6 +30,7 @@ API_BASE = "https://api.arccosgolf.com"
|
|
|
24
30
|
DEFAULT_TIMEOUT = 15
|
|
25
31
|
MAX_RETRIES = 3
|
|
26
32
|
RETRY_BACKOFF = 1.0 # seconds; doubles each retry
|
|
33
|
+
MAX_RETRY_DELAY = 60.0
|
|
27
34
|
|
|
28
35
|
|
|
29
36
|
class HttpClient:
|
|
@@ -38,10 +45,15 @@ class HttpClient:
|
|
|
38
45
|
- Raises typed :mod:`arccos.exceptions` for non-2xx responses
|
|
39
46
|
"""
|
|
40
47
|
|
|
41
|
-
def __init__(
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
auth: ArccosAuth,
|
|
51
|
+
creds: Credentials,
|
|
52
|
+
session: requests.Session | None = None,
|
|
53
|
+
):
|
|
42
54
|
self._auth = auth
|
|
43
55
|
self._creds = creds
|
|
44
|
-
self._session = requests.Session()
|
|
56
|
+
self._session = session or requests.Session()
|
|
45
57
|
self._session.headers.update({
|
|
46
58
|
"Content-Type": "application/json",
|
|
47
59
|
"Accept": "application/json",
|
|
@@ -74,19 +86,29 @@ class HttpClient:
|
|
|
74
86
|
"""
|
|
75
87
|
return self._request(path, params=params)
|
|
76
88
|
|
|
89
|
+
def close(self) -> None:
|
|
90
|
+
"""Close the underlying HTTP session."""
|
|
91
|
+
self._session.close()
|
|
92
|
+
|
|
93
|
+
def __enter__(self) -> HttpClient:
|
|
94
|
+
return self
|
|
95
|
+
|
|
96
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
97
|
+
self.close()
|
|
98
|
+
|
|
77
99
|
# ------------------------------------------------------------------
|
|
78
100
|
# Private
|
|
79
101
|
# ------------------------------------------------------------------
|
|
80
102
|
|
|
81
103
|
def _request(self, path: str, **kwargs) -> Any:
|
|
82
104
|
self._creds = self._auth.ensure_fresh(self._creds)
|
|
83
|
-
headers = {"Authorization": self._auth.auth_header(self._creds)}
|
|
84
|
-
|
|
85
105
|
url = f"{API_BASE}{path}"
|
|
86
106
|
logger.debug("GET %s", url)
|
|
87
107
|
|
|
88
108
|
last_exc: Exception | None = None
|
|
109
|
+
retried_auth = False
|
|
89
110
|
for attempt in range(MAX_RETRIES):
|
|
111
|
+
headers = {"Authorization": self._auth.auth_header(self._creds)}
|
|
90
112
|
try:
|
|
91
113
|
# Deliberately call Session.get rather than the generic request
|
|
92
114
|
# method so this client cannot issue mutating data-API requests.
|
|
@@ -96,32 +118,54 @@ class HttpClient:
|
|
|
96
118
|
)
|
|
97
119
|
except requests.exceptions.Timeout as exc:
|
|
98
120
|
last_exc = exc
|
|
99
|
-
|
|
121
|
+
if attempt < MAX_RETRIES - 1:
|
|
122
|
+
self._backoff(attempt)
|
|
100
123
|
continue
|
|
101
124
|
except requests.exceptions.ConnectionError as exc:
|
|
102
125
|
last_exc = exc
|
|
103
|
-
|
|
126
|
+
if attempt < MAX_RETRIES - 1:
|
|
127
|
+
self._backoff(attempt)
|
|
104
128
|
continue
|
|
105
129
|
|
|
106
130
|
if resp.ok:
|
|
107
131
|
if resp.content:
|
|
108
|
-
|
|
132
|
+
try:
|
|
133
|
+
return resp.json()
|
|
134
|
+
except (requests.exceptions.JSONDecodeError, ValueError) as exc:
|
|
135
|
+
raise ArccosResponseError(
|
|
136
|
+
f"Arccos returned invalid JSON for GET {path}.",
|
|
137
|
+
status_code=resp.status_code,
|
|
138
|
+
) from exc
|
|
109
139
|
return {}
|
|
110
140
|
|
|
141
|
+
# A JWT can be rejected before its local expiry time. Refresh once
|
|
142
|
+
# and retry with a newly constructed Authorization header.
|
|
143
|
+
if resp.status_code == 401 and not retried_auth:
|
|
144
|
+
self._creds = self._auth.refresh(self._creds)
|
|
145
|
+
retried_auth = True
|
|
146
|
+
continue
|
|
147
|
+
|
|
111
148
|
# Retry on 429 and 5xx; raise immediately on other errors
|
|
112
149
|
if resp.status_code == 429 or resp.status_code >= 500:
|
|
113
150
|
try:
|
|
114
151
|
raise_for_status(resp)
|
|
115
152
|
except Exception as exc:
|
|
116
153
|
last_exc = exc
|
|
117
|
-
|
|
154
|
+
if attempt < MAX_RETRIES - 1:
|
|
155
|
+
self._backoff(attempt, resp)
|
|
118
156
|
continue
|
|
119
157
|
|
|
120
158
|
# Non-retryable error (4xx other than 429)
|
|
121
159
|
raise_for_status(resp)
|
|
122
160
|
|
|
123
161
|
# Exhausted retries
|
|
124
|
-
|
|
162
|
+
if isinstance(last_exc, requests.exceptions.RequestException):
|
|
163
|
+
raise ArccosConnectionError(
|
|
164
|
+
f"Unable to reach Arccos after {MAX_RETRIES} attempts."
|
|
165
|
+
) from last_exc
|
|
166
|
+
if last_exc is not None:
|
|
167
|
+
raise last_exc
|
|
168
|
+
raise ArccosConnectionError("Arccos request failed without a response.")
|
|
125
169
|
|
|
126
170
|
@staticmethod
|
|
127
171
|
def _backoff(attempt: int, resp: requests.Response | None = None) -> None:
|
|
@@ -131,6 +175,18 @@ class HttpClient:
|
|
|
131
175
|
retry_after = resp.headers.get("Retry-After")
|
|
132
176
|
if retry_after:
|
|
133
177
|
with contextlib.suppress(ValueError, TypeError):
|
|
134
|
-
delay = max(
|
|
178
|
+
delay = max(HttpClient._retry_after_seconds(retry_after), delay)
|
|
179
|
+
delay = min(delay, MAX_RETRY_DELAY)
|
|
135
180
|
logger.warning("Retrying in %.1fs (attempt %d/%d)", delay, attempt + 1, MAX_RETRIES)
|
|
136
181
|
time.sleep(delay)
|
|
182
|
+
|
|
183
|
+
@staticmethod
|
|
184
|
+
def _retry_after_seconds(value: str) -> float:
|
|
185
|
+
"""Parse Retry-After seconds or an HTTP date."""
|
|
186
|
+
try:
|
|
187
|
+
return max(float(value), 0.0)
|
|
188
|
+
except ValueError:
|
|
189
|
+
parsed = parsedate_to_datetime(value)
|
|
190
|
+
if parsed.tzinfo is None:
|
|
191
|
+
parsed = parsed.replace(tzinfo=UTC)
|
|
192
|
+
return max((parsed - datetime.now(UTC)).total_seconds(), 0.0)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Shared validation for public resource arguments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import date
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def positive(value: int | float, name: str, *, allow_zero: bool = False) -> None:
|
|
9
|
+
"""Require a positive numeric value (or zero when explicitly allowed)."""
|
|
10
|
+
minimum = 0 if allow_zero else 1
|
|
11
|
+
if value < minimum:
|
|
12
|
+
qualifier = "non-negative" if allow_zero else "positive"
|
|
13
|
+
raise ValueError(f"{name} must be {qualifier}.")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def iso_date(value: str | None, name: str) -> None:
|
|
17
|
+
"""Require an ISO 8601 calendar date when a value is supplied."""
|
|
18
|
+
if value is None:
|
|
19
|
+
return
|
|
20
|
+
try:
|
|
21
|
+
date.fromisoformat(value)
|
|
22
|
+
except ValueError as exc:
|
|
23
|
+
raise ValueError(f"{name} must use YYYY-MM-DD format.") from exc
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def choice(value: str | None, name: str, choices: set[str]) -> None:
|
|
27
|
+
"""Require a value to belong to a fixed set when supplied."""
|
|
28
|
+
if value is not None and value not in choices:
|
|
29
|
+
allowed = ", ".join(sorted(choices))
|
|
30
|
+
raise ValueError(f"{name} must be one of: {allowed}.")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def nonempty(value: object, name: str) -> None:
|
|
34
|
+
"""Reject empty identifiers and collections."""
|
|
35
|
+
if value is None or (hasattr(value, "__len__") and len(value) == 0): # type: ignore[arg-type]
|
|
36
|
+
raise ValueError(f"{name} must not be empty.")
|
|
@@ -24,6 +24,8 @@ import contextlib
|
|
|
24
24
|
import json
|
|
25
25
|
import logging
|
|
26
26
|
import os
|
|
27
|
+
import re
|
|
28
|
+
import sys
|
|
27
29
|
import tempfile
|
|
28
30
|
from dataclasses import dataclass
|
|
29
31
|
from datetime import UTC, datetime
|
|
@@ -37,7 +39,31 @@ logger = logging.getLogger(__name__)
|
|
|
37
39
|
|
|
38
40
|
AUTH_BASE = "https://authentication.arccosgolf.com"
|
|
39
41
|
|
|
40
|
-
|
|
42
|
+
LEGACY_CREDS_PATH = Path.home() / ".arccos_creds.json"
|
|
43
|
+
KEYRING_SERVICE = "arccos-api"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def config_dir() -> Path:
|
|
47
|
+
"""Return the platform-appropriate per-user configuration directory."""
|
|
48
|
+
if os.name == "nt":
|
|
49
|
+
root = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
|
|
50
|
+
elif sys.platform == "darwin":
|
|
51
|
+
root = Path.home() / "Library" / "Application Support"
|
|
52
|
+
else:
|
|
53
|
+
root = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
|
54
|
+
return root / "arccos-api"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
DEFAULT_CREDS_PATH = config_dir() / "credentials.json"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def credentials_path(profile: str = "default") -> Path:
|
|
61
|
+
"""Resolve a profile name to its credential file without allowing traversal."""
|
|
62
|
+
if not re.fullmatch(r"[A-Za-z0-9_.-]+", profile):
|
|
63
|
+
raise ValueError("profile may contain only letters, numbers, '.', '_' and '-'.")
|
|
64
|
+
if profile == "default":
|
|
65
|
+
return DEFAULT_CREDS_PATH
|
|
66
|
+
return config_dir() / "profiles" / f"{profile}.json"
|
|
41
67
|
|
|
42
68
|
|
|
43
69
|
@dataclass
|
|
@@ -81,6 +107,7 @@ class Credentials:
|
|
|
81
107
|
prevents partially-written credential files.
|
|
82
108
|
"""
|
|
83
109
|
path = Path(path)
|
|
110
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
84
111
|
fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
85
112
|
try:
|
|
86
113
|
os.fchmod(fd, 0o600)
|
|
@@ -154,8 +181,12 @@ class ArccosAuth:
|
|
|
154
181
|
self,
|
|
155
182
|
creds_path: Path = DEFAULT_CREDS_PATH,
|
|
156
183
|
session: requests.Session | None = None,
|
|
184
|
+
profile: str = "default",
|
|
185
|
+
use_keyring: bool = False,
|
|
157
186
|
):
|
|
158
187
|
self._creds_path = creds_path
|
|
188
|
+
self._profile = profile
|
|
189
|
+
self._use_keyring = use_keyring
|
|
159
190
|
self._session = session or requests.Session()
|
|
160
191
|
self._session.headers.update({"Content-Type": "application/json"})
|
|
161
192
|
|
|
@@ -182,11 +213,9 @@ class ArccosAuth:
|
|
|
182
213
|
logger.debug("Logging in as %s", email)
|
|
183
214
|
|
|
184
215
|
# Step 1: email + password → accessKey
|
|
185
|
-
resp = self.
|
|
216
|
+
resp = self._post(
|
|
186
217
|
f"{AUTH_BASE}/accessKeys",
|
|
187
|
-
|
|
188
|
-
timeout=15,
|
|
189
|
-
verify=True,
|
|
218
|
+
{"email": email, "password": password, "signedInByFacebook": "F"},
|
|
190
219
|
)
|
|
191
220
|
if resp.status_code == 401:
|
|
192
221
|
raise ArccosAuthError(
|
|
@@ -194,7 +223,7 @@ class ArccosAuth:
|
|
|
194
223
|
status_code=resp.status_code,
|
|
195
224
|
)
|
|
196
225
|
raise_for_status(resp)
|
|
197
|
-
data =
|
|
226
|
+
data = self._response_json(resp)
|
|
198
227
|
|
|
199
228
|
try:
|
|
200
229
|
access_key = data["accessKey"]
|
|
@@ -216,7 +245,7 @@ class ArccosAuth:
|
|
|
216
245
|
token=token,
|
|
217
246
|
secret=secret,
|
|
218
247
|
)
|
|
219
|
-
|
|
248
|
+
self._save(creds)
|
|
220
249
|
logger.info("Logged in successfully as %s (userId=%s)", email, user_id)
|
|
221
250
|
return creds
|
|
222
251
|
|
|
@@ -238,7 +267,7 @@ class ArccosAuth:
|
|
|
238
267
|
"""
|
|
239
268
|
logger.debug("Refreshing token for userId=%s", creds.user_id)
|
|
240
269
|
creds.token = self._fetch_token(creds.user_id, creds.access_key)
|
|
241
|
-
|
|
270
|
+
self._save(creds)
|
|
242
271
|
return creds
|
|
243
272
|
|
|
244
273
|
def ensure_fresh(self, creds: Credentials) -> Credentials:
|
|
@@ -271,7 +300,7 @@ class ArccosAuth:
|
|
|
271
300
|
Returns:
|
|
272
301
|
Valid :class:`Credentials`.
|
|
273
302
|
"""
|
|
274
|
-
creds =
|
|
303
|
+
creds = self.load_cached()
|
|
275
304
|
if creds and creds.email == email:
|
|
276
305
|
return self.ensure_fresh(creds)
|
|
277
306
|
return self.login(email, password)
|
|
@@ -281,17 +310,77 @@ class ArccosAuth:
|
|
|
281
310
|
"""Return the ``Authorization`` header value for API requests."""
|
|
282
311
|
return f"Bearer {creds.token}"
|
|
283
312
|
|
|
313
|
+
def load_cached(self) -> Credentials | None:
|
|
314
|
+
"""Load cached credentials from the selected backend."""
|
|
315
|
+
if not self._use_keyring:
|
|
316
|
+
creds = Credentials.load(self._creds_path)
|
|
317
|
+
if (
|
|
318
|
+
creds is None
|
|
319
|
+
and self._profile == "default"
|
|
320
|
+
and self._creds_path == DEFAULT_CREDS_PATH
|
|
321
|
+
and LEGACY_CREDS_PATH.exists()
|
|
322
|
+
):
|
|
323
|
+
creds = Credentials.load(LEGACY_CREDS_PATH)
|
|
324
|
+
if creds is not None:
|
|
325
|
+
creds.save(self._creds_path)
|
|
326
|
+
LEGACY_CREDS_PATH.unlink()
|
|
327
|
+
logger.info("Migrated credentials to %s", self._creds_path)
|
|
328
|
+
return creds
|
|
329
|
+
try:
|
|
330
|
+
import keyring
|
|
331
|
+
except ImportError as exc:
|
|
332
|
+
raise ArccosAuthError(
|
|
333
|
+
"Keyring storage requires: pip install 'arccos-api[keyring]'"
|
|
334
|
+
) from exc
|
|
335
|
+
value = keyring.get_password(KEYRING_SERVICE, self._profile)
|
|
336
|
+
if value is None:
|
|
337
|
+
return None
|
|
338
|
+
try:
|
|
339
|
+
return Credentials.from_dict(json.loads(value))
|
|
340
|
+
except (KeyError, TypeError, json.JSONDecodeError) as exc:
|
|
341
|
+
raise ArccosAuthError("Stored keyring credentials are invalid.") from exc
|
|
342
|
+
|
|
343
|
+
def clear_cached(self) -> bool:
|
|
344
|
+
"""Remove cached credentials and report whether anything was removed."""
|
|
345
|
+
if not self._use_keyring:
|
|
346
|
+
if not self._creds_path.exists():
|
|
347
|
+
return False
|
|
348
|
+
self._creds_path.unlink()
|
|
349
|
+
return True
|
|
350
|
+
try:
|
|
351
|
+
import keyring
|
|
352
|
+
from keyring.errors import PasswordDeleteError
|
|
353
|
+
except ImportError as exc:
|
|
354
|
+
raise ArccosAuthError(
|
|
355
|
+
"Keyring storage requires: pip install 'arccos-api[keyring]'"
|
|
356
|
+
) from exc
|
|
357
|
+
try:
|
|
358
|
+
keyring.delete_password(KEYRING_SERVICE, self._profile)
|
|
359
|
+
except PasswordDeleteError:
|
|
360
|
+
return False
|
|
361
|
+
return True
|
|
362
|
+
|
|
284
363
|
# ------------------------------------------------------------------
|
|
285
364
|
# Private helpers
|
|
286
365
|
# ------------------------------------------------------------------
|
|
287
366
|
|
|
367
|
+
def _save(self, creds: Credentials) -> None:
|
|
368
|
+
if not self._use_keyring:
|
|
369
|
+
creds.save(self._creds_path)
|
|
370
|
+
return
|
|
371
|
+
try:
|
|
372
|
+
import keyring
|
|
373
|
+
except ImportError as exc:
|
|
374
|
+
raise ArccosAuthError(
|
|
375
|
+
"Keyring storage requires: pip install 'arccos-api[keyring]'"
|
|
376
|
+
) from exc
|
|
377
|
+
keyring.set_password(KEYRING_SERVICE, self._profile, json.dumps(creds.to_dict()))
|
|
378
|
+
|
|
288
379
|
def _fetch_token(self, user_id: str, access_key: str) -> str:
|
|
289
380
|
"""POST /tokens and return the JWT string."""
|
|
290
|
-
resp = self.
|
|
381
|
+
resp = self._post(
|
|
291
382
|
f"{AUTH_BASE}/tokens",
|
|
292
|
-
|
|
293
|
-
timeout=15,
|
|
294
|
-
verify=True,
|
|
383
|
+
{"userId": user_id, "accessKey": access_key},
|
|
295
384
|
)
|
|
296
385
|
if resp.status_code == 401:
|
|
297
386
|
raise ArccosAuthError(
|
|
@@ -299,4 +388,35 @@ class ArccosAuth:
|
|
|
299
388
|
status_code=resp.status_code,
|
|
300
389
|
)
|
|
301
390
|
raise_for_status(resp)
|
|
302
|
-
|
|
391
|
+
data = self._response_json(resp)
|
|
392
|
+
token = data.get("token")
|
|
393
|
+
if not isinstance(token, str) or not token:
|
|
394
|
+
raise ArccosAuthError(
|
|
395
|
+
"Unexpected response from authentication server.",
|
|
396
|
+
status_code=resp.status_code,
|
|
397
|
+
)
|
|
398
|
+
return token
|
|
399
|
+
|
|
400
|
+
@staticmethod
|
|
401
|
+
def _response_json(resp: requests.Response) -> dict:
|
|
402
|
+
"""Decode an authentication response or raise a stable public error."""
|
|
403
|
+
try:
|
|
404
|
+
data = resp.json()
|
|
405
|
+
except (requests.exceptions.JSONDecodeError, ValueError) as exc:
|
|
406
|
+
raise ArccosAuthError(
|
|
407
|
+
"Authentication server returned invalid JSON.",
|
|
408
|
+
status_code=resp.status_code,
|
|
409
|
+
) from exc
|
|
410
|
+
if not isinstance(data, dict):
|
|
411
|
+
raise ArccosAuthError(
|
|
412
|
+
"Unexpected response from authentication server.",
|
|
413
|
+
status_code=resp.status_code,
|
|
414
|
+
)
|
|
415
|
+
return data
|
|
416
|
+
|
|
417
|
+
def _post(self, url: str, payload: dict) -> requests.Response:
|
|
418
|
+
"""Send an auth request and normalize transport failures."""
|
|
419
|
+
try:
|
|
420
|
+
return self._session.post(url, json=payload, timeout=15, verify=True)
|
|
421
|
+
except requests.exceptions.RequestException as exc:
|
|
422
|
+
raise ArccosAuthError("Unable to reach the authentication server.") from exc
|
|
@@ -50,6 +50,7 @@ class RichGroup(click.Group):
|
|
|
50
50
|
("export", "Export rounds to JSON/CSV/NDJSON"),
|
|
51
51
|
("completions", "Generate shell completion script"),
|
|
52
52
|
("logout", "Clear cached credentials"),
|
|
53
|
+
("doctor", "Check credentials and API connectivity"),
|
|
53
54
|
]
|
|
54
55
|
for cmd, desc in cmds:
|
|
55
56
|
table.add_row(f"arccos {cmd}", desc)
|
|
@@ -57,7 +58,7 @@ class RichGroup(click.Group):
|
|
|
57
58
|
_console.print(table)
|
|
58
59
|
_console.print(
|
|
59
60
|
"\n[dim]All commands support [bold]--json[/bold] and [bold]--help[/bold]. "
|
|
60
|
-
"
|
|
61
|
+
"Named profiles and optional OS-keyring storage are supported.[/dim]\n"
|
|
61
62
|
)
|
|
62
63
|
|
|
63
64
|
|
|
@@ -20,9 +20,16 @@ def _get_client():
|
|
|
20
20
|
|
|
21
21
|
email = os.environ.get("ARCCOS_EMAIL")
|
|
22
22
|
password = os.environ.get("ARCCOS_PASSWORD")
|
|
23
|
+
profile = os.environ.get("ARCCOS_PROFILE", "default")
|
|
24
|
+
use_keyring = os.environ.get("ARCCOS_KEYRING", "").lower() in {"1", "true", "yes"}
|
|
23
25
|
|
|
24
26
|
try:
|
|
25
|
-
return ArccosClient(
|
|
27
|
+
return ArccosClient(
|
|
28
|
+
email=email,
|
|
29
|
+
password=password,
|
|
30
|
+
profile=profile,
|
|
31
|
+
use_keyring=use_keyring,
|
|
32
|
+
)
|
|
26
33
|
except ArccosAuthError as e:
|
|
27
34
|
err_console.print(f"[red]Auth failed:[/red] {e}")
|
|
28
35
|
err_console.print("Run [bold]arccos login[/bold] or set ARCCOS_EMAIL and ARCCOS_PASSWORD.")
|