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.
Files changed (53) hide show
  1. {arccos_api-0.5.0 → arccos_api-0.5.2}/MANIFEST.in +1 -0
  2. {arccos_api-0.5.0/arccos_api.egg-info → arccos_api-0.5.2}/PKG-INFO +16 -7
  3. {arccos_api-0.5.0 → arccos_api-0.5.2}/README.md +11 -5
  4. {arccos_api-0.5.0 → arccos_api-0.5.2}/arccos/__init__.py +4 -0
  5. {arccos_api-0.5.0 → arccos_api-0.5.2}/arccos/_http.py +67 -11
  6. arccos_api-0.5.2/arccos/_validation.py +36 -0
  7. {arccos_api-0.5.0 → arccos_api-0.5.2}/arccos/auth.py +134 -14
  8. {arccos_api-0.5.0 → arccos_api-0.5.2}/arccos/cli/__init__.py +2 -1
  9. {arccos_api-0.5.0 → arccos_api-0.5.2}/arccos/cli/_helpers.py +8 -1
  10. arccos_api-0.5.2/arccos/cli/achievements.py +267 -0
  11. arccos_api-0.5.2/arccos/cli/analytics.py +290 -0
  12. arccos_api-0.5.2/arccos/cli/auth.py +145 -0
  13. arccos_api-0.5.2/arccos/cli/clubs.py +259 -0
  14. arccos_api-0.5.2/arccos/cli/courses.py +144 -0
  15. arccos_api-0.5.2/arccos/cli/handicap.py +92 -0
  16. arccos_api-0.5.2/arccos/cli/presentation.py +222 -0
  17. arccos_api-0.5.2/arccos/cli/stats.py +36 -0
  18. {arccos_api-0.5.0 → arccos_api-0.5.2}/arccos/client.py +28 -8
  19. {arccos_api-0.5.0 → arccos_api-0.5.2}/arccos/exceptions.py +8 -0
  20. {arccos_api-0.5.0 → arccos_api-0.5.2}/arccos/resources/clubs.py +20 -5
  21. {arccos_api-0.5.0 → arccos_api-0.5.2}/arccos/resources/courses.py +30 -5
  22. {arccos_api-0.5.0 → arccos_api-0.5.2}/arccos/resources/handicap.py +9 -3
  23. {arccos_api-0.5.0 → arccos_api-0.5.2}/arccos/resources/rounds.py +30 -19
  24. {arccos_api-0.5.0 → arccos_api-0.5.2}/arccos/resources/stats.py +16 -6
  25. {arccos_api-0.5.0 → arccos_api-0.5.2}/arccos/types.py +51 -0
  26. {arccos_api-0.5.0 → arccos_api-0.5.2/arccos_api.egg-info}/PKG-INFO +16 -7
  27. {arccos_api-0.5.0 → arccos_api-0.5.2}/arccos_api.egg-info/SOURCES.txt +14 -1
  28. arccos_api-0.5.2/arccos_api.egg-info/dependency_links.txt +1 -0
  29. {arccos_api-0.5.0 → arccos_api-0.5.2}/arccos_api.egg-info/requires.txt +5 -1
  30. {arccos_api-0.5.0 → arccos_api-0.5.2}/pyproject.toml +7 -2
  31. arccos_api-0.5.2/tests/fixtures/contracts/courses_played.json +11 -0
  32. arccos_api-0.5.2/tests/fixtures/contracts/personal_bests.json +13 -0
  33. arccos_api-0.5.2/tests/fixtures/contracts/rounds.json +15 -0
  34. arccos_api-0.5.2/tests/fixtures/contracts/smart_distances.json +9 -0
  35. {arccos_api-0.5.0 → arccos_api-0.5.2}/tests/test_auth.py +68 -1
  36. {arccos_api-0.5.0 → arccos_api-0.5.2}/tests/test_cli.py +10 -0
  37. {arccos_api-0.5.0 → arccos_api-0.5.2}/tests/test_client.py +16 -7
  38. arccos_api-0.5.2/tests/test_contracts.py +71 -0
  39. {arccos_api-0.5.0 → arccos_api-0.5.2}/tests/test_http.py +55 -2
  40. {arccos_api-0.5.0 → arccos_api-0.5.2}/tests/test_resources.py +31 -0
  41. {arccos_api-0.5.0 → arccos_api-0.5.2}/tests/test_rounds.py +24 -4
  42. arccos_api-0.5.0/arccos/cli/auth.py +0 -85
  43. arccos_api-0.5.0/arccos/cli/stats.py +0 -1186
  44. {arccos_api-0.5.0 → arccos_api-0.5.2}/LICENSE +0 -0
  45. {arccos_api-0.5.0 → arccos_api-0.5.2}/arccos/__main__.py +0 -0
  46. {arccos_api-0.5.0 → arccos_api-0.5.2}/arccos/cli/rounds.py +0 -0
  47. /arccos_api-0.5.0/arccos_api.egg-info/dependency_links.txt → /arccos_api-0.5.2/arccos/py.typed +0 -0
  48. {arccos_api-0.5.0 → arccos_api-0.5.2}/arccos/resources/__init__.py +0 -0
  49. {arccos_api-0.5.0 → arccos_api-0.5.2}/arccos_api.egg-info/entry_points.txt +0 -0
  50. {arccos_api-0.5.0 → arccos_api-0.5.2}/arccos_api.egg-info/top_level.txt +0 -0
  51. {arccos_api-0.5.0 → arccos_api-0.5.2}/setup.cfg +0 -0
  52. {arccos_api-0.5.0 → arccos_api-0.5.2}/tests/test_exceptions.py +0 -0
  53. {arccos_api-0.5.0 → arccos_api-0.5.2}/tests/test_security.py +0 -0
@@ -1,5 +1,6 @@
1
1
  include LICENSE
2
2
  include README.md
3
3
  include pyproject.toml
4
+ recursive-include tests/fixtures *.json
4
5
  recursive-exclude __pycache__ *
5
6
  global-exclude *.pyc *.pyo
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arccos-api
3
- Version: 0.5.0
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<15,>=13.0
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 to `~/.arccos_creds.json` |
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 ~/.arccos_creds.json. Auto-refreshes silently.
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%+, 173 tests)
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
- Current suite: 173 tests.
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 `~/.arccos_creds.json` (mode `0600` — owner-only read/write). The file contains your access key (~180-day validity) and JWT token. Keep it safe.
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 to `~/.arccos_creds.json` |
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 ~/.arccos_creds.json. Auto-refreshes silently.
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%+, 173 tests)
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
- Current suite: 173 tests.
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 `~/.arccos_creds.json` (mode `0600` — owner-only read/write). The file contains your access key (~180-day validity) and JWT token. Keep it safe.
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 raise_for_status
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__(self, auth: ArccosAuth, creds: Credentials):
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
- self._backoff(attempt)
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
- self._backoff(attempt)
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
- return resp.json()
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
- self._backoff(attempt, resp)
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
- raise last_exc # type: ignore[misc]
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(float(retry_after), delay)
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
- DEFAULT_CREDS_PATH = Path.home() / ".arccos_creds.json"
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._session.post(
216
+ resp = self._post(
186
217
  f"{AUTH_BASE}/accessKeys",
187
- json={"email": email, "password": password, "signedInByFacebook": "F"},
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 = resp.json()
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
- creds.save(self._creds_path)
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
- creds.save(self._creds_path)
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 = Credentials.load(self._creds_path)
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._session.post(
381
+ resp = self._post(
291
382
  f"{AUTH_BASE}/tokens",
292
- json={"userId": user_id, "accessKey": access_key},
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
- return resp.json()["token"]
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
- "Credentials cached in ~/.arccos_creds.json.[/dim]\n"
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(email=email, password=password)
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.")