eticu 0.2.1__py3-none-any.whl

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.
eticu/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
eticu/cli.py ADDED
@@ -0,0 +1,229 @@
1
+ """eticu CLI — scrape / download / convert / upload / all."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Annotated
9
+
10
+ import typer
11
+
12
+ app = typer.Typer(help="Convert 80/20 Endurance workouts to intervals.icu structured workouts.")
13
+
14
+ _DEFAULT_CACHE = Path("8020_cache")
15
+
16
+
17
+ def _setup_logging(verbose: bool) -> None:
18
+ level = logging.DEBUG if verbose else logging.INFO
19
+ logging.basicConfig(level=level, format="%(levelname)s %(message)s", stream=sys.stderr)
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # scrape
24
+ # ---------------------------------------------------------------------------
25
+
26
+
27
+ @app.command()
28
+ def scrape(
29
+ verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False,
30
+ ) -> None:
31
+ """List all .FIT URLs from the 80/20 workout library (no download)."""
32
+ _setup_logging(verbose)
33
+ from eticu.scrape import scrape_workout_urls
34
+
35
+ grouped = scrape_workout_urls()
36
+ for sport, urls in grouped.items():
37
+ for url in urls:
38
+ typer.echo(f"{sport}\t{url}")
39
+ total = sum(len(v) for v in grouped.values())
40
+ run, ride, swim = len(grouped["Run"]), len(grouped["Ride"]), len(grouped["Swim"])
41
+ typer.echo(f"\nTotal: {total} (Run={run} Ride={ride} Swim={swim})")
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # download
46
+ # ---------------------------------------------------------------------------
47
+
48
+
49
+ @app.command()
50
+ def download(
51
+ cache_dir: Annotated[Path, typer.Option("--cache-dir")] = _DEFAULT_CACHE,
52
+ workers: Annotated[int, typer.Option("--jobs", "-j")] = 4,
53
+ dry_run: Annotated[bool, typer.Option("--dry-run")] = False,
54
+ verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False,
55
+ ) -> None:
56
+ """Download all .FIT files to a local cache directory."""
57
+ _setup_logging(verbose)
58
+ from eticu.download import download_all
59
+ from eticu.scrape import scrape_workout_urls
60
+
61
+ grouped = scrape_workout_urls()
62
+ all_urls = [u for urls in grouped.values() for u in urls]
63
+ typer.echo(f"Downloading {len(all_urls)} files to {cache_dir} ...")
64
+ counts = download_all(all_urls, cache_dir, workers=workers, dry_run=dry_run)
65
+ typer.echo(f"Done. ok={counts['ok']} skip={counts['skip']} error={counts['error']}")
66
+ if counts["error"]:
67
+ raise typer.Exit(1)
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # convert
72
+ # ---------------------------------------------------------------------------
73
+
74
+
75
+ @app.command(name="convert")
76
+ def convert_cmd(
77
+ fit_file: Annotated[Path, typer.Argument(help=".FIT file to convert")],
78
+ cycling_power: Annotated[bool, typer.Option("--cycling-power")] = False,
79
+ verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False,
80
+ ) -> None:
81
+ """Convert a single .FIT workout file and print the intervals.icu text."""
82
+ _setup_logging(verbose)
83
+ from eticu.convert import convert as do_convert
84
+ from eticu.fit_parse import parse_fit
85
+
86
+ workout = parse_fit(fit_file)
87
+ typer.echo(do_convert(workout, cycling_power=cycling_power))
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # upload
92
+ # ---------------------------------------------------------------------------
93
+
94
+
95
+ @app.command()
96
+ def upload(
97
+ cache_dir: Annotated[Path, typer.Option("--cache-dir")] = _DEFAULT_CACHE,
98
+ cycling_power: Annotated[bool, typer.Option("--cycling-power")] = False,
99
+ dry_run: Annotated[bool, typer.Option("--dry-run")] = False,
100
+ verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False,
101
+ ) -> None:
102
+ """Upload converted workouts to intervals.icu workout library."""
103
+ _setup_logging(verbose)
104
+ from eticu.config import get_settings
105
+ from eticu.intervals_client import IntervalsClient
106
+ from eticu.upload import upload_workouts
107
+
108
+ settings = get_settings()
109
+ if not settings.intervals_api_key or not settings.intervals_athlete_id:
110
+ typer.echo("Error: INTERVALS_API_KEY and INTERVALS_ATHLETE_ID must be set.", err=True)
111
+ raise typer.Exit(1)
112
+
113
+ fit_files = sorted(set(cache_dir.rglob("*.FIT")) | set(cache_dir.rglob("*.fit")))
114
+ if not fit_files:
115
+ typer.echo(f"No .FIT files found in {cache_dir}. Run 'eticu download' first.")
116
+ raise typer.Exit(1)
117
+
118
+ typer.echo(f"Uploading {len(fit_files)} workout(s) ...")
119
+ with IntervalsClient(settings.intervals_api_key, settings.intervals_athlete_id) as client:
120
+ counts = upload_workouts(fit_files, client, cycling_power=cycling_power, dry_run=dry_run)
121
+
122
+ typer.echo(
123
+ f"Done. created={counts['created']} updated={counts['updated']} "
124
+ f"skipped={counts['skipped']} error={counts['error']}"
125
+ )
126
+ if counts["error"]:
127
+ raise typer.Exit(1)
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # upload-plan
132
+ # ---------------------------------------------------------------------------
133
+
134
+
135
+ @app.command(name="upload-plan")
136
+ def upload_plan_cmd(
137
+ plan_csv: Annotated[Path, typer.Argument(help="CSV file containing the plan")],
138
+ name: Annotated[str, typer.Option("--name", "-n", help="Name of the training plan folder")],
139
+ pool_length: Annotated[int | None, typer.Option("--pool-length", help="Pool length in meters (e.g., 25 or 50)")] = None,
140
+ dry_run: Annotated[bool, typer.Option("--dry-run")] = False,
141
+ verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False,
142
+ ) -> None:
143
+ """Upload a training plan from a CSV to intervals.icu."""
144
+ _setup_logging(verbose)
145
+ from eticu.config import get_settings
146
+ from eticu.intervals_client import IntervalsClient
147
+ from eticu.upload import upload_plan
148
+
149
+ settings = get_settings()
150
+ if not dry_run and (not settings.intervals_api_key or not settings.intervals_athlete_id):
151
+ typer.echo("Error: INTERVALS_API_KEY and INTERVALS_ATHLETE_ID must be set.", err=True)
152
+ raise typer.Exit(1)
153
+
154
+ typer.echo(f"Uploading plan {name!r} from {plan_csv} ...")
155
+ if dry_run:
156
+ typer.echo(" [DRY-RUN] Will not perform actual upload.")
157
+
158
+ with IntervalsClient(settings.intervals_api_key, settings.intervals_athlete_id) as client:
159
+ counts = upload_plan(
160
+ csv_file=plan_csv,
161
+ plan_name=name,
162
+ client=client,
163
+ pool_length_m=pool_length,
164
+ dry_run=dry_run,
165
+ )
166
+
167
+ typer.echo(
168
+ f"Done. created={counts['created']} updated={counts['updated']} "
169
+ f"skipped={counts['skipped']} error={counts['error']} missing={counts['missing']}"
170
+ )
171
+ if counts["error"] or counts["missing"]:
172
+ raise typer.Exit(1)
173
+
174
+
175
+ # ---------------------------------------------------------------------------
176
+ # all
177
+ # ---------------------------------------------------------------------------
178
+
179
+
180
+ @app.command(name="all")
181
+ def all_cmd(
182
+ cache_dir: Annotated[Path, typer.Option("--cache-dir")] = _DEFAULT_CACHE,
183
+ workers: Annotated[int, typer.Option("--jobs", "-j")] = 4,
184
+ cycling_power: Annotated[bool, typer.Option("--cycling-power")] = False,
185
+ dry_run: Annotated[bool, typer.Option("--dry-run")] = False,
186
+ verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False,
187
+ ) -> None:
188
+ """Run the full pipeline: scrape → download → upload."""
189
+ _setup_logging(verbose)
190
+ from eticu.config import get_settings
191
+ from eticu.download import download_all
192
+ from eticu.intervals_client import IntervalsClient
193
+ from eticu.scrape import scrape_workout_urls
194
+ from eticu.upload import upload_workouts
195
+
196
+ settings = get_settings()
197
+ if not dry_run and (not settings.intervals_api_key or not settings.intervals_athlete_id):
198
+ typer.echo("Error: INTERVALS_API_KEY and INTERVALS_ATHLETE_ID must be set.", err=True)
199
+ raise typer.Exit(1)
200
+
201
+ typer.echo("Step 1/3: Scraping workout URLs ...")
202
+ grouped = scrape_workout_urls()
203
+ all_urls = [u for urls in grouped.values() for u in urls]
204
+ typer.echo(f" Found {len(all_urls)} URLs.")
205
+
206
+ typer.echo(f"Step 2/3: Downloading to {cache_dir} ...")
207
+ dl = download_all(all_urls, cache_dir, workers=workers, dry_run=dry_run)
208
+ typer.echo(f" ok={dl['ok']} skip={dl['skip']} error={dl['error']}")
209
+
210
+ fit_files = sorted(set(cache_dir.rglob("*.FIT")) | set(cache_dir.rglob("*.fit")))
211
+ typer.echo(f"Step 3/3: Uploading {len(fit_files)} workout(s) ...")
212
+
213
+ if dry_run:
214
+ typer.echo(" [DRY-RUN] Skipping upload.")
215
+ return
216
+
217
+ with IntervalsClient(settings.intervals_api_key, settings.intervals_athlete_id) as client:
218
+ counts = upload_workouts(fit_files, client, cycling_power=cycling_power, dry_run=dry_run)
219
+
220
+ typer.echo(
221
+ f" created={counts['created']} updated={counts['updated']} "
222
+ f"skipped={counts['skipped']} error={counts['error']}"
223
+ )
224
+ if counts["error"]:
225
+ raise typer.Exit(1)
226
+
227
+
228
+ if __name__ == "__main__":
229
+ app()
eticu/config.py ADDED
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic_settings import BaseSettings, SettingsConfigDict
4
+
5
+
6
+ class Settings(BaseSettings):
7
+ model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
8
+
9
+ intervals_api_key: str = ""
10
+ intervals_athlete_id: str = ""
11
+
12
+ @property
13
+ def base_url(self) -> str:
14
+ return f"https://intervals.icu/api/v1/athlete/{self.intervals_athlete_id}"
15
+
16
+
17
+ _settings: Settings | None = None
18
+
19
+
20
+ def get_settings() -> Settings:
21
+ global _settings
22
+ if _settings is None:
23
+ _settings = Settings()
24
+ return _settings
eticu/convert.py ADDED
@@ -0,0 +1,130 @@
1
+ """Convert a Workout model to intervals.icu workout description text.
2
+
3
+ Format notes (validated against intervals.icu documentation):
4
+ - Steps: "- [name] [duration] [target]"
5
+ - Time: "5m", "30s", "1h", "5m30s" ('m' = minutes)
6
+ - Distance run/ride: "2.0km" (km, rounded to 0.1 km)
7
+ - Distance swim: "400mtr" ('mtr' suffix for metres — 'mtr' ≠ 'm' which means minutes)
8
+ - HR target: "Z1 HR" (zone number + "HR" suffix)
9
+ - Power target: "Z1" (bare zone, power is the default target type)
10
+ - Repeat: "3x" on its own line; terminated by a BLANK LINE after the
11
+ last step in the block — without the blank line, subsequent
12
+ steps are interpreted as still inside the repeat.
13
+
14
+ NOTE: The ROADMAP §0.5 golden text omits the blank-line terminator. That
15
+ omission is corrected here based on verified intervals.icu format rules.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+
22
+ from eticu.models import Element, RepeatBlock, Step, Workout
23
+ from eticu.zones import icu_zone_label, step_label
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Duration formatting
30
+ # ---------------------------------------------------------------------------
31
+
32
+
33
+ def _format_time(seconds: float) -> str:
34
+ total = int(round(seconds))
35
+ h, rem = divmod(total, 3600)
36
+ m, s = divmod(rem, 60)
37
+ parts: list[str] = []
38
+ if h:
39
+ parts.append(f"{h}h")
40
+ if m:
41
+ parts.append(f"{m}m")
42
+ if s:
43
+ parts.append(f"{s}s")
44
+ return "".join(parts) if parts else "0s"
45
+
46
+
47
+ def _format_distance_run(metres: float) -> str:
48
+ """Round to nearest 0.1 km, emit as '2.0km'."""
49
+ km = round(metres / 1000, 1)
50
+ return f"{km:.1f}km"
51
+
52
+
53
+ def _format_distance_swim(metres: float) -> str:
54
+ """Round to nearest 25 m, emit as '400mtr' ('mtr' ≠ 'm' = minutes)."""
55
+ rounded = round(metres / 25) * 25
56
+ return f"{int(rounded)}mtr"
57
+
58
+
59
+ def _format_duration(step: Step, sport: str) -> str:
60
+ if step.kind == "time" and step.value > 0:
61
+ return _format_time(step.value)
62
+ if step.kind == "distance" and step.value > 0:
63
+ if sport == "Swim":
64
+ return _format_distance_swim(step.value)
65
+ return _format_distance_run(step.value)
66
+ return "lap"
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Target formatting
71
+ # ---------------------------------------------------------------------------
72
+
73
+
74
+ def _format_target(zone_8020: str | None, sport: str, cycling_power: bool) -> str:
75
+ icu_zone = icu_zone_label(zone_8020)
76
+ if sport == "Ride" and cycling_power:
77
+ return icu_zone # Power target: bare zone
78
+ return f"{icu_zone} HR" # HR target
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Step / block rendering
83
+ # ---------------------------------------------------------------------------
84
+
85
+
86
+ def _render_step(step: Step, sport: str, cycling_power: bool) -> str:
87
+ if step.zone_8020 is None and step.intensity not in ("rest", "recovery", "warmup", "cooldown"):
88
+ logger.warning("Step has no zone; defaulting to 8020/Z1 (Z1)")
89
+ label = step_label(step.zone_8020)
90
+ duration = _format_duration(step, sport)
91
+ target = _format_target(step.zone_8020, sport, cycling_power)
92
+
93
+ prefix = ""
94
+ if step.intensity and step.intensity not in ("active", "interval", "other"):
95
+ prefix = f"{step.intensity.capitalize()} "
96
+
97
+ return f"- {prefix}{label} {duration} {target}"
98
+
99
+
100
+ def _render_elements(
101
+ elements: list[Element],
102
+ sport: str,
103
+ cycling_power: bool,
104
+ ) -> list[str]:
105
+ lines: list[str] = []
106
+ for i, elem in enumerate(elements):
107
+ if isinstance(elem, Step):
108
+ lines.append(_render_step(elem, sport, cycling_power))
109
+ elif isinstance(elem, RepeatBlock):
110
+ if i > 0 and (not lines or lines[-1] != ""):
111
+ lines.append("")
112
+ lines.append(f"{elem.count}x")
113
+ lines.extend(_render_elements(elem.children, sport, cycling_power))
114
+ # Blank line terminates the repeat block in intervals.icu format.
115
+ # Without it, subsequent steps are treated as inside the block.
116
+ if i < len(elements) - 1:
117
+ lines.append("")
118
+ return lines
119
+
120
+
121
+ def convert(workout: Workout, cycling_power: bool = False) -> str:
122
+ """Render a Workout as intervals.icu workout description text.
123
+
124
+ Args:
125
+ workout: The parsed workout.
126
+ cycling_power: If True and sport is Ride, emit Power zone targets
127
+ instead of HR. Run/Swim always use HR regardless.
128
+ """
129
+ lines = _render_elements(workout.elements, workout.sport, cycling_power)
130
+ return "\n".join(lines)
eticu/download.py ADDED
@@ -0,0 +1,115 @@
1
+ """Download .FIT files to a local cache directory.
2
+
3
+ Features:
4
+ - Resumable: skips files that already exist and are non-empty.
5
+ - Atomic writes: download to a .part file, then rename.
6
+ - Modest concurrency with a configurable thread count.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import time
13
+ from concurrent.futures import ThreadPoolExecutor, as_completed
14
+ from pathlib import Path
15
+ from urllib.parse import unquote, urlparse
16
+
17
+ import httpx
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ _HEADERS = {
22
+ "User-Agent": (
23
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
24
+ "(KHTML, like Gecko) Chrome/124.0 Safari/537.36"
25
+ )
26
+ }
27
+
28
+
29
+ def local_path(url: str, cache_dir: Path) -> Path:
30
+ """Map a .FIT URL to a local path, preserving the sport subfolder."""
31
+ parts = [unquote(p) for p in urlparse(url).path.split("/") if p]
32
+ tail = parts[-2:] if len(parts) >= 2 else parts[-1:]
33
+ return cache_dir.joinpath(*tail)
34
+
35
+
36
+ def _download_one(
37
+ client: httpx.Client,
38
+ url: str,
39
+ cache_dir: Path,
40
+ retries: int = 3,
41
+ ) -> tuple[str, str]:
42
+ """Download a single file; return (url, status) where status ∈ ok/skip/error:…"""
43
+ dest = local_path(url, cache_dir)
44
+ if dest.exists() and dest.stat().st_size > 0:
45
+ return url, "skip"
46
+
47
+ dest.parent.mkdir(parents=True, exist_ok=True)
48
+ for attempt in range(1, retries + 1):
49
+ try:
50
+ with client.stream("GET", url, timeout=60) as r:
51
+ r.raise_for_status()
52
+ tmp = dest.with_suffix(dest.suffix + ".part")
53
+ with open(tmp, "wb") as f:
54
+ for chunk in r.iter_bytes(chunk_size=64 * 1024):
55
+ f.write(chunk)
56
+ tmp.replace(dest)
57
+ return url, "ok"
58
+ except httpx.HTTPError as exc:
59
+ if attempt == retries:
60
+ return url, f"error: {exc}"
61
+ time.sleep(2 * attempt)
62
+ return url, "error: exhausted retries"
63
+
64
+
65
+ def download_all(
66
+ urls: list[str],
67
+ cache_dir: Path,
68
+ *,
69
+ workers: int = 4,
70
+ dry_run: bool = False,
71
+ ) -> dict[str, int]:
72
+ """Download a list of .FIT URLs into cache_dir.
73
+
74
+ Returns counts: {"ok": N, "skip": N, "error": N}.
75
+ """
76
+ counts = {"ok": 0, "skip": 0, "error": 0}
77
+ if not urls:
78
+ return counts
79
+
80
+ cache_dir.mkdir(parents=True, exist_ok=True)
81
+
82
+ if dry_run:
83
+ for url in urls:
84
+ dest = local_path(url, cache_dir)
85
+ status = "skip" if (dest.exists() and dest.stat().st_size > 0) else "would-download"
86
+ logger.info("[DRY-RUN] %s %s", status, dest)
87
+
88
+ def _needs_download(u: str) -> bool:
89
+ p = local_path(u, cache_dir)
90
+ return not (p.exists() and p.stat().st_size > 0)
91
+
92
+ counts["ok"] = sum(1 for u in urls if _needs_download(u))
93
+ counts["skip"] = len(urls) - counts["ok"]
94
+ return counts
95
+
96
+ with (
97
+ httpx.Client(headers=_HEADERS, follow_redirects=True) as client,
98
+ ThreadPoolExecutor(max_workers=workers) as pool,
99
+ ):
100
+ futures = {pool.submit(_download_one, client, u, cache_dir): u for u in urls}
101
+ for i, fut in enumerate(as_completed(futures), 1):
102
+ url, status = fut.result()
103
+ dest = local_path(url, cache_dir)
104
+ rel = dest.relative_to(cache_dir) if dest.is_relative_to(cache_dir) else dest
105
+ if status == "ok":
106
+ counts["ok"] += 1
107
+ logger.info("[%4d/%d] OK %s", i, len(urls), rel)
108
+ elif status == "skip":
109
+ counts["skip"] += 1
110
+ logger.debug("[%4d/%d] SKIP %s", i, len(urls), rel)
111
+ else:
112
+ counts["error"] += 1
113
+ logger.error("[%4d/%d] FAIL %s (%s)", i, len(urls), rel, status)
114
+
115
+ return counts