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.
Files changed (38) hide show
  1. {arccos_api-0.2.0 → arccos_api-0.3.0}/PKG-INFO +28 -4
  2. {arccos_api-0.2.0 → arccos_api-0.3.0}/README.md +26 -3
  3. arccos_api-0.3.0/arccos/__init__.py +65 -0
  4. {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos/_http.py +16 -3
  5. {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos/auth.py +1 -1
  6. {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos/cli/__init__.py +2 -0
  7. {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos/cli/stats.py +132 -2
  8. {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos/client.py +1 -1
  9. {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos/exceptions.py +4 -2
  10. {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos_api.egg-info/PKG-INFO +28 -4
  11. {arccos_api-0.2.0 → arccos_api-0.3.0}/pyproject.toml +2 -1
  12. {arccos_api-0.2.0 → arccos_api-0.3.0}/tests/test_cli.py +115 -0
  13. {arccos_api-0.2.0 → arccos_api-0.3.0}/tests/test_exceptions.py +2 -1
  14. arccos_api-0.2.0/arccos/__init__.py +0 -33
  15. {arccos_api-0.2.0 → arccos_api-0.3.0}/MANIFEST.in +0 -0
  16. {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos/__main__.py +0 -0
  17. {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos/cli/_helpers.py +0 -0
  18. {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos/cli/auth.py +0 -0
  19. {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos/cli/rounds.py +0 -0
  20. {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos/resources/__init__.py +0 -0
  21. {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos/resources/clubs.py +0 -0
  22. {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos/resources/courses.py +0 -0
  23. {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos/resources/handicap.py +0 -0
  24. {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos/resources/rounds.py +0 -0
  25. {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos/resources/stats.py +0 -0
  26. {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos/types.py +0 -0
  27. {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos_api.egg-info/SOURCES.txt +0 -0
  28. {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos_api.egg-info/dependency_links.txt +0 -0
  29. {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos_api.egg-info/entry_points.txt +0 -0
  30. {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos_api.egg-info/requires.txt +0 -0
  31. {arccos_api-0.2.0 → arccos_api-0.3.0}/arccos_api.egg-info/top_level.txt +0 -0
  32. {arccos_api-0.2.0 → arccos_api-0.3.0}/setup.cfg +0 -0
  33. {arccos_api-0.2.0 → arccos_api-0.3.0}/tests/test_auth.py +0 -0
  34. {arccos_api-0.2.0 → arccos_api-0.3.0}/tests/test_client.py +0 -0
  35. {arccos_api-0.2.0 → arccos_api-0.3.0}/tests/test_http.py +0 -0
  36. {arccos_api-0.2.0 → arccos_api-0.3.0}/tests/test_resources.py +0 -0
  37. {arccos_api-0.2.0 → arccos_api-0.3.0}/tests/test_rounds.py +0 -0
  38. {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.2.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 all courses you've played |
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 <id>` | Strokes gained analysis for a round |
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 (88%+, 132 tests)
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 all courses you've played |
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 <id>` | Strokes gained analysis for a round |
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 (88%+, 132 tests)
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
- self._timeout = int(os.environ.get("ARCCOS_TIMEOUT", DEFAULT_TIMEOUT))
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)
@@ -87,7 +87,7 @@ class Credentials:
87
87
  os.fchmod(fd, 0o600)
88
88
  with os.fdopen(fd, "w") as f:
89
89
  json.dump(self.to_dict(), f, indent=2)
90
- except BaseException:
90
+ except Exception:
91
91
  with contextlib.suppress(OSError):
92
92
  os.close(fd)
93
93
  raise
@@ -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)
@@ -95,7 +95,7 @@ class ArccosClient:
95
95
  else:
96
96
  raise ValueError(
97
97
  "Provide email and password, or ensure cached credentials exist "
98
- f"at {creds_path}."
98
+ "at ~/.arccos_creds.json."
99
99
  )
100
100
 
101
101
  self._http = HttpClient(auth=self._auth, creds=self._creds)
@@ -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
- message = error.get("description", response.text[:200])
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
- message = response.text[:200]
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.2.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 all courses you've played |
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 <id>` | Strokes gained analysis for a round |
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 (88%+, 132 tests)
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.2.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
- assert len(str(exc_info.value)) <= 200
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