intervalssync 0.9.3__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.
Files changed (39) hide show
  1. intervalssync/__init__.py +3 -0
  2. intervalssync/_dropbox_app_key.py +7 -0
  3. intervalssync/_version.py +1 -0
  4. intervalssync/bryton/__init__.py +1 -0
  5. intervalssync/bryton/api.py +49 -0
  6. intervalssync/bryton/core.py +249 -0
  7. intervalssync/bryton/ddp.py +231 -0
  8. intervalssync/bryton/exceptions.py +13 -0
  9. intervalssync/bryton/fit_encode.py +363 -0
  10. intervalssync/bryton/workout.py +342 -0
  11. intervalssync/cli/__init__.py +5 -0
  12. intervalssync/cli/config.py +53 -0
  13. intervalssync/cli/env.py +189 -0
  14. intervalssync/cli/main.py +637 -0
  15. intervalssync/dropbox_client.py +143 -0
  16. intervalssync/gui/__init__.py +1 -0
  17. intervalssync/gui/app.py +352 -0
  18. intervalssync/gui/config.py +102 -0
  19. intervalssync/gui/profile_sync_ui.py +225 -0
  20. intervalssync/gui/secrets.py +135 -0
  21. intervalssync/gui/settings_view.py +716 -0
  22. intervalssync/gui/support_gamification.py +658 -0
  23. intervalssync/gui/sync_view.py +486 -0
  24. intervalssync/gui/system.py +21 -0
  25. intervalssync/gui/theme.py +216 -0
  26. intervalssync/gui/update_check.py +55 -0
  27. intervalssync/igpsport/__init__.py +1 -0
  28. intervalssync/igpsport/core.py +520 -0
  29. intervalssync/igpsport/interval_info.py +269 -0
  30. intervalssync/igpsport/profile_sync.py +391 -0
  31. intervalssync/igpsport/region.py +80 -0
  32. intervalssync/igpsport/workout.py +503 -0
  33. intervalssync/igpsport/zone_map.py +143 -0
  34. intervalssync/intervals_icu.py +201 -0
  35. intervalssync-0.9.3.dist-info/METADATA +179 -0
  36. intervalssync-0.9.3.dist-info/RECORD +39 -0
  37. intervalssync-0.9.3.dist-info/WHEEL +4 -0
  38. intervalssync-0.9.3.dist-info/entry_points.txt +5 -0
  39. intervalssync-0.9.3.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,53 @@
1
+ """Non-secret CLI settings persisted as JSON.
2
+
3
+ Secrets never live here — they are read from the Hermes profile .env file via
4
+ `env.py`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from dataclasses import asdict, dataclass, field
11
+ from pathlib import Path
12
+
13
+ from platformdirs import user_config_dir, user_downloads_dir
14
+
15
+ APP_NAME = "intervalssync-cli"
16
+
17
+ CONFIG_DIR = Path(user_config_dir(APP_NAME, appauthor=False))
18
+ CONFIG_PATH = CONFIG_DIR / "config.json"
19
+
20
+
21
+ def _default_download_dir() -> str:
22
+ return str(Path(user_downloads_dir()) / "intervalssync-fit")
23
+
24
+
25
+ @dataclass
26
+ class CliConfig:
27
+ # Optional override for the secrets .env file path.
28
+ env_file: str = ""
29
+ max_activities: int = 5
30
+ download_dir: str = field(default_factory=_default_download_dir)
31
+ delete_after_upload: bool = True
32
+ force_resync: bool = False
33
+ activity_type: str = ""
34
+ # Planned workouts: intervals.icu event id → iGPSPORT workoutId.
35
+ uploaded_workouts: dict[str, int] = field(default_factory=dict)
36
+ # Planned workouts: intervals.icu event id → Bryton FIT filename stem.
37
+ uploaded_bryton_workouts: dict[str, str] = field(default_factory=dict)
38
+ # How many calendar days of planned workouts to upload (1 = today only).
39
+ workout_days_ahead: int = 1
40
+
41
+
42
+ def load() -> CliConfig:
43
+ """Load CLI config from disk, falling back to defaults."""
44
+ data: dict = {}
45
+ if CONFIG_PATH.exists():
46
+ data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
47
+
48
+ return CliConfig(**{k: v for k, v in data.items() if k in CliConfig.__annotations__})
49
+
50
+
51
+ def save(config: CliConfig) -> None:
52
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
53
+ CONFIG_PATH.write_text(json.dumps(asdict(config), indent=2), encoding="utf-8")
@@ -0,0 +1,189 @@
1
+ """Credential loading for the headless CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Literal
9
+
10
+ ActivitySource = Literal["igpsport", "bryton"]
11
+
12
+ IGPSPORT_USER_KEY = "INTERVALSSYNC_IGPSPORT_USER"
13
+ IGPSPORT_PASSWORD_KEY = "INTERVALSSYNC_IGPSPORT_PASSWORD"
14
+ IGPSPORT_REGION_KEY = "INTERVALSSYNC_IGPSPORT_REGION"
15
+ BRYTON_EMAIL_KEY = "INTERVALSSYNC_BRYTON_EMAIL"
16
+ BRYTON_PASSWORD_KEY = "INTERVALSSYNC_BRYTON_PASSWORD"
17
+ INTERVALS_API_KEY_KEY = "INTERVALSSYNC_INTERVALS_API_KEY"
18
+
19
+ IGPSPORT_REQUIRED_KEYS = (
20
+ IGPSPORT_USER_KEY,
21
+ IGPSPORT_PASSWORD_KEY,
22
+ INTERVALS_API_KEY_KEY,
23
+ )
24
+ BRYTON_REQUIRED_KEYS = (
25
+ BRYTON_EMAIL_KEY,
26
+ BRYTON_PASSWORD_KEY,
27
+ INTERVALS_API_KEY_KEY,
28
+ )
29
+
30
+ _IGPSPORT_BODY = (
31
+ f" {IGPSPORT_USER_KEY}=<email>\n"
32
+ f" {IGPSPORT_PASSWORD_KEY}=<password>\n"
33
+ f" {INTERVALS_API_KEY_KEY}=<api-key>\n"
34
+ f" {IGPSPORT_REGION_KEY}=international # or china for app.igpsport.cn"
35
+ )
36
+ _BRYTON_BODY = (
37
+ f" {BRYTON_EMAIL_KEY}=<email>\n"
38
+ f" {BRYTON_PASSWORD_KEY}=<password>\n"
39
+ f" {INTERVALS_API_KEY_KEY}=<api-key>"
40
+ )
41
+
42
+ ENV_OVERRIDE_HINT = (
43
+ "Point intervalssync at a different file:\n"
44
+ " intervalssync <command> --env-file /path/to/.env\n"
45
+ "You can also set env_file in the intervalssync-cli config.json."
46
+ )
47
+
48
+ SETUP_HINT_IGPSPORT = (
49
+ "Add the required keys to your profile .env (see docs/AGENT.md):\n"
50
+ f" echo '{IGPSPORT_USER_KEY}=<email>' >> {{profile_home}}/.env\n"
51
+ f" echo '{IGPSPORT_PASSWORD_KEY}=<password>' >> {{profile_home}}/.env\n"
52
+ f" echo '{INTERVALS_API_KEY_KEY}=<api-key>' >> {{profile_home}}/.env"
53
+ )
54
+
55
+ SETUP_HINT_BRYTON = (
56
+ "Add the required keys to your profile .env (see docs/AGENT.md):\n"
57
+ f" echo '{BRYTON_EMAIL_KEY}=<email>' >> {{profile_home}}/.env\n"
58
+ f" echo '{BRYTON_PASSWORD_KEY}=<password>' >> {{profile_home}}/.env\n"
59
+ f" echo '{INTERVALS_API_KEY_KEY}=<api-key>' >> {{profile_home}}/.env"
60
+ )
61
+
62
+
63
+ class CliConfigError(Exception):
64
+ """Missing or invalid CLI credentials / env file."""
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class IgpsportCredentials:
69
+ igp_user: str
70
+ igp_password: str
71
+ intervals_api_key: str
72
+ igp_region: str = "international"
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class BrytonCredentials:
77
+ bryton_email: str
78
+ bryton_password: str
79
+ intervals_api_key: str
80
+
81
+
82
+ def _default_hermes_env_path() -> Path:
83
+ hermes_home = os.environ.get("HERMES_HOME")
84
+ if hermes_home:
85
+ return Path(hermes_home) / ".env"
86
+ return Path.home() / ".hermes" / ".env"
87
+
88
+
89
+ def resolve_env_path(
90
+ *,
91
+ env_file: Path | None = None,
92
+ config_env_file: str | None = None,
93
+ ) -> Path:
94
+ """Resolve the secrets .env path (first match wins)."""
95
+ if env_file is not None:
96
+ return env_file.expanduser()
97
+ if config_env_file:
98
+ return Path(config_env_file).expanduser()
99
+ return _default_hermes_env_path()
100
+
101
+
102
+ def parse_dotenv(text: str) -> dict[str, str]:
103
+ """Parse KEY=value lines from a .env file (stdlib only)."""
104
+ values: dict[str, str] = {}
105
+ for raw_line in text.splitlines():
106
+ line = raw_line.strip()
107
+ if not line or line.startswith("#"):
108
+ continue
109
+ if line.startswith("export "):
110
+ line = line[len("export ") :].strip()
111
+ if "=" not in line:
112
+ continue
113
+ key, _, value = line.partition("=")
114
+ key = key.strip()
115
+ value = value.strip()
116
+ if not key:
117
+ continue
118
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
119
+ value = value[1:-1]
120
+ values[key] = value
121
+ return values
122
+
123
+
124
+ def _secrets_file_not_found_message(env_path: Path) -> str:
125
+ return (
126
+ f"Secrets file not found: {env_path}\n\n"
127
+ f"Create that file with iGPSPORT keys:\n{_IGPSPORT_BODY}\n\n"
128
+ f"or Bryton keys:\n{_BRYTON_BODY}\n\n"
129
+ f"{ENV_OVERRIDE_HINT}\n\n"
130
+ "For local development, copy .env.example to .env and pass --env-file .env."
131
+ )
132
+
133
+
134
+ def _missing_keys_message(
135
+ env_path: Path, missing: list[str], *, source: ActivitySource
136
+ ) -> str:
137
+ body = _IGPSPORT_BODY if source == "igpsport" else _BRYTON_BODY
138
+ hint = SETUP_HINT_IGPSPORT if source == "igpsport" else SETUP_HINT_BRYTON
139
+ return (
140
+ f"Missing required keys in {env_path}: {', '.join(missing)}\n\n"
141
+ f"Add them to the file:\n{body}\n\n{hint}"
142
+ )
143
+
144
+
145
+ def load_igpsport_credentials(env_path: Path) -> IgpsportCredentials:
146
+ if not env_path.is_file():
147
+ raise CliConfigError(_secrets_file_not_found_message(env_path))
148
+
149
+ values = parse_dotenv(env_path.read_text(encoding="utf-8"))
150
+ missing = [key for key in IGPSPORT_REQUIRED_KEYS if not values.get(key)]
151
+ if missing:
152
+ raise CliConfigError(_missing_keys_message(env_path, missing, source="igpsport"))
153
+
154
+ region = values.get(IGPSPORT_REGION_KEY, "international") or "international"
155
+ if region not in ("international", "china"):
156
+ raise CliConfigError(
157
+ f"Invalid {IGPSPORT_REGION_KEY}={region!r} in {env_path} "
158
+ "(expected 'international' or 'china')."
159
+ )
160
+
161
+ return IgpsportCredentials(
162
+ igp_user=values[IGPSPORT_USER_KEY],
163
+ igp_password=values[IGPSPORT_PASSWORD_KEY],
164
+ intervals_api_key=values[INTERVALS_API_KEY_KEY],
165
+ igp_region=region,
166
+ )
167
+
168
+
169
+ def load_bryton_credentials(env_path: Path) -> BrytonCredentials:
170
+ if not env_path.is_file():
171
+ raise CliConfigError(_secrets_file_not_found_message(env_path))
172
+
173
+ values = parse_dotenv(env_path.read_text(encoding="utf-8"))
174
+ missing = [key for key in BRYTON_REQUIRED_KEYS if not values.get(key)]
175
+ if missing:
176
+ raise CliConfigError(_missing_keys_message(env_path, missing, source="bryton"))
177
+
178
+ return BrytonCredentials(
179
+ bryton_email=values[BRYTON_EMAIL_KEY],
180
+ bryton_password=values[BRYTON_PASSWORD_KEY],
181
+ intervals_api_key=values[INTERVALS_API_KEY_KEY],
182
+ )
183
+
184
+
185
+ def load_credentials(env_path: Path, source: ActivitySource = "igpsport"):
186
+ """Load credentials for the given activity source."""
187
+ if source == "bryton":
188
+ return load_bryton_credentials(env_path)
189
+ return load_igpsport_credentials(env_path)