arccos-api 0.3.0__tar.gz → 0.4.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.3.0/arccos_api.egg-info → arccos_api-0.4.0}/PKG-INFO +14 -12
  2. {arccos_api-0.3.0 → arccos_api-0.4.0}/README.md +13 -11
  3. arccos_api-0.4.0/arccos/cli/__init__.py +78 -0
  4. {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos/cli/auth.py +29 -0
  5. {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos/cli/rounds.py +122 -0
  6. {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos/cli/stats.py +82 -2
  7. {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos/exceptions.py +1 -1
  8. {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos/resources/courses.py +1 -1
  9. {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos/resources/rounds.py +6 -2
  10. {arccos_api-0.3.0 → arccos_api-0.4.0/arccos_api.egg-info}/PKG-INFO +14 -12
  11. {arccos_api-0.3.0 → arccos_api-0.4.0}/pyproject.toml +1 -1
  12. {arccos_api-0.3.0 → arccos_api-0.4.0}/tests/test_cli.py +118 -0
  13. arccos_api-0.3.0/arccos/cli/__init__.py +0 -56
  14. {arccos_api-0.3.0 → arccos_api-0.4.0}/MANIFEST.in +0 -0
  15. {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos/__init__.py +0 -0
  16. {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos/__main__.py +0 -0
  17. {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos/_http.py +0 -0
  18. {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos/auth.py +0 -0
  19. {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos/cli/_helpers.py +0 -0
  20. {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos/client.py +0 -0
  21. {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos/resources/__init__.py +0 -0
  22. {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos/resources/clubs.py +0 -0
  23. {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos/resources/handicap.py +0 -0
  24. {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos/resources/stats.py +0 -0
  25. {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos/types.py +0 -0
  26. {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos_api.egg-info/SOURCES.txt +0 -0
  27. {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos_api.egg-info/dependency_links.txt +0 -0
  28. {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos_api.egg-info/entry_points.txt +0 -0
  29. {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos_api.egg-info/requires.txt +0 -0
  30. {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos_api.egg-info/top_level.txt +0 -0
  31. {arccos_api-0.3.0 → arccos_api-0.4.0}/setup.cfg +0 -0
  32. {arccos_api-0.3.0 → arccos_api-0.4.0}/tests/test_auth.py +0 -0
  33. {arccos_api-0.3.0 → arccos_api-0.4.0}/tests/test_client.py +0 -0
  34. {arccos_api-0.3.0 → arccos_api-0.4.0}/tests/test_exceptions.py +0 -0
  35. {arccos_api-0.3.0 → arccos_api-0.4.0}/tests/test_http.py +0 -0
  36. {arccos_api-0.3.0 → arccos_api-0.4.0}/tests/test_resources.py +0 -0
  37. {arccos_api-0.3.0 → arccos_api-0.4.0}/tests/test_rounds.py +0 -0
  38. {arccos_api-0.3.0 → arccos_api-0.4.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.0
3
+ Version: 0.4.0
4
4
  Summary: Unofficial Python CLI and client library for the Arccos Golf API
5
5
  Author: Paul Frederiksen
6
6
  License: MIT
@@ -84,12 +84,15 @@ arccos clubs # smart club distances
84
84
  | `arccos bests` | All-time personal bests (lowest score, longest drive, etc.) |
85
85
  | `arccos overview` | Performance summary — scoring avg + handicap breakdown |
86
86
  | `arccos scoring` | Scoring trend with visual bar chart |
87
- | `arccos courses` | List courses you've played (or `--search` any course) |
87
+ | `arccos compare <a> <b>` | Side-by-side hole-by-hole round comparison |
88
+ | `arccos courses` | List courses you've played (or `--search` to filter) |
88
89
  | `arccos pace` | Pace of play analysis by course (color-coded) |
89
90
  | `arccos stats` | Strokes gained analysis (defaults to latest round) |
91
+ | `arccos trends` | Handicap trend chart over recent rounds |
90
92
  | `arccos club-shots <id>` | Shot history for a specific club |
91
93
  | `arccos up-and-down` | Scramble / strokes-to-get-down stats |
92
94
  | `arccos export` | Export rounds to JSON, CSV, or NDJSON (`--detail` for hole data) |
95
+ | `arccos completions <shell>` | Generate bash/zsh/fish completion script |
93
96
  | `arccos logout` | Clear cached credentials |
94
97
 
95
98
  Every command supports `--json` for raw JSON output and `--help` for usage info.
@@ -119,15 +122,23 @@ arccos clubs --after 2025-01-01 # distances from this year only
119
122
  arccos bests # all-time personal bests
120
123
  arccos overview # scoring avg + handicap breakdown
121
124
  arccos scoring # scoring trend with bar chart
125
+ arccos trends # handicap trend chart
122
126
  arccos up-and-down # scramble stats
123
127
 
128
+ # Compare rounds
129
+ arccos compare 26685289 26762935 # side-by-side hole comparison
130
+
124
131
  # Courses
125
132
  arccos courses # all courses played
126
- arccos courses --search "torrey" # search any course by name
133
+ arccos courses --search "torrey" # filter by name or city
127
134
 
128
135
  # Club detail
129
136
  arccos club-shots 1 # shot history for club ID 1
130
137
 
138
+ # Shell completions
139
+ eval "$(arccos completions bash)" # enable tab completion
140
+ eval "$(arccos completions zsh)" # for zsh
141
+
131
142
  # Pace of play
132
143
  arccos pace # all rounds, slowest courses first
133
144
  arccos pace -n 20 # last 20 rounds only
@@ -381,15 +392,6 @@ Coverage report: `htmlcov/index.html`. Minimum threshold: 80% (configured in `py
381
392
  - `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.
382
393
  - The export command restricts output paths to your home directory or current working directory.
383
394
 
384
- ## Known Gaps
385
-
386
- - [ ] SGA endpoint auth (`/v2/sga/shots/` returns 40102 — may need HMAC via `secret`)
387
- - [ ] Social/feed endpoints (`/users/{id}/feed`, follows, etc.)
388
- - [ ] Driving range session data (`roundType=range`)
389
- - [ ] Async client (`httpx`)
390
-
391
- PRs welcome.
392
-
393
395
  ## Disclaimer
394
396
 
395
397
  Not affiliated with, endorsed by, or connected to Arccos Golf LLC.
@@ -49,12 +49,15 @@ 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 courses you've played (or `--search` any course) |
52
+ | `arccos compare <a> <b>` | Side-by-side hole-by-hole round comparison |
53
+ | `arccos courses` | List courses you've played (or `--search` to filter) |
53
54
  | `arccos pace` | Pace of play analysis by course (color-coded) |
54
55
  | `arccos stats` | Strokes gained analysis (defaults to latest round) |
56
+ | `arccos trends` | Handicap trend chart over recent rounds |
55
57
  | `arccos club-shots <id>` | Shot history for a specific club |
56
58
  | `arccos up-and-down` | Scramble / strokes-to-get-down stats |
57
59
  | `arccos export` | Export rounds to JSON, CSV, or NDJSON (`--detail` for hole data) |
60
+ | `arccos completions <shell>` | Generate bash/zsh/fish completion script |
58
61
  | `arccos logout` | Clear cached credentials |
59
62
 
60
63
  Every command supports `--json` for raw JSON output and `--help` for usage info.
@@ -84,15 +87,23 @@ arccos clubs --after 2025-01-01 # distances from this year only
84
87
  arccos bests # all-time personal bests
85
88
  arccos overview # scoring avg + handicap breakdown
86
89
  arccos scoring # scoring trend with bar chart
90
+ arccos trends # handicap trend chart
87
91
  arccos up-and-down # scramble stats
88
92
 
93
+ # Compare rounds
94
+ arccos compare 26685289 26762935 # side-by-side hole comparison
95
+
89
96
  # Courses
90
97
  arccos courses # all courses played
91
- arccos courses --search "torrey" # search any course by name
98
+ arccos courses --search "torrey" # filter by name or city
92
99
 
93
100
  # Club detail
94
101
  arccos club-shots 1 # shot history for club ID 1
95
102
 
103
+ # Shell completions
104
+ eval "$(arccos completions bash)" # enable tab completion
105
+ eval "$(arccos completions zsh)" # for zsh
106
+
96
107
  # Pace of play
97
108
  arccos pace # all rounds, slowest courses first
98
109
  arccos pace -n 20 # last 20 rounds only
@@ -346,15 +357,6 @@ Coverage report: `htmlcov/index.html`. Minimum threshold: 80% (configured in `py
346
357
  - `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.
347
358
  - The export command restricts output paths to your home directory or current working directory.
348
359
 
349
- ## Known Gaps
350
-
351
- - [ ] SGA endpoint auth (`/v2/sga/shots/` returns 40102 — may need HMAC via `secret`)
352
- - [ ] Social/feed endpoints (`/users/{id}/feed`, follows, etc.)
353
- - [ ] Driving range session data (`roundType=range`)
354
- - [ ] Async client (`httpx`)
355
-
356
- PRs welcome.
357
-
358
360
  ## Disclaimer
359
361
 
360
362
  Not affiliated with, endorsed by, or connected to Arccos Golf LLC.
@@ -0,0 +1,78 @@
1
+ """
2
+ arccos CLI — pull your Arccos Golf data from the terminal.
3
+
4
+ Uses the reverse-engineered Arccos Golf API. Not affiliated with Arccos Golf LLC.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import click
10
+ from rich import box
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+
14
+ from . import auth, rounds, stats
15
+
16
+ _console = Console(stderr=True)
17
+
18
+
19
+ class RichGroup(click.Group):
20
+ """Click group that renders help with Rich formatting."""
21
+
22
+ def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
23
+ _console.print(
24
+ "\n[bold cyan]arccos[/bold cyan] — Your Arccos Golf data in the terminal.\n"
25
+ )
26
+
27
+ table = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))
28
+ table.add_column(style="bold green", no_wrap=True)
29
+ table.add_column(style="dim")
30
+
31
+ cmds = [
32
+ ("login", "Authenticate and save credentials"),
33
+ ("rounds", "List recent rounds with +/- and course names"),
34
+ ("round <id>", "Hole-by-hole detail for a round"),
35
+ ("compare <a> <b>", "Side-by-side round comparison"),
36
+ ("handicap", "Current handicap index breakdown"),
37
+ ("clubs", "Smart club distances with make/model"),
38
+ ("club-shots <id>", "Shot history for a specific club"),
39
+ ("courses", "Courses you've played (--search to filter)"),
40
+ ("bests", "All-time personal bests"),
41
+ ("overview", "Scoring avg + handicap breakdown"),
42
+ ("scoring", "Scoring trend with bar chart"),
43
+ ("trends", "Handicap trend over time"),
44
+ ("pace", "Pace of play by course"),
45
+ ("stats", "Strokes gained analysis"),
46
+ ("up-and-down", "Scramble / strokes-to-get-down stats"),
47
+ ("export", "Export rounds to JSON/CSV/NDJSON"),
48
+ ("completions", "Generate shell completion script"),
49
+ ("logout", "Clear cached credentials"),
50
+ ]
51
+ for cmd, desc in cmds:
52
+ table.add_row(f"arccos {cmd}", desc)
53
+
54
+ _console.print(table)
55
+ _console.print(
56
+ "\n[dim]All commands support [bold]--json[/bold] and [bold]--help[/bold]. "
57
+ "Credentials cached in ~/.arccos_creds.json.[/dim]\n"
58
+ )
59
+
60
+
61
+ @click.group(cls=RichGroup)
62
+ @click.version_option(package_name="arccos-api", prog_name="arccos")
63
+ def cli():
64
+ """arccos — Your Arccos Golf data in the terminal."""
65
+
66
+
67
+ # Register all command submodules
68
+ auth.register(cli)
69
+ rounds.register(cli)
70
+ stats.register(cli)
71
+
72
+
73
+ def main():
74
+ cli()
75
+
76
+
77
+ if __name__ == "__main__":
78
+ main()
@@ -12,6 +12,7 @@ from ._helpers import console, err_console
12
12
  def register(cli: click.Group) -> None:
13
13
  cli.add_command(login)
14
14
  cli.add_command(logout)
15
+ cli.add_command(completions)
15
16
 
16
17
 
17
18
  @click.command()
@@ -53,3 +54,31 @@ def logout():
53
54
  )
54
55
  else:
55
56
  console.print("[dim]No cached credentials found.[/dim]")
57
+
58
+
59
+ @click.command()
60
+ @click.argument("shell", type=click.Choice(["bash", "zsh", "fish"]))
61
+ def completions(shell: str):
62
+ """Generate shell completion script.
63
+
64
+ \b
65
+ Usage:
66
+ eval "$(arccos completions bash)"
67
+ eval "$(arccos completions zsh)"
68
+ arccos completions fish | source
69
+
70
+ \b
71
+ To install permanently, add the eval line to your shell profile
72
+ (~/.bashrc, ~/.zshrc, or ~/.config/fish/completions/arccos.fish).
73
+ """
74
+ import os
75
+ import subprocess
76
+
77
+ env = {**os.environ, "_ARCCOS_COMPLETE": f"{shell}_source"}
78
+ result = subprocess.run(
79
+ ["arccos"],
80
+ env=env,
81
+ capture_output=True,
82
+ text=True,
83
+ )
84
+ click.echo(result.stdout)
@@ -26,6 +26,7 @@ from ._helpers import (
26
26
  def register(cli: click.Group) -> None:
27
27
  cli.add_command(rounds)
28
28
  cli.add_command(round_detail)
29
+ cli.add_command(compare)
29
30
  cli.add_command(export)
30
31
  cli.add_command(pace)
31
32
  cli.add_command(scoring)
@@ -239,6 +240,127 @@ def round_detail(round_id: int, as_json: bool):
239
240
  console.print(table)
240
241
 
241
242
 
243
+ # ---------------------------------------------------------------------------
244
+ # compare
245
+ # ---------------------------------------------------------------------------
246
+
247
+ @click.command()
248
+ @click.argument("round_a", type=int)
249
+ @click.argument("round_b", type=int)
250
+ @click.option("--json", "as_json", is_flag=True, help="Output raw JSON.")
251
+ def compare(round_a: int, round_b: int, as_json: bool):
252
+ """Compare two rounds side-by-side, hole by hole.
253
+
254
+ \b
255
+ Examples:
256
+ arccos compare 26685289 26762935
257
+ """
258
+ client = _get_client()
259
+
260
+ with console.status("Fetching rounds\u2026"):
261
+ meta_a = client.rounds.get(round_a)
262
+ meta_b = client.rounds.get(round_b)
263
+ course_map, par_map = _build_course_map(client)
264
+
265
+ if as_json:
266
+ _output_json({"round_a": meta_a, "round_b": meta_b})
267
+ return
268
+
269
+ def _course_name(meta: dict) -> str:
270
+ cid: int = meta.get("courseId", 0)
271
+ return course_map.get(cid) or meta.get("courseName") or str(cid or "\u2014")
272
+
273
+ def _ou(meta: dict) -> str:
274
+ shots = meta.get("noOfShots")
275
+ cid: int = meta.get("courseId", 0)
276
+ par = par_map.get(cid)
277
+ if shots and par and meta.get("noOfHoles") == 18:
278
+ return f" ({shots - par:+d})"
279
+ return ""
280
+
281
+ # Summary panel
282
+ date_a = (meta_a.get("startTime") or "")[:10]
283
+ date_b = (meta_b.get("startTime") or "")[:10]
284
+ score_a = meta_a.get("noOfShots", "\u2014")
285
+ score_b = meta_b.get("noOfShots", "\u2014")
286
+
287
+ summary = (
288
+ f"[bold cyan]Round A:[/bold cyan] {date_a} {_course_name(meta_a)} "
289
+ f"[bold]{score_a}{_ou(meta_a)}[/bold]\n"
290
+ f"[bold cyan]Round B:[/bold cyan] {date_b} {_course_name(meta_b)} "
291
+ f"[bold]{score_b}{_ou(meta_b)}[/bold]"
292
+ )
293
+ console.print(Panel(summary, title="[bold]Round Comparison[/bold]", expand=False))
294
+
295
+ holes_a = {h["holeId"]: h for h in meta_a.get("holes", [])}
296
+ holes_b = {h["holeId"]: h for h in meta_b.get("holes", [])}
297
+
298
+ if not holes_a and not holes_b:
299
+ console.print("[dim]No hole data available for either round.[/dim]")
300
+ return
301
+
302
+ all_holes = sorted(set(holes_a.keys()) | set(holes_b.keys()))
303
+
304
+ table = Table(
305
+ box=box.ROUNDED,
306
+ header_style="bold cyan",
307
+ title="[bold]Hole-by-Hole Comparison[/bold]",
308
+ )
309
+ table.add_column("Hole", justify="right")
310
+ table.add_column("A", justify="right", style="bold")
311
+ table.add_column("B", justify="right", style="bold")
312
+ table.add_column("Diff", justify="right")
313
+ table.add_column("A Putts", justify="right", style="dim")
314
+ table.add_column("B Putts", justify="right", style="dim")
315
+
316
+ total_a = total_b = total_putts_a = total_putts_b = 0
317
+ for h_id in all_holes:
318
+ ha = holes_a.get(h_id, {})
319
+ hb = holes_b.get(h_id, {})
320
+ sa = ha.get("noOfShots")
321
+ sb = hb.get("noOfShots")
322
+ pa = ha.get("putts")
323
+ pb = hb.get("putts")
324
+
325
+ sa_s = str(sa) if sa is not None else "\u2014"
326
+ sb_s = str(sb) if sb is not None else "\u2014"
327
+ pa_s = str(pa) if pa is not None else "\u2014"
328
+ pb_s = str(pb) if pb is not None else "\u2014"
329
+
330
+ if sa is not None and sb is not None:
331
+ diff = sb - sa
332
+ color = "green" if diff < 0 else "red" if diff > 0 else "dim"
333
+ diff_s = f"[{color}]{diff:+d}[/{color}]"
334
+ else:
335
+ diff_s = ""
336
+
337
+ if sa:
338
+ total_a += sa
339
+ if sb:
340
+ total_b += sb
341
+ if pa:
342
+ total_putts_a += pa
343
+ if pb:
344
+ total_putts_b += pb
345
+
346
+ table.add_row(str(h_id), sa_s, sb_s, diff_s, pa_s, pb_s)
347
+
348
+ # Totals
349
+ table.add_section()
350
+ total_diff = total_b - total_a
351
+ diff_color = "green" if total_diff < 0 else "red" if total_diff > 0 else "dim"
352
+ table.add_row(
353
+ "[bold]Tot[/bold]",
354
+ f"[bold]{total_a}[/bold]",
355
+ f"[bold]{total_b}[/bold]",
356
+ f"[{diff_color}][bold]{total_diff:+d}[/bold][/{diff_color}]",
357
+ f"[bold]{total_putts_a}[/bold]",
358
+ f"[bold]{total_putts_b}[/bold]",
359
+ )
360
+
361
+ console.print(table)
362
+
363
+
242
364
  # ---------------------------------------------------------------------------
243
365
  # export
244
366
  # ---------------------------------------------------------------------------
@@ -1,7 +1,8 @@
1
- """Stats-related CLI commands: handicap, clubs, stats, bests, overview, courses."""
1
+ """Stats-related CLI commands: handicap, clubs, stats, bests, overview, courses, trends."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import builtins as _builtins
5
6
  import sys
6
7
 
7
8
  import click
@@ -12,6 +13,8 @@ from rich.text import Text
12
13
 
13
14
  from ._helpers import _get_client, _output_json, console, err_console
14
15
 
16
+ builtins_list = _builtins.list
17
+
15
18
 
16
19
  def register(cli: click.Group) -> None:
17
20
  cli.add_command(handicap)
@@ -20,6 +23,7 @@ def register(cli: click.Group) -> None:
20
23
  cli.add_command(stats)
21
24
  cli.add_command(bests)
22
25
  cli.add_command(overview)
26
+ cli.add_command(trends)
23
27
  cli.add_command(courses)
24
28
  cli.add_command(up_and_down)
25
29
 
@@ -551,7 +555,7 @@ def club_shots(club_id: int, limit: int, as_json: bool):
551
555
  # Try to resolve club name
552
556
  try:
553
557
  bag = client.clubs.bag(str(bag_id))
554
- bag_club = next(
558
+ bag_club: dict = next(
555
559
  (c for c in bag.get("clubs", []) if c["clubId"] == club_id), {}
556
560
  )
557
561
  club_type = bag_club.get("clubType", club_id)
@@ -632,3 +636,79 @@ def up_and_down(as_json: bool):
632
636
  table.add_row(f"{section}.{k}", display)
633
637
 
634
638
  console.print(table)
639
+
640
+
641
+ # ---------------------------------------------------------------------------
642
+ # trends
643
+ # ---------------------------------------------------------------------------
644
+
645
+ @click.command()
646
+ @click.option("--rounds", "-n", default=20, show_default=True,
647
+ help="Number of rounds to include.")
648
+ @click.option("--json", "as_json", is_flag=True, help="Output raw JSON.")
649
+ def trends(rounds: int, as_json: bool):
650
+ """Show handicap trend over recent rounds."""
651
+ client = _get_client()
652
+
653
+ with console.status("Fetching handicap history\u2026"):
654
+ data = client.handicap.history(rounds=rounds)
655
+
656
+ if as_json:
657
+ _output_json(data)
658
+ return
659
+
660
+ if not data or not isinstance(data, builtins_list):
661
+ console.print("[dim]No handicap history available.[/dim]")
662
+ return
663
+
664
+ # Extract HCP values
665
+ entries = []
666
+ for entry in data:
667
+ if isinstance(entry, dict) and "userHcp" in entry:
668
+ entries.append(entry)
669
+
670
+ if not entries:
671
+ console.print("[dim]No handicap data found in history.[/dim]")
672
+ return
673
+
674
+ hcps = [e["userHcp"] for e in entries]
675
+ best = min(hcps)
676
+ worst = max(hcps)
677
+ current = hcps[0]
678
+ change = hcps[0] - hcps[-1] if len(hcps) > 1 else 0.0
679
+
680
+ # Summary
681
+ change_color = "green" if change < 0 else "red" if change > 0 else "dim"
682
+ console.print(
683
+ f"\n[bold]Handicap Trend[/bold] [dim]({len(entries)} rounds)[/dim]\n"
684
+ f" Current: [bold cyan]{current:.1f}[/bold cyan] "
685
+ f"Best: [bold green]{best:.1f}[/bold green] "
686
+ f"Worst: [bold red]{worst:.1f}[/bold red] "
687
+ f"Change: [{change_color}]{change:+.1f}[/{change_color}]\n"
688
+ )
689
+
690
+ # Sparkline chart
691
+ hcp_min = min(hcps) - 1
692
+ hcp_max = max(hcps) + 1
693
+ hcp_range = hcp_max - hcp_min if hcp_max != hcp_min else 1
694
+
695
+ table = Table(
696
+ box=box.ROUNDED,
697
+ header_style="bold cyan",
698
+ title="[bold]HCP Over Time[/bold] (newest first)",
699
+ )
700
+ table.add_column("Round", justify="right", style="dim")
701
+ table.add_column("HCP", justify="right", style="bold")
702
+ table.add_column("", width=20)
703
+
704
+ for i, entry in enumerate(entries):
705
+ hcp = entry["userHcp"]
706
+ rid = str(entry.get("roundId", i + 1))
707
+ # Normalized bar position (lower = better for golf)
708
+ bar_len = int((hcp - hcp_min) / hcp_range * 18) + 1
709
+ color = "green" if hcp <= best + 1 else "red" if hcp >= worst - 1 else "yellow"
710
+ bar = f"[{color}]{'█' * bar_len}[/{color}]"
711
+ table.add_row(rid, f"{hcp:.1f}", bar)
712
+
713
+ console.print(table)
714
+ console.print("[dim]Lower = better. Green = near best, red = near worst.[/dim]")
@@ -6,7 +6,7 @@ Custom exceptions for the Arccos API client.
6
6
  class ArccosError(Exception):
7
7
  """Base exception for all Arccos API errors."""
8
8
 
9
- def __init__(self, message: str, status_code: int = None, error_code: int = None):
9
+ def __init__(self, message: str, status_code: int | None = None, error_code: int | None = None):
10
10
  super().__init__(message)
11
11
  self.status_code = status_code
12
12
  self.error_code = error_code
@@ -24,7 +24,7 @@ class CoursesResource:
24
24
  print(course["courseName"])
25
25
  """
26
26
 
27
- def __init__(self, http: HttpClient, user_id: str = None):
27
+ def __init__(self, http: HttpClient, user_id: str | None = None):
28
28
  self._http = http
29
29
  self._user_id = user_id
30
30
 
@@ -6,11 +6,15 @@ Endpoint prefix: /users/{userId}/rounds
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import builtins
9
10
  from collections.abc import Iterator
10
11
  from datetime import datetime
11
12
 
12
13
  from .._http import HttpClient
13
14
 
15
+ # Alias to avoid shadowing by the RoundsResource.list method
16
+ _list = builtins.list
17
+
14
18
 
15
19
  class RoundsResource:
16
20
  """
@@ -127,7 +131,7 @@ class RoundsResource:
127
131
  """
128
132
  return self._http.get(f"/users/{self._user_id}/rounds/{round_id}")
129
133
 
130
- def holes(self, round_id: int | str) -> list[dict]:
134
+ def holes(self, round_id: int | str) -> _list[dict]:
131
135
  """
132
136
  Fetch hole-by-hole data for a round.
133
137
 
@@ -143,7 +147,7 @@ class RoundsResource:
143
147
  # Analysis helpers
144
148
  # ------------------------------------------------------------------
145
149
 
146
- def pace_of_play(self, rounds: list[dict] = None) -> dict:
150
+ def pace_of_play(self, rounds: _list[dict] | None = None) -> dict:
147
151
  """
148
152
  Compute pace of play (round duration) per course.
149
153
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arccos-api
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Unofficial Python CLI and client library for the Arccos Golf API
5
5
  Author: Paul Frederiksen
6
6
  License: MIT
@@ -84,12 +84,15 @@ arccos clubs # smart club distances
84
84
  | `arccos bests` | All-time personal bests (lowest score, longest drive, etc.) |
85
85
  | `arccos overview` | Performance summary — scoring avg + handicap breakdown |
86
86
  | `arccos scoring` | Scoring trend with visual bar chart |
87
- | `arccos courses` | List courses you've played (or `--search` any course) |
87
+ | `arccos compare <a> <b>` | Side-by-side hole-by-hole round comparison |
88
+ | `arccos courses` | List courses you've played (or `--search` to filter) |
88
89
  | `arccos pace` | Pace of play analysis by course (color-coded) |
89
90
  | `arccos stats` | Strokes gained analysis (defaults to latest round) |
91
+ | `arccos trends` | Handicap trend chart over recent rounds |
90
92
  | `arccos club-shots <id>` | Shot history for a specific club |
91
93
  | `arccos up-and-down` | Scramble / strokes-to-get-down stats |
92
94
  | `arccos export` | Export rounds to JSON, CSV, or NDJSON (`--detail` for hole data) |
95
+ | `arccos completions <shell>` | Generate bash/zsh/fish completion script |
93
96
  | `arccos logout` | Clear cached credentials |
94
97
 
95
98
  Every command supports `--json` for raw JSON output and `--help` for usage info.
@@ -119,15 +122,23 @@ arccos clubs --after 2025-01-01 # distances from this year only
119
122
  arccos bests # all-time personal bests
120
123
  arccos overview # scoring avg + handicap breakdown
121
124
  arccos scoring # scoring trend with bar chart
125
+ arccos trends # handicap trend chart
122
126
  arccos up-and-down # scramble stats
123
127
 
128
+ # Compare rounds
129
+ arccos compare 26685289 26762935 # side-by-side hole comparison
130
+
124
131
  # Courses
125
132
  arccos courses # all courses played
126
- arccos courses --search "torrey" # search any course by name
133
+ arccos courses --search "torrey" # filter by name or city
127
134
 
128
135
  # Club detail
129
136
  arccos club-shots 1 # shot history for club ID 1
130
137
 
138
+ # Shell completions
139
+ eval "$(arccos completions bash)" # enable tab completion
140
+ eval "$(arccos completions zsh)" # for zsh
141
+
131
142
  # Pace of play
132
143
  arccos pace # all rounds, slowest courses first
133
144
  arccos pace -n 20 # last 20 rounds only
@@ -381,15 +392,6 @@ Coverage report: `htmlcov/index.html`. Minimum threshold: 80% (configured in `py
381
392
  - `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.
382
393
  - The export command restricts output paths to your home directory or current working directory.
383
394
 
384
- ## Known Gaps
385
-
386
- - [ ] SGA endpoint auth (`/v2/sga/shots/` returns 40102 — may need HMAC via `secret`)
387
- - [ ] Social/feed endpoints (`/users/{id}/feed`, follows, etc.)
388
- - [ ] Driving range session data (`roundType=range`)
389
- - [ ] Async client (`httpx`)
390
-
391
- PRs welcome.
392
-
393
395
  ## Disclaimer
394
396
 
395
397
  Not affiliated with, endorsed by, or connected to Arccos Golf LLC.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "arccos-api"
7
- version = "0.3.0"
7
+ version = "0.4.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"}
@@ -841,3 +841,121 @@ class TestUpAndDownCommand:
841
841
  result = runner.invoke(cli, ["up-and-down", "--json"])
842
842
  data = json.loads(result.output)
843
843
  assert data["totalAttempts"] == 50
844
+
845
+
846
+ # ---------------------------------------------------------------------------
847
+ # compare
848
+ # ---------------------------------------------------------------------------
849
+
850
+ class TestCompareCommand:
851
+ @patch(_ROUNDS_GET_CLIENT)
852
+ def test_compare_table(self, mock_get, runner):
853
+ client = _mock_client()
854
+ client.rounds.get.side_effect = [
855
+ {
856
+ "roundId": 1, "courseId": 100, "noOfShots": 85, "noOfHoles": 18,
857
+ "startTime": "2025-06-01T08:00:00Z",
858
+ "holes": [
859
+ {"holeId": 1, "noOfShots": 5, "putts": 2},
860
+ {"holeId": 2, "noOfShots": 4, "putts": 2},
861
+ {"holeId": 3, "noOfShots": 3, "putts": 1},
862
+ ],
863
+ },
864
+ {
865
+ "roundId": 2, "courseId": 100, "noOfShots": 82, "noOfHoles": 18,
866
+ "startTime": "2025-07-01T08:00:00Z",
867
+ "holes": [
868
+ {"holeId": 1, "noOfShots": 4, "putts": 1},
869
+ {"holeId": 2, "noOfShots": 4, "putts": 2},
870
+ {"holeId": 3, "noOfShots": 4, "putts": 2},
871
+ ],
872
+ },
873
+ ]
874
+ mock_get.return_value = client
875
+
876
+ result = runner.invoke(cli, ["compare", "1", "2"])
877
+ assert result.exit_code == 0
878
+ assert "Round A" in result.output
879
+ assert "Round B" in result.output
880
+ assert "Comparison" in result.output
881
+
882
+ @patch(_ROUNDS_GET_CLIENT)
883
+ def test_compare_json(self, mock_get, runner):
884
+ client = _mock_client()
885
+ client.rounds.get.side_effect = [
886
+ {"roundId": 1, "holes": []},
887
+ {"roundId": 2, "holes": []},
888
+ ]
889
+ mock_get.return_value = client
890
+
891
+ result = runner.invoke(cli, ["compare", "--json", "1", "2"])
892
+ data = json.loads(result.output)
893
+ assert "round_a" in data
894
+ assert "round_b" in data
895
+
896
+
897
+ # ---------------------------------------------------------------------------
898
+ # trends
899
+ # ---------------------------------------------------------------------------
900
+
901
+ class TestTrendsCommand:
902
+ @patch(_STATS_GET_CLIENT)
903
+ def test_trends_chart(self, mock_get, runner):
904
+ client = _mock_client()
905
+ client.handicap.history.return_value = [
906
+ {"roundId": 1, "userHcp": -15.0, "driveHcp": -10.0},
907
+ {"roundId": 2, "userHcp": -16.0, "driveHcp": -11.0},
908
+ {"roundId": 3, "userHcp": -14.5, "driveHcp": -9.5},
909
+ ]
910
+ mock_get.return_value = client
911
+
912
+ result = runner.invoke(cli, ["trends"])
913
+ assert result.exit_code == 0
914
+ assert "Handicap Trend" in result.output
915
+ assert "-15.0" in result.output
916
+ assert "-16.0" in result.output
917
+
918
+ @patch(_STATS_GET_CLIENT)
919
+ def test_trends_json(self, mock_get, runner):
920
+ client = _mock_client()
921
+ client.handicap.history.return_value = [{"userHcp": -15.0}]
922
+ mock_get.return_value = client
923
+
924
+ result = runner.invoke(cli, ["trends", "--json"])
925
+ data = json.loads(result.output)
926
+ assert data[0]["userHcp"] == -15.0
927
+
928
+ @patch(_STATS_GET_CLIENT)
929
+ def test_trends_empty(self, mock_get, runner):
930
+ client = _mock_client()
931
+ client.handicap.history.return_value = []
932
+ mock_get.return_value = client
933
+
934
+ result = runner.invoke(cli, ["trends"])
935
+ assert result.exit_code == 0
936
+ assert "No handicap history" in result.output
937
+
938
+
939
+ # ---------------------------------------------------------------------------
940
+ # completions
941
+ # ---------------------------------------------------------------------------
942
+
943
+ class TestCompletionsCommand:
944
+ def test_completions_bash(self, runner):
945
+ result = runner.invoke(cli, ["completions", "bash"])
946
+ assert result.exit_code == 0
947
+
948
+ def test_completions_invalid(self, runner):
949
+ result = runner.invoke(cli, ["completions", "powershell"])
950
+ assert result.exit_code != 0
951
+
952
+
953
+ # ---------------------------------------------------------------------------
954
+ # rich help
955
+ # ---------------------------------------------------------------------------
956
+
957
+ class TestRichHelp:
958
+ def test_help_renders(self, runner):
959
+ result = runner.invoke(cli, ["--help"])
960
+ assert result.exit_code == 0
961
+ assert "arccos" in result.output
@@ -1,56 +0,0 @@
1
- """
2
- arccos CLI — pull your Arccos Golf data from the terminal.
3
-
4
- Uses the reverse-engineered Arccos Golf API. Not affiliated with Arccos Golf LLC.
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- import click
10
-
11
- from . import auth, rounds, stats
12
-
13
-
14
- @click.group()
15
- @click.version_option(package_name="arccos-api", prog_name="arccos")
16
- def cli():
17
- """
18
- \b
19
- arccos — Your Arccos Golf data in the terminal.
20
-
21
- \b
22
- Quick start:
23
- arccos login Authenticate and save credentials
24
- arccos rounds Your recent rounds
25
- arccos round <id> Hole-by-hole round detail
26
- arccos handicap Current handicap index
27
- arccos clubs Smart club distances
28
- arccos courses Courses you've played
29
- arccos pace Pace of play by course
30
- arccos stats Strokes gained analysis
31
- arccos bests Personal bests (all-time records)
32
- arccos overview Overall stats summary
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
36
- arccos export Export rounds to JSON/CSV
37
- arccos logout Clear cached credentials
38
-
39
- \b
40
- Credentials are cached in ~/.arccos_creds.json after first login.
41
- Set ARCCOS_EMAIL and ARCCOS_PASSWORD to skip the prompts.
42
- """
43
-
44
-
45
- # Register all command submodules
46
- auth.register(cli)
47
- rounds.register(cli)
48
- stats.register(cli)
49
-
50
-
51
- def main():
52
- cli()
53
-
54
-
55
- if __name__ == "__main__":
56
- main()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes