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.
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ *.egg-info/
6
+ uv.lock
@@ -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())