arccos-api 0.2.0__tar.gz → 0.3.0__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.2.0 → arccos_api-0.3.0}/PKG-INFO +28 -4
- {arccos_api-0.2.0 → arccos_api-0.3.0}/README.md +26 -3
- arccos_api-0.3.0/arccos/__init__.py +65 -0
- {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos/_http.py +16 -3
- {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos/auth.py +1 -1
- {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos/cli/__init__.py +2 -0
- {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos/cli/stats.py +132 -2
- {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos/client.py +1 -1
- {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos/exceptions.py +4 -2
- {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos_api.egg-info/PKG-INFO +28 -4
- {arccos_api-0.2.0 → arccos_api-0.3.0}/pyproject.toml +2 -1
- {arccos_api-0.2.0 → arccos_api-0.3.0}/tests/test_cli.py +115 -0
- {arccos_api-0.2.0 → arccos_api-0.3.0}/tests/test_exceptions.py +2 -1
- arccos_api-0.2.0/arccos/__init__.py +0 -33
- {arccos_api-0.2.0 → arccos_api-0.3.0}/MANIFEST.in +0 -0
- {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos/__main__.py +0 -0
- {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos/cli/_helpers.py +0 -0
- {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos/cli/auth.py +0 -0
- {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos/cli/rounds.py +0 -0
- {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos/resources/__init__.py +0 -0
- {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos/resources/clubs.py +0 -0
- {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos/resources/courses.py +0 -0
- {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos/resources/handicap.py +0 -0
- {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos/resources/rounds.py +0 -0
- {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos/resources/stats.py +0 -0
- {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos/types.py +0 -0
- {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos_api.egg-info/SOURCES.txt +0 -0
- {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos_api.egg-info/dependency_links.txt +0 -0
- {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos_api.egg-info/entry_points.txt +0 -0
- {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos_api.egg-info/requires.txt +0 -0
- {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos_api.egg-info/top_level.txt +0 -0
- {arccos_api-0.2.0 → arccos_api-0.3.0}/setup.cfg +0 -0
- {arccos_api-0.2.0 → arccos_api-0.3.0}/tests/test_auth.py +0 -0
- {arccos_api-0.2.0 → arccos_api-0.3.0}/tests/test_client.py +0 -0
- {arccos_api-0.2.0 → arccos_api-0.3.0}/tests/test_http.py +0 -0
- {arccos_api-0.2.0 → arccos_api-0.3.0}/tests/test_resources.py +0 -0
- {arccos_api-0.2.0 → arccos_api-0.3.0}/tests/test_rounds.py +0 -0
- {arccos_api-0.2.0 → arccos_api-0.3.0}/tests/test_security.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: arccos-api
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Unofficial Python CLI and client library for the Arccos Golf API
|
|
5
5
|
Author: Paul Frederiksen
|
|
6
6
|
License: MIT
|
|
@@ -17,6 +17,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.11
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.12
|
|
19
19
|
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
21
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
22
|
Requires-Python: >=3.11
|
|
22
23
|
Description-Content-Type: text/markdown
|
|
@@ -83,9 +84,11 @@ arccos clubs # smart club distances
|
|
|
83
84
|
| `arccos bests` | All-time personal bests (lowest score, longest drive, etc.) |
|
|
84
85
|
| `arccos overview` | Performance summary — scoring avg + handicap breakdown |
|
|
85
86
|
| `arccos scoring` | Scoring trend with visual bar chart |
|
|
86
|
-
| `arccos courses` | List
|
|
87
|
+
| `arccos courses` | List courses you've played (or `--search` any course) |
|
|
87
88
|
| `arccos pace` | Pace of play analysis by course (color-coded) |
|
|
88
|
-
| `arccos stats
|
|
89
|
+
| `arccos stats` | Strokes gained analysis (defaults to latest round) |
|
|
90
|
+
| `arccos club-shots <id>` | Shot history for a specific club |
|
|
91
|
+
| `arccos up-and-down` | Scramble / strokes-to-get-down stats |
|
|
89
92
|
| `arccos export` | Export rounds to JSON, CSV, or NDJSON (`--detail` for hole data) |
|
|
90
93
|
| `arccos logout` | Clear cached credentials |
|
|
91
94
|
|
|
@@ -116,9 +119,14 @@ arccos clubs --after 2025-01-01 # distances from this year only
|
|
|
116
119
|
arccos bests # all-time personal bests
|
|
117
120
|
arccos overview # scoring avg + handicap breakdown
|
|
118
121
|
arccos scoring # scoring trend with bar chart
|
|
122
|
+
arccos up-and-down # scramble stats
|
|
119
123
|
|
|
120
124
|
# Courses
|
|
121
125
|
arccos courses # all courses played
|
|
126
|
+
arccos courses --search "torrey" # search any course by name
|
|
127
|
+
|
|
128
|
+
# Club detail
|
|
129
|
+
arccos club-shots 1 # shot history for club ID 1
|
|
122
130
|
|
|
123
131
|
# Pace of play
|
|
124
132
|
arccos pace # all rounds, slowest courses first
|
|
@@ -208,6 +216,13 @@ export ARCCOS_PASSWORD="your_password"
|
|
|
208
216
|
arccos rounds
|
|
209
217
|
```
|
|
210
218
|
|
|
219
|
+
Other environment variables:
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
export ARCCOS_TIMEOUT=30 # request timeout in seconds (default: 15)
|
|
223
|
+
export ARCCOS_LOG_LEVEL=DEBUG # enable debug logging (DEBUG, INFO, WARNING)
|
|
224
|
+
```
|
|
225
|
+
|
|
211
226
|
> **Security note:** Environment variables may be visible in process listings (`ps`) and shell history. Prefer `arccos login` for interactive use.
|
|
212
227
|
|
|
213
228
|
## Python Library
|
|
@@ -257,6 +272,13 @@ for c in pace["course_averages"][:5]:
|
|
|
257
272
|
|
|
258
273
|
# Personal bests
|
|
259
274
|
bests = client.stats.personal_bests()
|
|
275
|
+
|
|
276
|
+
# Iterate ALL rounds (auto-pagination)
|
|
277
|
+
for r in client.rounds.iter_all():
|
|
278
|
+
print(r["roundId"], r["noOfShots"])
|
|
279
|
+
|
|
280
|
+
# Course search
|
|
281
|
+
results = client.courses.search("Torrey Pines")
|
|
260
282
|
```
|
|
261
283
|
|
|
262
284
|
## API Reference
|
|
@@ -290,7 +312,9 @@ All data calls go to `https://api.arccosgolf.com` with `Authorization: Bearer <t
|
|
|
290
312
|
| Courses played | GET | `/users/{userId}/coursesPlayed` |
|
|
291
313
|
| Course metadata | GET | `/courses/{courseId}?courseVersion=1` |
|
|
292
314
|
| Course search | GET | `/v2/courses?search={query}` |
|
|
315
|
+
| Club shots | GET | `/users/{userId}/bags/{bagId}/clubs/{clubId}/shots` |
|
|
293
316
|
| Personal bests | GET | `/users/{userId}/personalBests?tags=allTimeBest` |
|
|
317
|
+
| Strokes to get down | GET | `/v2/sga/strokes-to-get-down` |
|
|
294
318
|
|
|
295
319
|
### Key data structures
|
|
296
320
|
|
|
@@ -343,7 +367,7 @@ python3 -m venv .venv
|
|
|
343
367
|
source .venv/bin/activate
|
|
344
368
|
pip install -e ".[dev]"
|
|
345
369
|
|
|
346
|
-
pytest # tests with coverage (
|
|
370
|
+
pytest # tests with coverage (87%+, 139 tests)
|
|
347
371
|
ruff check arccos/ # lint
|
|
348
372
|
mypy arccos/ # type check
|
|
349
373
|
```
|
|
@@ -49,9 +49,11 @@ arccos clubs # smart club distances
|
|
|
49
49
|
| `arccos bests` | All-time personal bests (lowest score, longest drive, etc.) |
|
|
50
50
|
| `arccos overview` | Performance summary — scoring avg + handicap breakdown |
|
|
51
51
|
| `arccos scoring` | Scoring trend with visual bar chart |
|
|
52
|
-
| `arccos courses` | List
|
|
52
|
+
| `arccos courses` | List courses you've played (or `--search` any course) |
|
|
53
53
|
| `arccos pace` | Pace of play analysis by course (color-coded) |
|
|
54
|
-
| `arccos stats
|
|
54
|
+
| `arccos stats` | Strokes gained analysis (defaults to latest round) |
|
|
55
|
+
| `arccos club-shots <id>` | Shot history for a specific club |
|
|
56
|
+
| `arccos up-and-down` | Scramble / strokes-to-get-down stats |
|
|
55
57
|
| `arccos export` | Export rounds to JSON, CSV, or NDJSON (`--detail` for hole data) |
|
|
56
58
|
| `arccos logout` | Clear cached credentials |
|
|
57
59
|
|
|
@@ -82,9 +84,14 @@ arccos clubs --after 2025-01-01 # distances from this year only
|
|
|
82
84
|
arccos bests # all-time personal bests
|
|
83
85
|
arccos overview # scoring avg + handicap breakdown
|
|
84
86
|
arccos scoring # scoring trend with bar chart
|
|
87
|
+
arccos up-and-down # scramble stats
|
|
85
88
|
|
|
86
89
|
# Courses
|
|
87
90
|
arccos courses # all courses played
|
|
91
|
+
arccos courses --search "torrey" # search any course by name
|
|
92
|
+
|
|
93
|
+
# Club detail
|
|
94
|
+
arccos club-shots 1 # shot history for club ID 1
|
|
88
95
|
|
|
89
96
|
# Pace of play
|
|
90
97
|
arccos pace # all rounds, slowest courses first
|
|
@@ -174,6 +181,13 @@ export ARCCOS_PASSWORD="your_password"
|
|
|
174
181
|
arccos rounds
|
|
175
182
|
```
|
|
176
183
|
|
|
184
|
+
Other environment variables:
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
export ARCCOS_TIMEOUT=30 # request timeout in seconds (default: 15)
|
|
188
|
+
export ARCCOS_LOG_LEVEL=DEBUG # enable debug logging (DEBUG, INFO, WARNING)
|
|
189
|
+
```
|
|
190
|
+
|
|
177
191
|
> **Security note:** Environment variables may be visible in process listings (`ps`) and shell history. Prefer `arccos login` for interactive use.
|
|
178
192
|
|
|
179
193
|
## Python Library
|
|
@@ -223,6 +237,13 @@ for c in pace["course_averages"][:5]:
|
|
|
223
237
|
|
|
224
238
|
# Personal bests
|
|
225
239
|
bests = client.stats.personal_bests()
|
|
240
|
+
|
|
241
|
+
# Iterate ALL rounds (auto-pagination)
|
|
242
|
+
for r in client.rounds.iter_all():
|
|
243
|
+
print(r["roundId"], r["noOfShots"])
|
|
244
|
+
|
|
245
|
+
# Course search
|
|
246
|
+
results = client.courses.search("Torrey Pines")
|
|
226
247
|
```
|
|
227
248
|
|
|
228
249
|
## API Reference
|
|
@@ -256,7 +277,9 @@ All data calls go to `https://api.arccosgolf.com` with `Authorization: Bearer <t
|
|
|
256
277
|
| Courses played | GET | `/users/{userId}/coursesPlayed` |
|
|
257
278
|
| Course metadata | GET | `/courses/{courseId}?courseVersion=1` |
|
|
258
279
|
| Course search | GET | `/v2/courses?search={query}` |
|
|
280
|
+
| Club shots | GET | `/users/{userId}/bags/{bagId}/clubs/{clubId}/shots` |
|
|
259
281
|
| Personal bests | GET | `/users/{userId}/personalBests?tags=allTimeBest` |
|
|
282
|
+
| Strokes to get down | GET | `/v2/sga/strokes-to-get-down` |
|
|
260
283
|
|
|
261
284
|
### Key data structures
|
|
262
285
|
|
|
@@ -309,7 +332,7 @@ python3 -m venv .venv
|
|
|
309
332
|
source .venv/bin/activate
|
|
310
333
|
pip install -e ".[dev]"
|
|
311
334
|
|
|
312
|
-
pytest # tests with coverage (
|
|
335
|
+
pytest # tests with coverage (87%+, 139 tests)
|
|
313
336
|
ruff check arccos/ # lint
|
|
314
337
|
mypy arccos/ # type check
|
|
315
338
|
```
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
arccos — Unofficial Python client for the Arccos Golf API.
|
|
3
|
+
|
|
4
|
+
Quick start:
|
|
5
|
+
from arccos import ArccosClient
|
|
6
|
+
|
|
7
|
+
client = ArccosClient(email="you@example.com", password="secret")
|
|
8
|
+
rounds = client.rounds.list()
|
|
9
|
+
print(client.handicap.current())
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import logging as _logging
|
|
13
|
+
import os as _os
|
|
14
|
+
import re as _re
|
|
15
|
+
from importlib.metadata import version as _pkg_version
|
|
16
|
+
|
|
17
|
+
from .auth import ArccosAuth
|
|
18
|
+
from .client import ArccosClient
|
|
19
|
+
from .exceptions import (
|
|
20
|
+
ArccosAuthError,
|
|
21
|
+
ArccosError,
|
|
22
|
+
ArccosForbiddenError,
|
|
23
|
+
ArccosNotFoundError,
|
|
24
|
+
ArccosRateLimitError,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _SensitiveFilter(_logging.Filter):
|
|
29
|
+
"""Redact tokens, access keys, and passwords from log messages."""
|
|
30
|
+
|
|
31
|
+
_PATTERNS = _re.compile(
|
|
32
|
+
r"(Bearer\s+)\S+|"
|
|
33
|
+
r"(eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.)[A-Za-z0-9_-]+|"
|
|
34
|
+
r"(accessKey[\"']?\s*[:=]\s*[\"']?)\S+",
|
|
35
|
+
_re.IGNORECASE,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def filter(self, record: _logging.LogRecord) -> bool:
|
|
39
|
+
if isinstance(record.msg, str):
|
|
40
|
+
record.msg = self._PATTERNS.sub(r"\1\2\3[REDACTED]", record.msg)
|
|
41
|
+
return True
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_logger = _logging.getLogger("arccos")
|
|
45
|
+
_logger.addFilter(_SensitiveFilter())
|
|
46
|
+
|
|
47
|
+
# Allow ARCCOS_LOG_LEVEL=DEBUG (or INFO, WARNING, etc.) to enable logging
|
|
48
|
+
_log_level = _os.environ.get("ARCCOS_LOG_LEVEL")
|
|
49
|
+
if _log_level:
|
|
50
|
+
_logger.setLevel(getattr(_logging, _log_level.upper(), _logging.WARNING))
|
|
51
|
+
if not _logger.handlers:
|
|
52
|
+
_handler = _logging.StreamHandler()
|
|
53
|
+
_handler.setFormatter(_logging.Formatter("%(name)s %(levelname)s: %(message)s"))
|
|
54
|
+
_logger.addHandler(_handler)
|
|
55
|
+
|
|
56
|
+
__version__ = _pkg_version("arccos-api")
|
|
57
|
+
__all__ = [
|
|
58
|
+
"ArccosClient",
|
|
59
|
+
"ArccosAuth",
|
|
60
|
+
"ArccosError",
|
|
61
|
+
"ArccosAuthError",
|
|
62
|
+
"ArccosForbiddenError",
|
|
63
|
+
"ArccosNotFoundError",
|
|
64
|
+
"ArccosRateLimitError",
|
|
65
|
+
]
|
|
@@ -7,6 +7,7 @@ Retries transient failures (429, 5xx, timeouts) with exponential backoff.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import contextlib
|
|
10
11
|
import logging
|
|
11
12
|
import os
|
|
12
13
|
import time
|
|
@@ -44,7 +45,13 @@ class HttpClient:
|
|
|
44
45
|
"Content-Type": "application/json",
|
|
45
46
|
"Accept": "application/json",
|
|
46
47
|
})
|
|
47
|
-
|
|
48
|
+
try:
|
|
49
|
+
self._timeout = int(os.environ.get("ARCCOS_TIMEOUT", DEFAULT_TIMEOUT))
|
|
50
|
+
if self._timeout <= 0:
|
|
51
|
+
raise ValueError
|
|
52
|
+
except (ValueError, TypeError):
|
|
53
|
+
logger.warning("Invalid ARCCOS_TIMEOUT, using default %ds", DEFAULT_TIMEOUT)
|
|
54
|
+
self._timeout = DEFAULT_TIMEOUT
|
|
48
55
|
|
|
49
56
|
# ------------------------------------------------------------------
|
|
50
57
|
# Public interface
|
|
@@ -125,7 +132,7 @@ class HttpClient:
|
|
|
125
132
|
raise_for_status(resp)
|
|
126
133
|
except Exception as exc:
|
|
127
134
|
last_exc = exc
|
|
128
|
-
self._backoff(attempt)
|
|
135
|
+
self._backoff(attempt, resp)
|
|
129
136
|
continue
|
|
130
137
|
|
|
131
138
|
# Non-retryable error (4xx other than 429)
|
|
@@ -135,7 +142,13 @@ class HttpClient:
|
|
|
135
142
|
raise last_exc # type: ignore[misc]
|
|
136
143
|
|
|
137
144
|
@staticmethod
|
|
138
|
-
def _backoff(attempt: int) -> None:
|
|
145
|
+
def _backoff(attempt: int, resp: requests.Response | None = None) -> None:
|
|
139
146
|
delay = RETRY_BACKOFF * (2 ** attempt)
|
|
147
|
+
# Respect Retry-After header if present (429 responses)
|
|
148
|
+
if resp is not None:
|
|
149
|
+
retry_after = resp.headers.get("Retry-After")
|
|
150
|
+
if retry_after:
|
|
151
|
+
with contextlib.suppress(ValueError, TypeError):
|
|
152
|
+
delay = max(float(retry_after), delay)
|
|
140
153
|
logger.warning("Retrying in %.1fs (attempt %d/%d)", delay, attempt + 1, MAX_RETRIES)
|
|
141
154
|
time.sleep(delay)
|
|
@@ -31,6 +31,8 @@ def cli():
|
|
|
31
31
|
arccos bests Personal bests (all-time records)
|
|
32
32
|
arccos overview Overall stats summary
|
|
33
33
|
arccos scoring Scoring trend over recent rounds
|
|
34
|
+
arccos club-shots <id> Shot history for a specific club
|
|
35
|
+
arccos up-and-down Scramble / up-and-down stats
|
|
34
36
|
arccos export Export rounds to JSON/CSV
|
|
35
37
|
arccos logout Clear cached credentials
|
|
36
38
|
|
|
@@ -16,10 +16,12 @@ from ._helpers import _get_client, _output_json, console, err_console
|
|
|
16
16
|
def register(cli: click.Group) -> None:
|
|
17
17
|
cli.add_command(handicap)
|
|
18
18
|
cli.add_command(clubs)
|
|
19
|
+
cli.add_command(club_shots)
|
|
19
20
|
cli.add_command(stats)
|
|
20
21
|
cli.add_command(bests)
|
|
21
22
|
cli.add_command(overview)
|
|
22
23
|
cli.add_command(courses)
|
|
24
|
+
cli.add_command(up_and_down)
|
|
23
25
|
|
|
24
26
|
|
|
25
27
|
# ---------------------------------------------------------------------------
|
|
@@ -462,14 +464,23 @@ def overview(limit: int, as_json: bool):
|
|
|
462
464
|
# ---------------------------------------------------------------------------
|
|
463
465
|
|
|
464
466
|
@click.command()
|
|
467
|
+
@click.option("--search", "-s", default=None, help="Search for a course by name.")
|
|
465
468
|
@click.option("--json", "as_json", is_flag=True, help="Output raw JSON.")
|
|
466
|
-
def courses(as_json: bool):
|
|
467
|
-
"""List courses you have played."""
|
|
469
|
+
def courses(search: str, as_json: bool):
|
|
470
|
+
"""List courses you have played, or search for any course."""
|
|
468
471
|
client = _get_client()
|
|
469
472
|
|
|
470
473
|
with console.status("Fetching courses\u2026"):
|
|
471
474
|
data = client.courses.played()
|
|
472
475
|
|
|
476
|
+
if search:
|
|
477
|
+
needle = search.lower()
|
|
478
|
+
data = [
|
|
479
|
+
c for c in data
|
|
480
|
+
if needle in (c.get("name") or c.get("courseName") or "").lower()
|
|
481
|
+
or needle in (c.get("city") or "").lower()
|
|
482
|
+
]
|
|
483
|
+
|
|
473
484
|
if as_json:
|
|
474
485
|
_output_json(data)
|
|
475
486
|
return
|
|
@@ -502,3 +513,122 @@ def courses(as_json: bool):
|
|
|
502
513
|
table.add_row(name, city, state, played, cid)
|
|
503
514
|
|
|
504
515
|
console.print(table)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
# ---------------------------------------------------------------------------
|
|
519
|
+
# club-shots
|
|
520
|
+
# ---------------------------------------------------------------------------
|
|
521
|
+
|
|
522
|
+
@click.command(name="club-shots")
|
|
523
|
+
@click.argument("club_id", type=int)
|
|
524
|
+
@click.option("--limit", "-n", default=20, show_default=True, help="Number of shots to show.")
|
|
525
|
+
@click.option("--json", "as_json", is_flag=True, help="Output raw JSON.")
|
|
526
|
+
def club_shots(club_id: int, limit: int, as_json: bool):
|
|
527
|
+
"""Show individual shot history for a specific club.
|
|
528
|
+
|
|
529
|
+
CLUB_ID is the bag-specific club ID (shown in `arccos clubs --json`).
|
|
530
|
+
"""
|
|
531
|
+
client = _get_client()
|
|
532
|
+
|
|
533
|
+
with console.status("Fetching shots\u2026"):
|
|
534
|
+
profile = client._http.get(f"/users/{client.user_id}")
|
|
535
|
+
bag_id = profile.get("bagId")
|
|
536
|
+
if not bag_id:
|
|
537
|
+
err_console.print("[red]Could not determine bag ID.[/red]")
|
|
538
|
+
sys.exit(1)
|
|
539
|
+
data = client.clubs.club_shots(str(bag_id), club_id, limit=limit)
|
|
540
|
+
|
|
541
|
+
if as_json:
|
|
542
|
+
_output_json(data)
|
|
543
|
+
return
|
|
544
|
+
|
|
545
|
+
if not data:
|
|
546
|
+
console.print("[dim]No shots found for this club.[/dim]")
|
|
547
|
+
return
|
|
548
|
+
|
|
549
|
+
from arccos.resources.clubs import CLUB_TYPE_NAMES
|
|
550
|
+
|
|
551
|
+
# Try to resolve club name
|
|
552
|
+
try:
|
|
553
|
+
bag = client.clubs.bag(str(bag_id))
|
|
554
|
+
bag_club = next(
|
|
555
|
+
(c for c in bag.get("clubs", []) if c["clubId"] == club_id), {}
|
|
556
|
+
)
|
|
557
|
+
club_type = bag_club.get("clubType", club_id)
|
|
558
|
+
club_name = CLUB_TYPE_NAMES.get(club_type, f"Club {club_id}")
|
|
559
|
+
except Exception:
|
|
560
|
+
club_name = f"Club {club_id}"
|
|
561
|
+
|
|
562
|
+
table = Table(
|
|
563
|
+
box=box.ROUNDED,
|
|
564
|
+
header_style="bold cyan",
|
|
565
|
+
title=f"[bold]Shot History — {club_name}[/bold] [dim]({len(data)} shots)[/dim]",
|
|
566
|
+
)
|
|
567
|
+
table.add_column("Date", style="white", no_wrap=True)
|
|
568
|
+
table.add_column("Distance", justify="right", style="green bold")
|
|
569
|
+
table.add_column("Terrain", style="dim")
|
|
570
|
+
|
|
571
|
+
for shot in data:
|
|
572
|
+
date = shot.get("shotTime", shot.get("startTime", ""))[:10]
|
|
573
|
+
dist = shot.get("distance")
|
|
574
|
+
dist_s = f"{dist:.0f}y" if dist else "\u2014"
|
|
575
|
+
terrain = str(shot.get("userStartTerrainOverride", ""))
|
|
576
|
+
table.add_row(date, dist_s, terrain)
|
|
577
|
+
|
|
578
|
+
console.print(table)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
# ---------------------------------------------------------------------------
|
|
582
|
+
# up-and-down
|
|
583
|
+
# ---------------------------------------------------------------------------
|
|
584
|
+
|
|
585
|
+
@click.command(name="up-and-down")
|
|
586
|
+
@click.option("--json", "as_json", is_flag=True, help="Output raw JSON.")
|
|
587
|
+
def up_and_down(as_json: bool):
|
|
588
|
+
"""Show strokes-to-get-down (up-and-down / scramble) stats."""
|
|
589
|
+
client = _get_client()
|
|
590
|
+
|
|
591
|
+
from arccos.exceptions import ArccosError
|
|
592
|
+
|
|
593
|
+
try:
|
|
594
|
+
with console.status("Fetching up-and-down stats\u2026"):
|
|
595
|
+
data = client.stats.strokes_to_get_down()
|
|
596
|
+
except ArccosError as e:
|
|
597
|
+
err_console.print(f"[red]Failed:[/red] {e}")
|
|
598
|
+
err_console.print(
|
|
599
|
+
"[dim]This endpoint may not be available for all accounts.[/dim]"
|
|
600
|
+
)
|
|
601
|
+
sys.exit(1)
|
|
602
|
+
|
|
603
|
+
if as_json:
|
|
604
|
+
_output_json(data)
|
|
605
|
+
return
|
|
606
|
+
|
|
607
|
+
if not data:
|
|
608
|
+
console.print("[dim]No up-and-down data available.[/dim]")
|
|
609
|
+
return
|
|
610
|
+
|
|
611
|
+
# Display whatever the API returns as a key-value table
|
|
612
|
+
table = Table(
|
|
613
|
+
box=box.ROUNDED,
|
|
614
|
+
header_style="bold cyan",
|
|
615
|
+
title="[bold]Strokes to Get Down[/bold]",
|
|
616
|
+
)
|
|
617
|
+
table.add_column("Stat", style="white bold")
|
|
618
|
+
table.add_column("Value", justify="right", style="bold")
|
|
619
|
+
|
|
620
|
+
for key, val in data.items():
|
|
621
|
+
if isinstance(val, (int, float)):
|
|
622
|
+
display = f"{val:.1f}" if isinstance(val, float) else str(val)
|
|
623
|
+
table.add_row(key, display)
|
|
624
|
+
|
|
625
|
+
if table.row_count == 0:
|
|
626
|
+
# Nested structure — try one level deeper
|
|
627
|
+
for section, vals in data.items():
|
|
628
|
+
if isinstance(vals, dict):
|
|
629
|
+
for k, v in vals.items():
|
|
630
|
+
if isinstance(v, (int, float)):
|
|
631
|
+
display = f"{v:.1f}" if isinstance(v, float) else str(v)
|
|
632
|
+
table.add_row(f"{section}.{k}", display)
|
|
633
|
+
|
|
634
|
+
console.print(table)
|
|
@@ -58,10 +58,12 @@ def raise_for_status(response) -> None:
|
|
|
58
58
|
body = response.json()
|
|
59
59
|
error = body.get("error", {})
|
|
60
60
|
code = error.get("code", status)
|
|
61
|
-
|
|
61
|
+
raw = response.text
|
|
62
|
+
message = error.get("description", raw[:200] + ("\u2026" if len(raw) > 200 else ""))
|
|
62
63
|
except Exception:
|
|
63
64
|
code = status
|
|
64
|
-
|
|
65
|
+
raw = response.text
|
|
66
|
+
message = raw[:200] + ("\u2026" if len(raw) > 200 else "")
|
|
65
67
|
|
|
66
68
|
if status == 401 or (isinstance(code, int) and 40100 <= code <= 40199):
|
|
67
69
|
raise ArccosAuthError(message, status_code=status, error_code=code)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: arccos-api
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Unofficial Python CLI and client library for the Arccos Golf API
|
|
5
5
|
Author: Paul Frederiksen
|
|
6
6
|
License: MIT
|
|
@@ -17,6 +17,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.11
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.12
|
|
19
19
|
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
21
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
22
|
Requires-Python: >=3.11
|
|
22
23
|
Description-Content-Type: text/markdown
|
|
@@ -83,9 +84,11 @@ arccos clubs # smart club distances
|
|
|
83
84
|
| `arccos bests` | All-time personal bests (lowest score, longest drive, etc.) |
|
|
84
85
|
| `arccos overview` | Performance summary — scoring avg + handicap breakdown |
|
|
85
86
|
| `arccos scoring` | Scoring trend with visual bar chart |
|
|
86
|
-
| `arccos courses` | List
|
|
87
|
+
| `arccos courses` | List courses you've played (or `--search` any course) |
|
|
87
88
|
| `arccos pace` | Pace of play analysis by course (color-coded) |
|
|
88
|
-
| `arccos stats
|
|
89
|
+
| `arccos stats` | Strokes gained analysis (defaults to latest round) |
|
|
90
|
+
| `arccos club-shots <id>` | Shot history for a specific club |
|
|
91
|
+
| `arccos up-and-down` | Scramble / strokes-to-get-down stats |
|
|
89
92
|
| `arccos export` | Export rounds to JSON, CSV, or NDJSON (`--detail` for hole data) |
|
|
90
93
|
| `arccos logout` | Clear cached credentials |
|
|
91
94
|
|
|
@@ -116,9 +119,14 @@ arccos clubs --after 2025-01-01 # distances from this year only
|
|
|
116
119
|
arccos bests # all-time personal bests
|
|
117
120
|
arccos overview # scoring avg + handicap breakdown
|
|
118
121
|
arccos scoring # scoring trend with bar chart
|
|
122
|
+
arccos up-and-down # scramble stats
|
|
119
123
|
|
|
120
124
|
# Courses
|
|
121
125
|
arccos courses # all courses played
|
|
126
|
+
arccos courses --search "torrey" # search any course by name
|
|
127
|
+
|
|
128
|
+
# Club detail
|
|
129
|
+
arccos club-shots 1 # shot history for club ID 1
|
|
122
130
|
|
|
123
131
|
# Pace of play
|
|
124
132
|
arccos pace # all rounds, slowest courses first
|
|
@@ -208,6 +216,13 @@ export ARCCOS_PASSWORD="your_password"
|
|
|
208
216
|
arccos rounds
|
|
209
217
|
```
|
|
210
218
|
|
|
219
|
+
Other environment variables:
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
export ARCCOS_TIMEOUT=30 # request timeout in seconds (default: 15)
|
|
223
|
+
export ARCCOS_LOG_LEVEL=DEBUG # enable debug logging (DEBUG, INFO, WARNING)
|
|
224
|
+
```
|
|
225
|
+
|
|
211
226
|
> **Security note:** Environment variables may be visible in process listings (`ps`) and shell history. Prefer `arccos login` for interactive use.
|
|
212
227
|
|
|
213
228
|
## Python Library
|
|
@@ -257,6 +272,13 @@ for c in pace["course_averages"][:5]:
|
|
|
257
272
|
|
|
258
273
|
# Personal bests
|
|
259
274
|
bests = client.stats.personal_bests()
|
|
275
|
+
|
|
276
|
+
# Iterate ALL rounds (auto-pagination)
|
|
277
|
+
for r in client.rounds.iter_all():
|
|
278
|
+
print(r["roundId"], r["noOfShots"])
|
|
279
|
+
|
|
280
|
+
# Course search
|
|
281
|
+
results = client.courses.search("Torrey Pines")
|
|
260
282
|
```
|
|
261
283
|
|
|
262
284
|
## API Reference
|
|
@@ -290,7 +312,9 @@ All data calls go to `https://api.arccosgolf.com` with `Authorization: Bearer <t
|
|
|
290
312
|
| Courses played | GET | `/users/{userId}/coursesPlayed` |
|
|
291
313
|
| Course metadata | GET | `/courses/{courseId}?courseVersion=1` |
|
|
292
314
|
| Course search | GET | `/v2/courses?search={query}` |
|
|
315
|
+
| Club shots | GET | `/users/{userId}/bags/{bagId}/clubs/{clubId}/shots` |
|
|
293
316
|
| Personal bests | GET | `/users/{userId}/personalBests?tags=allTimeBest` |
|
|
317
|
+
| Strokes to get down | GET | `/v2/sga/strokes-to-get-down` |
|
|
294
318
|
|
|
295
319
|
### Key data structures
|
|
296
320
|
|
|
@@ -343,7 +367,7 @@ python3 -m venv .venv
|
|
|
343
367
|
source .venv/bin/activate
|
|
344
368
|
pip install -e ".[dev]"
|
|
345
369
|
|
|
346
|
-
pytest # tests with coverage (
|
|
370
|
+
pytest # tests with coverage (87%+, 139 tests)
|
|
347
371
|
ruff check arccos/ # lint
|
|
348
372
|
mypy arccos/ # type check
|
|
349
373
|
```
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "arccos-api"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Unofficial Python CLI and client library for the Arccos Golf API"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "MIT"}
|
|
@@ -21,6 +21,7 @@ classifiers = [
|
|
|
21
21
|
"Programming Language :: Python :: 3.11",
|
|
22
22
|
"Programming Language :: Python :: 3.12",
|
|
23
23
|
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Programming Language :: Python :: 3.14",
|
|
24
25
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
25
26
|
]
|
|
26
27
|
dependencies = [
|
|
@@ -726,3 +726,118 @@ class TestExportDetailFlag:
|
|
|
726
726
|
data = json.loads(output[json_start:])
|
|
727
727
|
assert "holes" in data[0]
|
|
728
728
|
assert data[0]["holes"][0]["holeId"] == 1
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
# ---------------------------------------------------------------------------
|
|
732
|
+
# course search
|
|
733
|
+
# ---------------------------------------------------------------------------
|
|
734
|
+
|
|
735
|
+
class TestCourseSearchCommand:
|
|
736
|
+
@patch(_STATS_GET_CLIENT)
|
|
737
|
+
def test_courses_search(self, mock_get, runner):
|
|
738
|
+
client = _mock_client()
|
|
739
|
+
client.courses.played.return_value = [
|
|
740
|
+
{"courseId": 100, "name": "Torrey Pines South", "city": "San Diego",
|
|
741
|
+
"state": "CA"},
|
|
742
|
+
{"courseId": 101, "name": "Torrey Pines North", "city": "San Diego",
|
|
743
|
+
"state": "CA"},
|
|
744
|
+
{"courseId": 200, "name": "Augusta National", "city": "Augusta",
|
|
745
|
+
"state": "GA"},
|
|
746
|
+
]
|
|
747
|
+
mock_get.return_value = client
|
|
748
|
+
|
|
749
|
+
result = runner.invoke(cli, ["courses", "--search", "torrey"])
|
|
750
|
+
assert result.exit_code == 0
|
|
751
|
+
assert "Torrey Pines South" in result.output
|
|
752
|
+
assert "Torrey Pines North" in result.output
|
|
753
|
+
assert "Augusta" not in result.output
|
|
754
|
+
|
|
755
|
+
@patch(_STATS_GET_CLIENT)
|
|
756
|
+
def test_courses_search_empty(self, mock_get, runner):
|
|
757
|
+
client = _mock_client()
|
|
758
|
+
client.courses.played.return_value = [
|
|
759
|
+
{"courseId": 100, "name": "Augusta National"},
|
|
760
|
+
]
|
|
761
|
+
mock_get.return_value = client
|
|
762
|
+
|
|
763
|
+
result = runner.invoke(cli, ["courses", "--search", "xyznotfound"])
|
|
764
|
+
assert result.exit_code == 0
|
|
765
|
+
assert "No courses found" in result.output
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
# ---------------------------------------------------------------------------
|
|
769
|
+
# club-shots
|
|
770
|
+
# ---------------------------------------------------------------------------
|
|
771
|
+
|
|
772
|
+
class TestClubShotsCommand:
|
|
773
|
+
@patch(_STATS_GET_CLIENT)
|
|
774
|
+
def test_club_shots_table(self, mock_get, runner):
|
|
775
|
+
client = _mock_client()
|
|
776
|
+
client._http.get.return_value = {"bagId": "bag-1"}
|
|
777
|
+
client.clubs.club_shots.return_value = [
|
|
778
|
+
{"shotTime": "2025-06-01T12:00:00Z", "distance": 255.3,
|
|
779
|
+
"userStartTerrainOverride": 0},
|
|
780
|
+
{"shotTime": "2025-06-02T14:00:00Z", "distance": 248.1,
|
|
781
|
+
"userStartTerrainOverride": 0},
|
|
782
|
+
]
|
|
783
|
+
client.clubs.bag.return_value = {
|
|
784
|
+
"clubs": [{"clubId": 1, "clubType": 1, "isDeleted": "F"}],
|
|
785
|
+
}
|
|
786
|
+
mock_get.return_value = client
|
|
787
|
+
|
|
788
|
+
result = runner.invoke(cli, ["club-shots", "1"])
|
|
789
|
+
assert result.exit_code == 0
|
|
790
|
+
assert "255y" in result.output
|
|
791
|
+
assert "248y" in result.output
|
|
792
|
+
|
|
793
|
+
@patch(_STATS_GET_CLIENT)
|
|
794
|
+
def test_club_shots_json(self, mock_get, runner):
|
|
795
|
+
client = _mock_client()
|
|
796
|
+
client._http.get.return_value = {"bagId": "bag-1"}
|
|
797
|
+
client.clubs.club_shots.return_value = [{"distance": 255.3}]
|
|
798
|
+
mock_get.return_value = client
|
|
799
|
+
|
|
800
|
+
result = runner.invoke(cli, ["club-shots", "--json", "1"])
|
|
801
|
+
data = json.loads(result.output)
|
|
802
|
+
assert data[0]["distance"] == 255.3
|
|
803
|
+
|
|
804
|
+
@patch(_STATS_GET_CLIENT)
|
|
805
|
+
def test_club_shots_empty(self, mock_get, runner):
|
|
806
|
+
client = _mock_client()
|
|
807
|
+
client._http.get.return_value = {"bagId": "bag-1"}
|
|
808
|
+
client.clubs.club_shots.return_value = []
|
|
809
|
+
mock_get.return_value = client
|
|
810
|
+
|
|
811
|
+
result = runner.invoke(cli, ["club-shots", "99"])
|
|
812
|
+
assert result.exit_code == 0
|
|
813
|
+
assert "No shots found" in result.output
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
# ---------------------------------------------------------------------------
|
|
817
|
+
# up-and-down
|
|
818
|
+
# ---------------------------------------------------------------------------
|
|
819
|
+
|
|
820
|
+
class TestUpAndDownCommand:
|
|
821
|
+
@patch(_STATS_GET_CLIENT)
|
|
822
|
+
def test_up_and_down_table(self, mock_get, runner):
|
|
823
|
+
client = _mock_client()
|
|
824
|
+
client.stats.strokes_to_get_down.return_value = {
|
|
825
|
+
"totalAttempts": 50,
|
|
826
|
+
"successRate": 0.42,
|
|
827
|
+
"avgProximity": 15.3,
|
|
828
|
+
}
|
|
829
|
+
mock_get.return_value = client
|
|
830
|
+
|
|
831
|
+
result = runner.invoke(cli, ["up-and-down"])
|
|
832
|
+
assert result.exit_code == 0
|
|
833
|
+
assert "50" in result.output
|
|
834
|
+
|
|
835
|
+
@patch(_STATS_GET_CLIENT)
|
|
836
|
+
def test_up_and_down_json(self, mock_get, runner):
|
|
837
|
+
client = _mock_client()
|
|
838
|
+
client.stats.strokes_to_get_down.return_value = {"totalAttempts": 50}
|
|
839
|
+
mock_get.return_value = client
|
|
840
|
+
|
|
841
|
+
result = runner.invoke(cli, ["up-and-down", "--json"])
|
|
842
|
+
data = json.loads(result.output)
|
|
843
|
+
assert data["totalAttempts"] == 50
|
|
@@ -83,7 +83,8 @@ class TestRaiseForStatus:
|
|
|
83
83
|
resp = _mock_response(500, text="x" * 500)
|
|
84
84
|
with pytest.raises(ArccosError) as exc_info:
|
|
85
85
|
raise_for_status(resp)
|
|
86
|
-
|
|
86
|
+
# 200 chars + ellipsis indicator
|
|
87
|
+
assert len(str(exc_info.value)) <= 201
|
|
87
88
|
|
|
88
89
|
|
|
89
90
|
class TestArccosError:
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
arccos — Unofficial Python client for the Arccos Golf API.
|
|
3
|
-
|
|
4
|
-
Quick start:
|
|
5
|
-
from arccos import ArccosClient
|
|
6
|
-
|
|
7
|
-
client = ArccosClient(email="you@example.com", password="secret")
|
|
8
|
-
rounds = client.rounds.list()
|
|
9
|
-
print(client.handicap.current())
|
|
10
|
-
"""
|
|
11
|
-
|
|
12
|
-
from importlib.metadata import version as _pkg_version
|
|
13
|
-
|
|
14
|
-
from .auth import ArccosAuth
|
|
15
|
-
from .client import ArccosClient
|
|
16
|
-
from .exceptions import (
|
|
17
|
-
ArccosAuthError,
|
|
18
|
-
ArccosError,
|
|
19
|
-
ArccosForbiddenError,
|
|
20
|
-
ArccosNotFoundError,
|
|
21
|
-
ArccosRateLimitError,
|
|
22
|
-
)
|
|
23
|
-
|
|
24
|
-
__version__ = _pkg_version("arccos-api")
|
|
25
|
-
__all__ = [
|
|
26
|
-
"ArccosClient",
|
|
27
|
-
"ArccosAuth",
|
|
28
|
-
"ArccosError",
|
|
29
|
-
"ArccosAuthError",
|
|
30
|
-
"ArccosForbiddenError",
|
|
31
|
-
"ArccosNotFoundError",
|
|
32
|
-
"ArccosRateLimitError",
|
|
33
|
-
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|