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.
- {arccos_api-0.3.0/arccos_api.egg-info → arccos_api-0.4.0}/PKG-INFO +14 -12
- {arccos_api-0.3.0 → arccos_api-0.4.0}/README.md +13 -11
- arccos_api-0.4.0/arccos/cli/__init__.py +78 -0
- {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos/cli/auth.py +29 -0
- {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos/cli/rounds.py +122 -0
- {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos/cli/stats.py +82 -2
- {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos/exceptions.py +1 -1
- {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos/resources/courses.py +1 -1
- {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos/resources/rounds.py +6 -2
- {arccos_api-0.3.0 → arccos_api-0.4.0/arccos_api.egg-info}/PKG-INFO +14 -12
- {arccos_api-0.3.0 → arccos_api-0.4.0}/pyproject.toml +1 -1
- {arccos_api-0.3.0 → arccos_api-0.4.0}/tests/test_cli.py +118 -0
- arccos_api-0.3.0/arccos/cli/__init__.py +0 -56
- {arccos_api-0.3.0 → arccos_api-0.4.0}/MANIFEST.in +0 -0
- {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos/__init__.py +0 -0
- {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos/__main__.py +0 -0
- {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos/_http.py +0 -0
- {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos/auth.py +0 -0
- {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos/cli/_helpers.py +0 -0
- {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos/client.py +0 -0
- {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos/resources/__init__.py +0 -0
- {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos/resources/clubs.py +0 -0
- {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos/resources/handicap.py +0 -0
- {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos/resources/stats.py +0 -0
- {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos/types.py +0 -0
- {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos_api.egg-info/SOURCES.txt +0 -0
- {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos_api.egg-info/dependency_links.txt +0 -0
- {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos_api.egg-info/entry_points.txt +0 -0
- {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos_api.egg-info/requires.txt +0 -0
- {arccos_api-0.3.0 → arccos_api-0.4.0}/arccos_api.egg-info/top_level.txt +0 -0
- {arccos_api-0.3.0 → arccos_api-0.4.0}/setup.cfg +0 -0
- {arccos_api-0.3.0 → arccos_api-0.4.0}/tests/test_auth.py +0 -0
- {arccos_api-0.3.0 → arccos_api-0.4.0}/tests/test_client.py +0 -0
- {arccos_api-0.3.0 → arccos_api-0.4.0}/tests/test_exceptions.py +0 -0
- {arccos_api-0.3.0 → arccos_api-0.4.0}/tests/test_http.py +0 -0
- {arccos_api-0.3.0 → arccos_api-0.4.0}/tests/test_resources.py +0 -0
- {arccos_api-0.3.0 → arccos_api-0.4.0}/tests/test_rounds.py +0 -0
- {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
|
+
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
|
|
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" #
|
|
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
|
|
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" #
|
|
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
|
|
@@ -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) ->
|
|
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:
|
|
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
|
+
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
|
|
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" #
|
|
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.
|
|
@@ -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
|
|
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
|