strava-client-cli 0.1.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.
- strava_client_cli-0.1.0/.gitignore +6 -0
- strava_client_cli-0.1.0/LICENSE +21 -0
- strava_client_cli-0.1.0/PKG-INFO +68 -0
- strava_client_cli-0.1.0/README.md +46 -0
- strava_client_cli-0.1.0/pyproject.toml +38 -0
- strava_client_cli-0.1.0/src/strava_cli/__init__.py +1 -0
- strava_client_cli-0.1.0/src/strava_cli/cli.py +342 -0
- strava_client_cli-0.1.0/src/strava_cli/config.py +155 -0
- strava_client_cli-0.1.0/src/strava_cli/formatting.py +66 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Geo Deterra
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: strava-client-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI tool for interacting with the Strava API
|
|
5
|
+
Project-URL: Homepage, https://github.com/geodeterra/strava-cli
|
|
6
|
+
Project-URL: Repository, https://github.com/geodeterra/strava-cli
|
|
7
|
+
Author-email: Geo Deterra <geo.deterra@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: cli,cycling,fitness,running,strava
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Requires-Dist: httpx>=0.27.0
|
|
19
|
+
Requires-Dist: rich>=13.0.0
|
|
20
|
+
Requires-Dist: typer>=0.9.0
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# strava-cli
|
|
24
|
+
|
|
25
|
+
CLI tool for interacting with the Strava API.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
uv pip install -e .
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Setup
|
|
34
|
+
|
|
35
|
+
1. Create a Strava API app at https://www.strava.com/settings/api
|
|
36
|
+
2. Set callback URL to `http://localhost`
|
|
37
|
+
3. Run `strava auth` and follow the prompts
|
|
38
|
+
|
|
39
|
+
## Commands
|
|
40
|
+
|
|
41
|
+
| Command | Description |
|
|
42
|
+
|---------|-------------|
|
|
43
|
+
| `strava auth` | OAuth2 setup flow |
|
|
44
|
+
| `strava profile` | Show athlete profile |
|
|
45
|
+
| `strava stats` | Athlete stats summary |
|
|
46
|
+
| `strava activities` | List recent activities |
|
|
47
|
+
| `strava activity <id>` | Detailed activity view |
|
|
48
|
+
| `strava export` | Bulk export activities |
|
|
49
|
+
|
|
50
|
+
## Usage
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
# List last 10 activities
|
|
54
|
+
strava activities --limit 10
|
|
55
|
+
|
|
56
|
+
# Filter by type and date
|
|
57
|
+
strava activities --type Run --after 2024-01-01
|
|
58
|
+
|
|
59
|
+
# View a specific activity
|
|
60
|
+
strava activity 12345678
|
|
61
|
+
|
|
62
|
+
# Export activities to JSON
|
|
63
|
+
strava export --output ./export --format json --after 2024-01-01
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Token Management
|
|
67
|
+
|
|
68
|
+
Tokens are stored in `~/.config/strava-cli/tokens.json` and automatically refreshed when expired (every 6 hours).
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# strava-cli
|
|
2
|
+
|
|
3
|
+
CLI tool for interacting with the Strava API.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
uv pip install -e .
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
1. Create a Strava API app at https://www.strava.com/settings/api
|
|
14
|
+
2. Set callback URL to `http://localhost`
|
|
15
|
+
3. Run `strava auth` and follow the prompts
|
|
16
|
+
|
|
17
|
+
## Commands
|
|
18
|
+
|
|
19
|
+
| Command | Description |
|
|
20
|
+
|---------|-------------|
|
|
21
|
+
| `strava auth` | OAuth2 setup flow |
|
|
22
|
+
| `strava profile` | Show athlete profile |
|
|
23
|
+
| `strava stats` | Athlete stats summary |
|
|
24
|
+
| `strava activities` | List recent activities |
|
|
25
|
+
| `strava activity <id>` | Detailed activity view |
|
|
26
|
+
| `strava export` | Bulk export activities |
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# List last 10 activities
|
|
32
|
+
strava activities --limit 10
|
|
33
|
+
|
|
34
|
+
# Filter by type and date
|
|
35
|
+
strava activities --type Run --after 2024-01-01
|
|
36
|
+
|
|
37
|
+
# View a specific activity
|
|
38
|
+
strava activity 12345678
|
|
39
|
+
|
|
40
|
+
# Export activities to JSON
|
|
41
|
+
strava export --output ./export --format json --after 2024-01-01
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Token Management
|
|
45
|
+
|
|
46
|
+
Tokens are stored in `~/.config/strava-cli/tokens.json` and automatically refreshed when expired (every 6 hours).
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "strava-client-cli"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "CLI tool for interacting with the Strava API"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Geo Deterra", email = "geo.deterra@gmail.com" },
|
|
10
|
+
]
|
|
11
|
+
keywords = ["strava", "cli", "running", "cycling", "fitness"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"Environment :: Console",
|
|
15
|
+
"Intended Audience :: End Users/Desktop",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"typer>=0.9.0",
|
|
22
|
+
"httpx>=0.27.0",
|
|
23
|
+
"rich>=13.0.0",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/geodeterra/strava-cli"
|
|
28
|
+
Repository = "https://github.com/geodeterra/strava-cli"
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
strava = "strava_cli.cli:app"
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
packages = ["src/strava_cli"]
|
|
35
|
+
|
|
36
|
+
[build-system]
|
|
37
|
+
requires = ["hatchling"]
|
|
38
|
+
build-backend = "hatchling.build"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Strava CLI — interact with the Strava API from the command line."""
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""Strava CLI — interact with Strava from the command line."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import webbrowser
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
from urllib.parse import urlencode
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.panel import Panel
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
|
|
16
|
+
from strava_cli.config import (
|
|
17
|
+
STRAVA_AUTH_URL,
|
|
18
|
+
StravaClient,
|
|
19
|
+
StravaConfig,
|
|
20
|
+
)
|
|
21
|
+
from strava_cli.formatting import (
|
|
22
|
+
format_date,
|
|
23
|
+
format_date_short,
|
|
24
|
+
format_distance,
|
|
25
|
+
format_duration,
|
|
26
|
+
format_elevation,
|
|
27
|
+
format_pace,
|
|
28
|
+
format_speed,
|
|
29
|
+
parse_date_to_epoch,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
app = typer.Typer(
|
|
33
|
+
name="strava",
|
|
34
|
+
help="Interact with the Strava API from the command line.",
|
|
35
|
+
no_args_is_help=True,
|
|
36
|
+
)
|
|
37
|
+
console = Console()
|
|
38
|
+
err_console = Console(stderr=True)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _get_client() -> StravaClient:
|
|
42
|
+
return StravaClient()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# auth
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
@app.command()
|
|
50
|
+
def auth() -> None:
|
|
51
|
+
"""Authenticate with Strava via OAuth2."""
|
|
52
|
+
config = StravaConfig()
|
|
53
|
+
loaded = config.load()
|
|
54
|
+
|
|
55
|
+
if not loaded:
|
|
56
|
+
console.print("[bold]Strava OAuth Setup[/bold]\n")
|
|
57
|
+
console.print("Create an app at https://www.strava.com/settings/api")
|
|
58
|
+
console.print("Use [cyan]http://localhost[/cyan] as the callback URL.\n")
|
|
59
|
+
config.client_id = typer.prompt("Client ID")
|
|
60
|
+
config.client_secret = typer.prompt("Client Secret")
|
|
61
|
+
config.save()
|
|
62
|
+
console.print("[green]✓ Config saved[/green]\n")
|
|
63
|
+
else:
|
|
64
|
+
console.print(f"[dim]Using existing config (client_id: {config.client_id})[/dim]\n")
|
|
65
|
+
|
|
66
|
+
params = urlencode({
|
|
67
|
+
"client_id": config.client_id,
|
|
68
|
+
"response_type": "code",
|
|
69
|
+
"redirect_uri": "http://localhost",
|
|
70
|
+
"approval_prompt": "force",
|
|
71
|
+
"scope": "activity:read_all,profile:read_all",
|
|
72
|
+
})
|
|
73
|
+
auth_url = f"{STRAVA_AUTH_URL}?{params}"
|
|
74
|
+
|
|
75
|
+
console.print("Open this URL in your browser:\n")
|
|
76
|
+
console.print(f"[cyan]{auth_url}[/cyan]\n")
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
webbrowser.open(auth_url)
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
console.print(
|
|
84
|
+
"After authorizing, you'll be redirected to localhost with a [bold]code[/bold] parameter."
|
|
85
|
+
)
|
|
86
|
+
console.print("Copy the code from the URL (e.g. http://localhost/?code=XXXXX)\n")
|
|
87
|
+
|
|
88
|
+
code = typer.prompt("Paste the authorization code")
|
|
89
|
+
|
|
90
|
+
client = StravaClient()
|
|
91
|
+
data = client.exchange_code(code)
|
|
92
|
+
|
|
93
|
+
athlete = data.get("athlete", {})
|
|
94
|
+
name = f"{athlete.get('firstname', '')} {athlete.get('lastname', '')}".strip()
|
|
95
|
+
console.print(f"\n[green]✓ Authenticated as {name}[/green]")
|
|
96
|
+
console.print("[dim]Tokens saved to ~/.config/strava-cli/tokens.json[/dim]")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
# profile
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
@app.command()
|
|
104
|
+
def profile() -> None:
|
|
105
|
+
"""Show your Strava athlete profile."""
|
|
106
|
+
client = _get_client()
|
|
107
|
+
data = client.get("/athlete")
|
|
108
|
+
|
|
109
|
+
table = Table(title="Athlete Profile")
|
|
110
|
+
table.add_column("Field", style="cyan")
|
|
111
|
+
table.add_column("Value")
|
|
112
|
+
|
|
113
|
+
name = f"{data.get('firstname', '')} {data.get('lastname', '')}".strip()
|
|
114
|
+
table.add_row("Name", name)
|
|
115
|
+
table.add_row("Username", data.get("username", "—"))
|
|
116
|
+
table.add_row("City", data.get("city", "—") or "—")
|
|
117
|
+
table.add_row("State", data.get("state", "—") or "—")
|
|
118
|
+
table.add_row("Country", data.get("country", "—") or "—")
|
|
119
|
+
table.add_row("Sex", data.get("sex", "—") or "—")
|
|
120
|
+
table.add_row("Weight", f"{data.get('weight', 0):.1f} kg" if data.get("weight") else "—")
|
|
121
|
+
table.add_row("FTP", str(data.get("ftp", "—")) if data.get("ftp") else "—")
|
|
122
|
+
table.add_row("Follower Count", str(data.get("follower_count", 0)))
|
|
123
|
+
table.add_row("Friend Count", str(data.get("friend_count", 0)))
|
|
124
|
+
|
|
125
|
+
console.print(table)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
# stats
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
@app.command()
|
|
133
|
+
def stats() -> None:
|
|
134
|
+
"""Show your athlete stats summary."""
|
|
135
|
+
client = _get_client()
|
|
136
|
+
athlete_id = client.athlete_id
|
|
137
|
+
|
|
138
|
+
if not athlete_id:
|
|
139
|
+
# Fetch profile to get ID
|
|
140
|
+
profile_data = client.get("/athlete")
|
|
141
|
+
athlete_id = profile_data["id"]
|
|
142
|
+
|
|
143
|
+
data = client.get(f"/athletes/{athlete_id}/stats")
|
|
144
|
+
|
|
145
|
+
def _add_stat_section(table: Table, label: str, totals: dict) -> None:
|
|
146
|
+
count = totals.get("count", 0)
|
|
147
|
+
if count == 0:
|
|
148
|
+
table.add_row(label, "No activities", "", "", "")
|
|
149
|
+
return
|
|
150
|
+
table.add_row(
|
|
151
|
+
label,
|
|
152
|
+
str(count),
|
|
153
|
+
format_distance(totals.get("distance", 0)),
|
|
154
|
+
format_duration(totals.get("moving_time", 0)),
|
|
155
|
+
format_elevation(totals.get("elevation_gain", 0)),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
for sport, key_prefix in [("Run", "run"), ("Ride", "ride"), ("Swim", "swim")]:
|
|
159
|
+
table = Table(title=f"{sport} Stats")
|
|
160
|
+
table.add_column("Period", style="cyan")
|
|
161
|
+
table.add_column("Count")
|
|
162
|
+
table.add_column("Distance")
|
|
163
|
+
table.add_column("Time")
|
|
164
|
+
table.add_column("Elevation")
|
|
165
|
+
|
|
166
|
+
_add_stat_section(table, "Recent", data.get(f"recent_{key_prefix}_totals", {}))
|
|
167
|
+
_add_stat_section(table, "YTD", data.get(f"ytd_{key_prefix}_totals", {}))
|
|
168
|
+
_add_stat_section(table, "All Time", data.get(f"all_{key_prefix}_totals", {}))
|
|
169
|
+
|
|
170
|
+
console.print(table)
|
|
171
|
+
console.print()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
# activities
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
@app.command()
|
|
179
|
+
def activities(
|
|
180
|
+
limit: int = typer.Option(20, "--limit", "-n", help="Number of activities to show."),
|
|
181
|
+
after: Optional[str] = typer.Option(None, help="Show activities after this date (YYYY-MM-DD)."),
|
|
182
|
+
before: Optional[str] = typer.Option(None, help="Show activities before this date (YYYY-MM-DD)."),
|
|
183
|
+
activity_type: Optional[str] = typer.Option(None, "--type", "-t", help="Filter by type (Run, Ride, Swim, etc.)."),
|
|
184
|
+
) -> None:
|
|
185
|
+
"""List recent activities."""
|
|
186
|
+
client = _get_client()
|
|
187
|
+
|
|
188
|
+
params: dict = {"per_page": min(limit, 200)}
|
|
189
|
+
if after:
|
|
190
|
+
params["after"] = parse_date_to_epoch(after)
|
|
191
|
+
if before:
|
|
192
|
+
params["before"] = parse_date_to_epoch(before)
|
|
193
|
+
|
|
194
|
+
data = client.get("/athlete/activities", params=params)
|
|
195
|
+
|
|
196
|
+
if activity_type:
|
|
197
|
+
normalized = activity_type.lower()
|
|
198
|
+
data = [a for a in data if a.get("type", "").lower() == normalized]
|
|
199
|
+
|
|
200
|
+
if not data:
|
|
201
|
+
console.print("[yellow]No activities found.[/yellow]")
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
table = Table(title="Activities")
|
|
205
|
+
table.add_column("ID", style="dim")
|
|
206
|
+
table.add_column("Date", style="cyan")
|
|
207
|
+
table.add_column("Type")
|
|
208
|
+
table.add_column("Name")
|
|
209
|
+
table.add_column("Distance", justify="right")
|
|
210
|
+
table.add_column("Time", justify="right")
|
|
211
|
+
table.add_column("Elevation", justify="right")
|
|
212
|
+
|
|
213
|
+
for a in data[:limit]:
|
|
214
|
+
table.add_row(
|
|
215
|
+
str(a["id"]),
|
|
216
|
+
format_date_short(a["start_date"]),
|
|
217
|
+
a.get("type", "—"),
|
|
218
|
+
a.get("name", "—"),
|
|
219
|
+
format_distance(a.get("distance", 0)),
|
|
220
|
+
format_duration(a.get("moving_time", 0)),
|
|
221
|
+
format_elevation(a.get("total_elevation_gain", 0)),
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
console.print(table)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ---------------------------------------------------------------------------
|
|
228
|
+
# activity
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
@app.command()
|
|
232
|
+
def activity(
|
|
233
|
+
activity_id: int = typer.Argument(help="Strava activity ID."),
|
|
234
|
+
) -> None:
|
|
235
|
+
"""Show detailed view of a single activity."""
|
|
236
|
+
client = _get_client()
|
|
237
|
+
data = client.get(f"/activities/{activity_id}")
|
|
238
|
+
|
|
239
|
+
title = data.get("name", "Activity")
|
|
240
|
+
activity_type = data.get("type", "Unknown")
|
|
241
|
+
date = format_date(data.get("start_date", ""))
|
|
242
|
+
|
|
243
|
+
table = Table(title=f"{title} ({activity_type})")
|
|
244
|
+
table.add_column("Field", style="cyan")
|
|
245
|
+
table.add_column("Value")
|
|
246
|
+
|
|
247
|
+
table.add_row("Date", date)
|
|
248
|
+
table.add_row("Distance", format_distance(data.get("distance", 0)))
|
|
249
|
+
table.add_row("Moving Time", format_duration(data.get("moving_time", 0)))
|
|
250
|
+
table.add_row("Elapsed Time", format_duration(data.get("elapsed_time", 0)))
|
|
251
|
+
table.add_row("Elevation Gain", format_elevation(data.get("total_elevation_gain", 0)))
|
|
252
|
+
table.add_row("Max Elevation", format_elevation(data.get("elev_high", 0)))
|
|
253
|
+
table.add_row("Min Elevation", format_elevation(data.get("elev_low", 0)))
|
|
254
|
+
|
|
255
|
+
avg_speed = data.get("average_speed", 0)
|
|
256
|
+
max_speed = data.get("max_speed", 0)
|
|
257
|
+
table.add_row("Avg Speed", format_speed(avg_speed))
|
|
258
|
+
table.add_row("Max Speed", format_speed(max_speed))
|
|
259
|
+
|
|
260
|
+
if data.get("type") in ("Run", "Walk", "Hike"):
|
|
261
|
+
table.add_row("Avg Pace", format_pace(data.get("distance", 0), data.get("moving_time", 0)))
|
|
262
|
+
|
|
263
|
+
if data.get("average_heartrate"):
|
|
264
|
+
table.add_row("Avg HR", f"{data['average_heartrate']:.0f} bpm")
|
|
265
|
+
if data.get("max_heartrate"):
|
|
266
|
+
table.add_row("Max HR", f"{data['max_heartrate']:.0f} bpm")
|
|
267
|
+
if data.get("average_cadence"):
|
|
268
|
+
table.add_row("Avg Cadence", f"{data['average_cadence']:.0f} rpm")
|
|
269
|
+
if data.get("average_watts"):
|
|
270
|
+
table.add_row("Avg Power", f"{data['average_watts']:.0f} W")
|
|
271
|
+
if data.get("calories"):
|
|
272
|
+
table.add_row("Calories", f"{data['calories']:.0f}")
|
|
273
|
+
|
|
274
|
+
table.add_row("Kudos", str(data.get("kudos_count", 0)))
|
|
275
|
+
table.add_row("Description", data.get("description", "—") or "—")
|
|
276
|
+
|
|
277
|
+
console.print(table)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# ---------------------------------------------------------------------------
|
|
281
|
+
# export
|
|
282
|
+
# ---------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
@app.command()
|
|
285
|
+
def export(
|
|
286
|
+
output: Path = typer.Option(Path("./strava-export"), "--output", "-o", help="Output directory."),
|
|
287
|
+
fmt: str = typer.Option("json", "--format", "-f", help="Export format: json or gpx."),
|
|
288
|
+
after: Optional[str] = typer.Option(None, help="Export activities after this date (YYYY-MM-DD)."),
|
|
289
|
+
before: Optional[str] = typer.Option(None, help="Export activities before this date (YYYY-MM-DD)."),
|
|
290
|
+
limit: int = typer.Option(100, "--limit", "-n", help="Max activities to export."),
|
|
291
|
+
) -> None:
|
|
292
|
+
"""Bulk export activities to JSON or GPX files."""
|
|
293
|
+
client = _get_client()
|
|
294
|
+
|
|
295
|
+
params: dict = {"per_page": min(limit, 200)}
|
|
296
|
+
if after:
|
|
297
|
+
params["after"] = parse_date_to_epoch(after)
|
|
298
|
+
if before:
|
|
299
|
+
params["before"] = parse_date_to_epoch(before)
|
|
300
|
+
|
|
301
|
+
console.print(f"[dim]Fetching activities...[/dim]")
|
|
302
|
+
activity_list = client.get("/athlete/activities", params=params)
|
|
303
|
+
|
|
304
|
+
if not activity_list:
|
|
305
|
+
console.print("[yellow]No activities found.[/yellow]")
|
|
306
|
+
return
|
|
307
|
+
|
|
308
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
309
|
+
exported = 0
|
|
310
|
+
|
|
311
|
+
for summary in activity_list[:limit]:
|
|
312
|
+
aid = summary["id"]
|
|
313
|
+
|
|
314
|
+
if fmt == "json":
|
|
315
|
+
detail = client.get(f"/activities/{aid}")
|
|
316
|
+
filepath = output / f"{aid}.json"
|
|
317
|
+
filepath.write_text(json.dumps(detail, indent=2) + "\n")
|
|
318
|
+
exported += 1
|
|
319
|
+
elif fmt == "gpx":
|
|
320
|
+
# Strava API v3 doesn't provide GPX directly; export streams as JSON
|
|
321
|
+
# and note this limitation
|
|
322
|
+
try:
|
|
323
|
+
streams = client.get(
|
|
324
|
+
f"/activities/{aid}/streams",
|
|
325
|
+
params={"keys": "latlng,altitude,time", "key_type": "stream"},
|
|
326
|
+
)
|
|
327
|
+
filepath = output / f"{aid}_streams.json"
|
|
328
|
+
filepath.write_text(json.dumps(streams, indent=2) + "\n")
|
|
329
|
+
exported += 1
|
|
330
|
+
except Exception as exc:
|
|
331
|
+
err_console.print(f"[red]Failed to export {aid}: {exc}[/red]")
|
|
332
|
+
|
|
333
|
+
name = summary.get("name", "")
|
|
334
|
+
console.print(f" [green]✓[/green] {aid} — {name}")
|
|
335
|
+
|
|
336
|
+
console.print(f"\n[green]Exported {exported} activities to {output}[/green]")
|
|
337
|
+
if fmt == "gpx":
|
|
338
|
+
console.print("[dim]Note: Strava API v3 doesn't serve GPX directly. Exported stream data as JSON.[/dim]")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
if __name__ == "__main__":
|
|
342
|
+
app()
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Configuration and token management for Strava CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
CONFIG_DIR = Path.home() / ".config" / "strava-cli"
|
|
13
|
+
TOKENS_PATH = CONFIG_DIR / "tokens.json"
|
|
14
|
+
CONFIG_PATH = CONFIG_DIR / "config.json"
|
|
15
|
+
|
|
16
|
+
STRAVA_AUTH_URL = "https://www.strava.com/oauth/authorize"
|
|
17
|
+
STRAVA_TOKEN_URL = "https://www.strava.com/oauth/token"
|
|
18
|
+
STRAVA_API_BASE = "https://www.strava.com/api/v3"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class StravaConfig:
|
|
22
|
+
"""Manages client credentials stored in config.json."""
|
|
23
|
+
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
self.client_id: str = ""
|
|
26
|
+
self.client_secret: str = ""
|
|
27
|
+
|
|
28
|
+
def load(self) -> bool:
|
|
29
|
+
if not CONFIG_PATH.exists():
|
|
30
|
+
return False
|
|
31
|
+
data = json.loads(CONFIG_PATH.read_text())
|
|
32
|
+
self.client_id = data.get("client_id", "")
|
|
33
|
+
self.client_secret = data.get("client_secret", "")
|
|
34
|
+
return bool(self.client_id and self.client_secret)
|
|
35
|
+
|
|
36
|
+
def save(self) -> None:
|
|
37
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
CONFIG_PATH.write_text(json.dumps({
|
|
39
|
+
"client_id": self.client_id,
|
|
40
|
+
"client_secret": self.client_secret,
|
|
41
|
+
}, indent=2) + "\n")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TokenStore:
|
|
45
|
+
"""Manages OAuth tokens stored in tokens.json with auto-refresh."""
|
|
46
|
+
|
|
47
|
+
def __init__(self) -> None:
|
|
48
|
+
self.access_token: str = ""
|
|
49
|
+
self.refresh_token: str = ""
|
|
50
|
+
self.expires_at: int = 0
|
|
51
|
+
self.athlete_id: int = 0
|
|
52
|
+
|
|
53
|
+
def load(self) -> bool:
|
|
54
|
+
if not TOKENS_PATH.exists():
|
|
55
|
+
return False
|
|
56
|
+
data = json.loads(TOKENS_PATH.read_text())
|
|
57
|
+
self.access_token = data.get("access_token", "")
|
|
58
|
+
self.refresh_token = data.get("refresh_token", "")
|
|
59
|
+
self.expires_at = data.get("expires_at", 0)
|
|
60
|
+
self.athlete_id = data.get("athlete_id", 0)
|
|
61
|
+
return bool(self.access_token)
|
|
62
|
+
|
|
63
|
+
def save(self) -> None:
|
|
64
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
TOKENS_PATH.write_text(json.dumps({
|
|
66
|
+
"access_token": self.access_token,
|
|
67
|
+
"refresh_token": self.refresh_token,
|
|
68
|
+
"expires_at": self.expires_at,
|
|
69
|
+
"athlete_id": self.athlete_id,
|
|
70
|
+
}, indent=2) + "\n")
|
|
71
|
+
|
|
72
|
+
def is_expired(self) -> bool:
|
|
73
|
+
return time.time() >= self.expires_at
|
|
74
|
+
|
|
75
|
+
def update_from_response(self, data: dict) -> None:
|
|
76
|
+
self.access_token = data["access_token"]
|
|
77
|
+
self.refresh_token = data["refresh_token"]
|
|
78
|
+
self.expires_at = data["expires_at"]
|
|
79
|
+
if "athlete" in data:
|
|
80
|
+
self.athlete_id = data["athlete"]["id"]
|
|
81
|
+
self.save()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class StravaClient:
|
|
85
|
+
"""HTTP client for Strava API with automatic token refresh."""
|
|
86
|
+
|
|
87
|
+
def __init__(self) -> None:
|
|
88
|
+
self._config = StravaConfig()
|
|
89
|
+
self._tokens = TokenStore()
|
|
90
|
+
self._http: Optional[httpx.Client] = None
|
|
91
|
+
|
|
92
|
+
def _ensure_config(self) -> None:
|
|
93
|
+
if not self._config.load():
|
|
94
|
+
raise SystemExit(
|
|
95
|
+
"No Strava config found. Run 'strava auth' first."
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def _ensure_tokens(self) -> None:
|
|
99
|
+
if not self._tokens.load():
|
|
100
|
+
raise SystemExit(
|
|
101
|
+
"No tokens found. Run 'strava auth' first."
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def _refresh_if_needed(self) -> None:
|
|
105
|
+
if not self._tokens.is_expired():
|
|
106
|
+
return
|
|
107
|
+
self._ensure_config()
|
|
108
|
+
response = httpx.post(STRAVA_TOKEN_URL, data={
|
|
109
|
+
"client_id": self._config.client_id,
|
|
110
|
+
"client_secret": self._config.client_secret,
|
|
111
|
+
"grant_type": "refresh_token",
|
|
112
|
+
"refresh_token": self._tokens.refresh_token,
|
|
113
|
+
})
|
|
114
|
+
response.raise_for_status()
|
|
115
|
+
self._tokens.update_from_response(response.json())
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def athlete_id(self) -> int:
|
|
119
|
+
self._ensure_tokens()
|
|
120
|
+
return self._tokens.athlete_id
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def config(self) -> StravaConfig:
|
|
124
|
+
self._ensure_config()
|
|
125
|
+
return self._config
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def tokens(self) -> TokenStore:
|
|
129
|
+
self._ensure_tokens()
|
|
130
|
+
return self._tokens
|
|
131
|
+
|
|
132
|
+
def get(self, path: str, params: Optional[dict] = None) -> dict | list:
|
|
133
|
+
self._ensure_tokens()
|
|
134
|
+
self._refresh_if_needed()
|
|
135
|
+
response = httpx.get(
|
|
136
|
+
f"{STRAVA_API_BASE}{path}",
|
|
137
|
+
headers={"Authorization": f"Bearer {self._tokens.access_token}"},
|
|
138
|
+
params=params,
|
|
139
|
+
timeout=30,
|
|
140
|
+
)
|
|
141
|
+
response.raise_for_status()
|
|
142
|
+
return response.json()
|
|
143
|
+
|
|
144
|
+
def exchange_code(self, code: str) -> dict:
|
|
145
|
+
self._ensure_config()
|
|
146
|
+
response = httpx.post(STRAVA_TOKEN_URL, data={
|
|
147
|
+
"client_id": self._config.client_id,
|
|
148
|
+
"client_secret": self._config.client_secret,
|
|
149
|
+
"code": code,
|
|
150
|
+
"grant_type": "authorization_code",
|
|
151
|
+
})
|
|
152
|
+
response.raise_for_status()
|
|
153
|
+
data = response.json()
|
|
154
|
+
self._tokens.update_from_response(data)
|
|
155
|
+
return data
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Formatting helpers for Strava data display."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def format_distance(meters: float) -> str:
|
|
9
|
+
"""Format distance in meters to km."""
|
|
10
|
+
km = meters / 1000.0
|
|
11
|
+
return f"{km:.2f} km"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def format_distance_miles(meters: float) -> str:
|
|
15
|
+
"""Format distance in meters to miles."""
|
|
16
|
+
miles = meters / 1609.34
|
|
17
|
+
return f"{miles:.2f} mi"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def format_duration(seconds: int) -> str:
|
|
21
|
+
"""Format seconds into human readable duration."""
|
|
22
|
+
hours = seconds // 3600
|
|
23
|
+
minutes = (seconds % 3600) // 60
|
|
24
|
+
secs = seconds % 60
|
|
25
|
+
if hours > 0:
|
|
26
|
+
return f"{hours}h {minutes:02d}m {secs:02d}s"
|
|
27
|
+
return f"{minutes}m {secs:02d}s"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def format_pace(meters: float, seconds: int) -> str:
|
|
31
|
+
"""Format pace as min/km."""
|
|
32
|
+
if meters <= 0 or seconds <= 0:
|
|
33
|
+
return "—"
|
|
34
|
+
pace_seconds = seconds / (meters / 1000.0)
|
|
35
|
+
minutes = int(pace_seconds // 60)
|
|
36
|
+
secs = int(pace_seconds % 60)
|
|
37
|
+
return f"{minutes}:{secs:02d} /km"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def format_speed(meters_per_second: float) -> str:
|
|
41
|
+
"""Format speed in km/h."""
|
|
42
|
+
kmh = meters_per_second * 3.6
|
|
43
|
+
return f"{kmh:.1f} km/h"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def format_elevation(meters: float) -> str:
|
|
47
|
+
"""Format elevation in meters."""
|
|
48
|
+
return f"{meters:.0f} m"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def format_date(iso_string: str) -> str:
|
|
52
|
+
"""Format ISO date string to human readable."""
|
|
53
|
+
dt = datetime.fromisoformat(iso_string.replace("Z", "+00:00"))
|
|
54
|
+
return dt.strftime("%Y-%m-%d %H:%M")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def format_date_short(iso_string: str) -> str:
|
|
58
|
+
"""Format ISO date to short form."""
|
|
59
|
+
dt = datetime.fromisoformat(iso_string.replace("Z", "+00:00"))
|
|
60
|
+
return dt.strftime("%b %d")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def parse_date_to_epoch(date_str: str) -> int:
|
|
64
|
+
"""Parse YYYY-MM-DD to Unix timestamp."""
|
|
65
|
+
dt = datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
|
66
|
+
return int(dt.timestamp())
|