catence 0.0.0 → 0.1.0

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 (106) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +373 -29
  3. package/config.example.json +63 -0
  4. package/dist/contracts/index.d.ts +4 -0
  5. package/dist/contracts/index.js +5 -0
  6. package/dist/contracts/release.d.ts +20 -0
  7. package/dist/contracts/release.js +17 -0
  8. package/dist/contracts/runtime.d.ts +11 -0
  9. package/dist/contracts/runtime.js +2 -0
  10. package/dist/contracts/staging.d.ts +183 -0
  11. package/dist/contracts/staging.js +36 -0
  12. package/dist/contracts/storage.d.ts +15 -0
  13. package/dist/contracts/storage.js +2 -0
  14. package/dist/core/dashboard/snapshot.d.ts +16 -0
  15. package/dist/core/dashboard/snapshot.js +65 -0
  16. package/dist/core/query/activity-discovery.d.ts +21 -0
  17. package/dist/core/query/activity-discovery.js +133 -0
  18. package/dist/core/query/analytics.d.ts +59 -0
  19. package/dist/core/query/analytics.js +395 -0
  20. package/dist/core/query/catalog.d.ts +26 -0
  21. package/dist/core/query/catalog.js +141 -0
  22. package/dist/core/query/fitness.d.ts +40 -0
  23. package/dist/core/query/fitness.js +297 -0
  24. package/dist/core/query/repository.d.ts +19 -0
  25. package/dist/core/query/repository.js +182 -0
  26. package/dist/core/query/sql-guard.d.ts +7 -0
  27. package/dist/core/query/sql-guard.js +121 -0
  28. package/dist/core/query/sql.d.ts +2 -0
  29. package/dist/core/query/sql.js +5 -0
  30. package/dist/core/query/swimming.d.ts +19 -0
  31. package/dist/core/query/swimming.js +184 -0
  32. package/dist/core/query/wellness.d.ts +45 -0
  33. package/dist/core/query/wellness.js +292 -0
  34. package/dist/core/retrieval/index.d.ts +13 -0
  35. package/dist/core/retrieval/index.js +248 -0
  36. package/dist/core/runtime/configuration.d.ts +71 -0
  37. package/dist/core/runtime/configuration.js +106 -0
  38. package/dist/core/runtime/limiter.d.ts +13 -0
  39. package/dist/core/runtime/limiter.js +17 -0
  40. package/dist/elt/application/demo.d.ts +23 -0
  41. package/dist/elt/application/demo.js +165 -0
  42. package/dist/elt/application/management.d.ts +25 -0
  43. package/dist/elt/application/management.js +283 -0
  44. package/dist/elt/ingestion/importer.d.ts +8 -0
  45. package/dist/elt/ingestion/importer.js +87 -0
  46. package/dist/elt/ingestion/providers/intervals.d.ts +21 -0
  47. package/dist/elt/ingestion/providers/intervals.js +224 -0
  48. package/dist/elt/ingestion/providers/registry.d.ts +19 -0
  49. package/dist/elt/ingestion/providers/registry.js +62 -0
  50. package/dist/elt/ingestion/providers/strava/service.d.ts +42 -0
  51. package/dist/elt/ingestion/providers/strava/service.js +365 -0
  52. package/dist/elt/normalization/activities/linking.d.ts +5 -0
  53. package/dist/elt/normalization/activities/linking.js +165 -0
  54. package/dist/elt/normalization/normalizers.d.ts +5 -0
  55. package/dist/elt/normalization/normalizers.js +816 -0
  56. package/dist/elt/normalization/segments/strava-normalizer.d.ts +3 -0
  57. package/dist/elt/normalization/segments/strava-normalizer.js +93 -0
  58. package/dist/elt/storage/artifacts.d.ts +13 -0
  59. package/dist/elt/storage/artifacts.js +48 -0
  60. package/dist/elt/storage/database.d.ts +74 -0
  61. package/dist/elt/storage/database.js +296 -0
  62. package/dist/elt/storage/migrations.d.ts +3 -0
  63. package/dist/elt/storage/migrations.js +832 -0
  64. package/dist/elt/storage/migrations.legacy.d.ts +12 -0
  65. package/dist/elt/storage/migrations.legacy.js +332 -0
  66. package/dist/elt/storage/sql.d.ts +2 -0
  67. package/dist/elt/storage/sql.js +7 -0
  68. package/dist/elt/storage/write-lock.d.ts +6 -0
  69. package/dist/elt/storage/write-lock.js +28 -0
  70. package/dist/elt/streams.d.ts +24 -0
  71. package/dist/elt/streams.js +62 -0
  72. package/dist/interfaces/cli/commands.d.ts +2 -0
  73. package/dist/interfaces/cli/commands.js +101 -0
  74. package/dist/interfaces/cli/main.d.ts +2 -0
  75. package/dist/interfaces/cli/main.js +3 -0
  76. package/dist/interfaces/http/cli-options.d.ts +9 -0
  77. package/dist/interfaces/http/cli-options.js +81 -0
  78. package/dist/interfaces/http/server.d.ts +14 -0
  79. package/dist/interfaces/http/server.js +169 -0
  80. package/dist/interfaces/mcp/cli-options.d.ts +6 -0
  81. package/dist/interfaces/mcp/cli-options.js +36 -0
  82. package/dist/interfaces/mcp/main.d.ts +2 -0
  83. package/dist/interfaces/mcp/main.js +59 -0
  84. package/dist/interfaces/mcp/server.d.ts +8 -0
  85. package/dist/interfaces/mcp/server.js +521 -0
  86. package/dist/runtime/index.d.ts +29 -0
  87. package/dist/runtime/index.js +19 -0
  88. package/docs/architecture.md +100 -0
  89. package/docs/assets/image.png +0 -0
  90. package/docs/mcp-activity-retrieval.md +377 -0
  91. package/package.json +81 -6
  92. package/pyproject.toml +28 -0
  93. package/python/__init__.py +1 -0
  94. package/python/catence/__init__.py +0 -0
  95. package/python/catence/providers/__init__.py +0 -0
  96. package/python/catence/providers/garmin/__init__.py +3 -0
  97. package/python/catence/providers/garmin/cli.py +62 -0
  98. package/python/catence/providers/garmin/contracts.py +23 -0
  99. package/python/catence/providers/garmin/registry.py +48 -0
  100. package/python/catence/providers/garmin/staging.py +87 -0
  101. package/python/catence/providers/garmin/streams.py +195 -0
  102. package/python/catence/providers/garmin/worker.py +450 -0
  103. package/python/catence/providers/strava/__init__.py +3 -0
  104. package/python/catence/providers/strava/cli.py +404 -0
  105. package/python/catence/providers/strava/staging.py +73 -0
  106. package/uv.lock +708 -0
@@ -0,0 +1,404 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import stat
7
+ import sys
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from .staging import StravaStagingWriter
13
+
14
+ SCOPES = ["read", "activity:read_all", "read_all"]
15
+ READ_ALLOWLIST = {
16
+ "athlete", "gear", "activity_candidates", "activity_detail", "segment", "segment_efforts",
17
+ }
18
+
19
+
20
+ class StravaConnectionError(RuntimeError):
21
+ pass
22
+
23
+
24
+ def parse_args() -> argparse.Namespace:
25
+ parser = argparse.ArgumentParser(description="Stage targeted, read-only Strava data for Catence.")
26
+ parser.add_argument("--mode", required=True, choices=["authorization-url", "auth", "gear", "activity", "segment-history"])
27
+ parser.add_argument("--data-dir", required=True, type=Path)
28
+ parser.add_argument("--output", type=Path)
29
+ parser.add_argument("--result", required=True, type=Path)
30
+ parser.add_argument("--run-id")
31
+ parser.add_argument("--code")
32
+ parser.add_argument("--redirect-uri", default="http://localhost")
33
+ parser.add_argument("--activity-id")
34
+ parser.add_argument("--strava-activity-id")
35
+ parser.add_argument("--strava-match-method")
36
+ parser.add_argument("--started-at")
37
+ parser.add_argument("--sport")
38
+ parser.add_argument("--distance-m", type=float)
39
+ parser.add_argument("--elapsed-s", type=float)
40
+ parser.add_argument("--segment-id")
41
+ parser.add_argument("--start-page", type=int, default=1)
42
+ parser.add_argument("--refresh", action="store_true")
43
+ return parser.parse_args()
44
+
45
+
46
+ def secret_path(data_dir: Path) -> Path:
47
+ return data_dir / "secrets" / "strava.json"
48
+
49
+
50
+ def write_secret(data_dir: Path, value: dict[str, Any]) -> None:
51
+ destination = secret_path(data_dir)
52
+ destination.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
53
+ temporary = destination.with_suffix(".tmp")
54
+ temporary.write_text(json.dumps(value, indent=2, sort_keys=True, default=str) + "\n", encoding="utf-8")
55
+ os.chmod(temporary, stat.S_IRUSR | stat.S_IWUSR)
56
+ os.replace(temporary, destination)
57
+ os.chmod(destination, stat.S_IRUSR | stat.S_IWUSR)
58
+
59
+
60
+ def read_secret(data_dir: Path) -> dict[str, Any]:
61
+ destination = secret_path(data_dir)
62
+ try:
63
+ value = json.loads(destination.read_text(encoding="utf-8"))
64
+ except FileNotFoundError as error:
65
+ raise StravaConnectionError("Strava is not connected. Run `catence-data auth strava` first.") from error
66
+ if not isinstance(value, dict) or not value.get("client_id") or not value.get("client_secret") or not value.get("access_token"):
67
+ raise StravaConnectionError("Strava credentials are incomplete. Run `catence-data auth strava` again.")
68
+ return value
69
+
70
+
71
+ def object_value(value: Any) -> dict[str, Any]:
72
+ if isinstance(value, dict):
73
+ return value
74
+ if hasattr(value, "model_dump"):
75
+ dumped = value.model_dump()
76
+ return dumped if isinstance(dumped, dict) else {}
77
+ if hasattr(value, "dict"):
78
+ dumped = value.dict()
79
+ return dumped if isinstance(dumped, dict) else {}
80
+ return {}
81
+
82
+
83
+ def timestamp(value: Any) -> float | None:
84
+ if not isinstance(value, str):
85
+ return None
86
+ try:
87
+ return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
88
+ except ValueError:
89
+ return None
90
+
91
+
92
+ def sport_family(value: Any) -> str | None:
93
+ if not isinstance(value, str):
94
+ return None
95
+ normalized = "".join(character for character in value.lower() if character.isalpha())
96
+ if "ride" in normalized or "cycl" in normalized or "bik" in normalized:
97
+ return "ride"
98
+ if "run" in normalized:
99
+ return "run"
100
+ if "swim" in normalized:
101
+ return "swim"
102
+ if "walk" in normalized:
103
+ return "walk"
104
+ if "strength" in normalized or "weight" in normalized:
105
+ return "strength"
106
+ return normalized or None
107
+
108
+
109
+ def is_virtual_or_indoor(value: Any) -> bool:
110
+ return isinstance(value, str) and ("virtual" in value.lower() or "indoor" in value.lower())
111
+
112
+
113
+ def is_not_found(error: Exception) -> bool:
114
+ message = str(error).lower()
115
+ return "404" in message or "not found" in message or "record not found" in message
116
+
117
+
118
+ def matches_activity(candidate: dict[str, Any], started_at: str, sport: str | None, distance_m: float | None, elapsed_s: float | None) -> tuple[bool, dict[str, Any]]:
119
+ candidate_start = timestamp(candidate.get("start_date"))
120
+ requested_start = timestamp(started_at)
121
+ candidate_distance = candidate.get("distance")
122
+ candidate_elapsed = candidate.get("elapsed_time")
123
+ requested_sport = sport_family(sport)
124
+ candidate_sport = sport_family(candidate.get("sport_type") or candidate.get("type"))
125
+ evidence: dict[str, Any] = {
126
+ "requestedSportFamily": requested_sport,
127
+ "candidateSportFamily": candidate_sport,
128
+ "candidateVirtualOrIndoor": is_virtual_or_indoor(candidate.get("sport_type") or candidate.get("type")),
129
+ "rejectionReasons": [],
130
+ }
131
+ if requested_start is None or candidate_start is None or not isinstance(candidate_distance, (int, float)) or not isinstance(candidate_elapsed, (int, float)):
132
+ evidence["rejectionReasons"] = ["candidate_missing_required_fields"]
133
+ return False, evidence
134
+ if distance_m is None or elapsed_s is None:
135
+ evidence["rejectionReasons"] = ["catence_activity_missing_required_fields"]
136
+ return False, evidence
137
+ start_delta = abs(requested_start - candidate_start)
138
+ duration_delta = abs(elapsed_s - float(candidate_elapsed))
139
+ distance_delta = abs(distance_m - float(candidate_distance))
140
+ duration_limit = max(120.0, elapsed_s * 0.05)
141
+ distance_limit = max(200.0, distance_m * 0.025)
142
+ evidence.update({
143
+ "startDeltaSeconds": start_delta, "elapsedDurationDeltaSeconds": duration_delta,
144
+ "distanceDeltaMeters": distance_delta, "elapsedDurationLimitSeconds": duration_limit,
145
+ "distanceLimitMeters": distance_limit,
146
+ })
147
+ reasons: list[str] = []
148
+ if candidate_sport != requested_sport:
149
+ reasons.append("sport_family_mismatch")
150
+ if start_delta > 90:
151
+ reasons.append("start_time_outside_90_second_window")
152
+ if duration_delta > duration_limit:
153
+ reasons.append("elapsed_duration_outside_tolerance")
154
+ if distance_delta > distance_limit:
155
+ reasons.append("distance_outside_tolerance")
156
+ evidence["rejectionReasons"] = reasons
157
+ return not reasons, evidence
158
+
159
+
160
+ class StravaReader:
161
+ """A raw GET-only adapter around stravalib's OAuth/session support."""
162
+
163
+ def __init__(self, data_dir: Path):
164
+ try:
165
+ from stravalib.client import Client
166
+ except ImportError as error:
167
+ raise StravaConnectionError("stravalib is unavailable. Install the Catence Python dependencies before connecting Strava.") from error
168
+ self.data_dir = data_dir
169
+ self.secret = read_secret(data_dir)
170
+ self.client = Client(access_token=str(self.secret["access_token"]))
171
+ self.observed_headers: dict[str, str] = {}
172
+ self._delegate_rate_limiter = self.client.protocol.rate_limiter
173
+ self.client.protocol.rate_limiter = self._capture_rate_headers
174
+ self._refresh_if_needed()
175
+
176
+ @staticmethod
177
+ def authorization_url(client_id: str, redirect_uri: str) -> str:
178
+ from stravalib.client import Client
179
+ return Client().authorization_url(client_id=client_id, redirect_uri=redirect_uri, scope=SCOPES)
180
+
181
+ @staticmethod
182
+ def exchange_code(data_dir: Path, code: str, redirect_uri: str, client_id: str, client_secret: str) -> dict[str, Any]:
183
+ from stravalib.client import Client
184
+ client = Client()
185
+ token = object_value(client.exchange_code_for_token(client_id=client_id, client_secret=client_secret, code=code))
186
+ token["client_id"] = client_id
187
+ token["client_secret"] = client_secret
188
+ token["redirect_uri"] = redirect_uri
189
+ token["scopes"] = SCOPES
190
+ if not token.get("access_token"):
191
+ raise StravaConnectionError("Strava did not return an access token. Check the authorization code and requested scopes.")
192
+ write_secret(data_dir, token)
193
+ return token
194
+
195
+ def _refresh_if_needed(self) -> None:
196
+ expires_at = self.secret.get("expires_at")
197
+ if not isinstance(expires_at, (int, float)) or expires_at > datetime.now(timezone.utc).timestamp() + 60:
198
+ return
199
+ refreshed = object_value(self.client.refresh_access_token(
200
+ client_id=str(self.secret["client_id"]), client_secret=str(self.secret["client_secret"]),
201
+ refresh_token=str(self.secret.get("refresh_token") or ""),
202
+ ))
203
+ if not refreshed.get("access_token"):
204
+ raise StravaConnectionError("Strava token refresh failed. Reconnect with `catence-data auth strava`.")
205
+ self.secret.update(refreshed)
206
+ write_secret(self.data_dir, self.secret)
207
+ self.client.access_token = str(self.secret["access_token"])
208
+
209
+ def _capture_rate_headers(self, headers: dict[str, str], method: Any) -> None:
210
+ """Record Strava's rate-limit headers without disabling stravalib's limiter.
211
+
212
+ ``ApiV3`` calls its rate limiter after every non-OAuth response with
213
+ ``(response.headers, request_method)``. Keep the header names stable
214
+ for the TypeScript worker result while delegating to the limiter that
215
+ stravalib installed on the client.
216
+ """
217
+ for name, value in headers.items():
218
+ normalized = str(name).lower()
219
+ if normalized in {"x-ratelimit-limit", "x-ratelimit-usage"}:
220
+ self.observed_headers[normalized] = str(value)
221
+ self._delegate_rate_limiter(headers, method)
222
+
223
+ def get(self, endpoint: str, path: str, **params: Any) -> Any:
224
+ if endpoint not in READ_ALLOWLIST:
225
+ raise RuntimeError(f"Strava endpoint {endpoint!r} is not in Catence's GET allowlist.")
226
+ try:
227
+ return self.client.protocol.get(path, **params)
228
+ except Exception as error:
229
+ if "rate" in type(error).__name__.lower() or "429" in str(error):
230
+ raise StravaConnectionError("Strava rate limited this request (HTTP 429).") from error
231
+ raise
232
+
233
+
234
+ def result_path_write(path: Path, result: dict[str, Any]) -> None:
235
+ path.parent.mkdir(parents=True, exist_ok=True)
236
+ path.write_text(json.dumps(result, indent=2, sort_keys=True, default=str) + "\n", encoding="utf-8")
237
+
238
+
239
+ def stage_gear(reader: StravaReader, writer: StravaStagingWriter) -> dict[str, Any]:
240
+ athlete = object_value(reader.get("athlete", "/athlete"))
241
+ athlete_hash = writer.archive_json("athlete", str(athlete.get("id") or "athlete"), athlete)
242
+ writer.source_entity("athlete", str(athlete.get("id") or "athlete"), athlete, athlete_hash)
243
+ gear_ids = [str(item.get("id")) for key in ("bikes", "shoes") for item in athlete.get(key, []) if isinstance(item, dict) and item.get("id")]
244
+ for gear_id in gear_ids:
245
+ gear = object_value(reader.get("gear", f"/gear/{gear_id}"))
246
+ raw_hash = writer.archive_json("gear", gear_id, gear)
247
+ writer.source_entity("gear", gear_id, gear, raw_hash)
248
+ return {"status": "completed", "gearCount": len(gear_ids), "rateHeaders": reader.observed_headers}
249
+
250
+
251
+ def stage_activity(reader: StravaReader, writer: StravaStagingWriter, args: argparse.Namespace) -> dict[str, Any]:
252
+ direct_lookup_unavailable = False
253
+ if args.strava_activity_id:
254
+ remote_id = str(args.strava_activity_id)
255
+ match_method = args.strava_match_method or "strong_strava_id"
256
+ try:
257
+ detail = object_value(reader.get("activity_detail", f"/activities/{remote_id}", include_all_efforts="true"))
258
+ if str(detail.get("id")) != remote_id:
259
+ raise StravaConnectionError("Strava returned an activity whose ID did not match Catence's provider-supplied Strava ID.")
260
+ detail_hash = writer.archive_json("activity_detail", remote_id, detail, {"activitySourceId": args.activity_id, "includeAllEfforts": True, "matchMethod": match_method})
261
+ writer.source_entity("activity", remote_id, detail, detail_hash)
262
+ gear_id = detail.get("gear_id")
263
+ if isinstance(gear_id, (str, int)) and str(gear_id):
264
+ gear = object_value(reader.get("gear", f"/gear/{gear_id}"))
265
+ gear_hash = writer.archive_json("gear", str(gear_id), gear, {"activityId": remote_id})
266
+ writer.source_entity("gear", str(gear_id), gear, gear_hash)
267
+ return {"status": "completed", "stravaActivityId": remote_id, "rawHash": detail_hash, "matchEvidence": {"method": match_method, "stravaActivityId": remote_id}, "rateHeaders": reader.observed_headers}
268
+ except Exception as error:
269
+ if match_method == "source_strava_id" and is_not_found(error):
270
+ return {
271
+ "status": "not_found", "stravaActivityId": remote_id,
272
+ "message": "The source's archived Strava activity ID is not available to the authenticated Strava account.",
273
+ "matchEvidence": {"method": match_method, "stravaActivityId": remote_id},
274
+ "matchDiagnostics": {
275
+ "strategy": "direct_strava_activity_id",
276
+ "likelyReasons": ["The activity is unavailable to the authenticated Strava account.", "Confirm the connected athlete and that the activity has not been deleted or made inaccessible."],
277
+ },
278
+ "rateHeaders": reader.observed_headers,
279
+ }
280
+ if not is_not_found(error):
281
+ raise
282
+ direct_lookup_unavailable = True
283
+ if not args.started_at or not args.sport or args.distance_m is None or args.elapsed_s is None:
284
+ raise StravaConnectionError("Activity hydration requires a start time, sport, distance, and elapsed duration from the Catence activity.")
285
+ start = timestamp(args.started_at)
286
+ if start is None:
287
+ raise StravaConnectionError("The Catence activity has an invalid start time and cannot be matched to Strava.")
288
+ candidates = reader.get("activity_candidates", "/athlete/activities", after=int(start - 90), before=int(start + 90), page=1, per_page=200)
289
+ if not isinstance(candidates, list):
290
+ candidates = []
291
+ candidates_hash = writer.archive_json("activity_candidates", args.activity_id, candidates, {"activitySourceId": args.activity_id})
292
+ qualified: list[tuple[dict[str, Any], dict[str, Any]]] = []
293
+ rejection_counts: dict[str, int] = {}
294
+ for candidate in candidates:
295
+ if not isinstance(candidate, dict):
296
+ continue
297
+ accepted, evidence = matches_activity(candidate, args.started_at, args.sport, args.distance_m, args.elapsed_s)
298
+ if accepted:
299
+ qualified.append((candidate, evidence))
300
+ else:
301
+ for reason in evidence.get("rejectionReasons", []):
302
+ rejection_counts[reason] = rejection_counts.get(reason, 0) + 1
303
+ if len(qualified) != 1:
304
+ search_start = datetime.fromtimestamp(start - 90, timezone.utc).isoformat().replace("+00:00", "Z")
305
+ search_end = datetime.fromtimestamp(start + 90, timezone.utc).isoformat().replace("+00:00", "Z")
306
+ if not candidates:
307
+ likely_reasons = [
308
+ "Strava returned no activities in the exact ±90-second search window.",
309
+ "Confirm that Catence is connected to the same Strava athlete and that activity:read_all access includes the activity.",
310
+ "A private, deleted, or differently timestamped Strava activity cannot be matched from this result.",
311
+ ]
312
+ elif not qualified:
313
+ likely_reasons = ["Strava returned activities, but none met Catence's safe sport, time, elapsed-duration, and distance tolerances."]
314
+ else:
315
+ likely_reasons = ["More than one Strava activity met the safe matching tolerances; Catence will not choose one automatically."]
316
+ return {
317
+ "status": "not_found" if not qualified else "ambiguous", "candidateCount": len(qualified),
318
+ "candidatesRawHash": candidates_hash,
319
+ "matchDiagnostics": {
320
+ "strategy": "time_sport_distance",
321
+ "searchWindow": {"startAt": search_start, "endAt": search_end},
322
+ "returnedCandidateCount": len(candidates), "qualifiedCandidateCount": len(qualified),
323
+ "rejectionCounts": rejection_counts, "directLookupUnavailable": direct_lookup_unavailable,
324
+ "likelyReasons": likely_reasons,
325
+ },
326
+ "rateHeaders": reader.observed_headers,
327
+ }
328
+ selected, evidence = qualified[0]
329
+ remote_id = str(selected["id"])
330
+ detail = object_value(reader.get("activity_detail", f"/activities/{remote_id}", include_all_efforts="true"))
331
+ detail_hash = writer.archive_json("activity_detail", remote_id, detail, {"activitySourceId": args.activity_id, "includeAllEfforts": True})
332
+ writer.source_entity("activity", remote_id, detail, detail_hash, occurred_on=str(detail.get("start_date") or "")[:10] or None)
333
+ gear_id = detail.get("gear_id")
334
+ if isinstance(gear_id, (str, int)) and str(gear_id):
335
+ gear = object_value(reader.get("gear", f"/gear/{gear_id}"))
336
+ gear_hash = writer.archive_json("gear", str(gear_id), gear, {"activityId": remote_id})
337
+ writer.source_entity("gear", str(gear_id), gear, gear_hash)
338
+ return {"status": "completed", "stravaActivityId": remote_id, "rawHash": detail_hash, "matchEvidence": evidence, "rateHeaders": reader.observed_headers}
339
+
340
+
341
+ def stage_segment_history(reader: StravaReader, writer: StravaStagingWriter, args: argparse.Namespace) -> dict[str, Any]:
342
+ if not args.segment_id:
343
+ raise StravaConnectionError("Segment history hydration requires a persisted Strava segment ID.")
344
+ segment = object_value(reader.get("segment", f"/segments/{args.segment_id}"))
345
+ segment_hash = writer.archive_json("segment", args.segment_id, segment)
346
+ writer.source_entity("segment", args.segment_id, segment, segment_hash)
347
+ page = max(1, args.start_page)
348
+ effort_count = 0
349
+ while True:
350
+ try:
351
+ efforts = reader.get("segment_efforts", "/segment_efforts", segment_id=args.segment_id, page=page, per_page=200)
352
+ except StravaConnectionError as error:
353
+ if "rate limited" in str(error).lower():
354
+ return {"status": "partial", "segmentId": args.segment_id, "effortCount": effort_count, "continuationPage": page, "message": str(error), "rateHeaders": reader.observed_headers}
355
+ raise
356
+ if not isinstance(efforts, list):
357
+ efforts = []
358
+ raw_hash = writer.archive_json("segment_efforts", args.segment_id, efforts, {"segmentId": args.segment_id, "page": page})
359
+ for effort in efforts:
360
+ if not isinstance(effort, dict) or not effort.get("id"):
361
+ continue
362
+ effort.setdefault("segment", segment)
363
+ writer.source_entity("segment_effort", str(effort["id"]), effort, raw_hash, parent_remote_id=args.segment_id, occurred_on=str(effort.get("start_date") or "")[:10] or None)
364
+ effort_count += 1
365
+ if len(efforts) < 200:
366
+ return {"status": "completed", "segmentId": args.segment_id, "effortCount": effort_count, "lastCompletedPage": page, "rateHeaders": reader.observed_headers}
367
+ page += 1
368
+
369
+
370
+ def main() -> None:
371
+ args = parse_args()
372
+ client_id = os.environ.get("STRAVA_CLIENT_ID")
373
+ client_secret = os.environ.get("STRAVA_CLIENT_SECRET")
374
+ try:
375
+ if args.mode == "authorization-url":
376
+ if not client_id:
377
+ raise StravaConnectionError("STRAVA_CLIENT_ID is required to begin Strava authorization.")
378
+ result_path_write(args.result, {"status": "authorization_required", "authorizationUrl": StravaReader.authorization_url(client_id, args.redirect_uri), "scopes": SCOPES})
379
+ return
380
+ if args.mode == "auth":
381
+ if not args.code or not client_id or not client_secret:
382
+ raise StravaConnectionError("STRAVA_CLIENT_ID, STRAVA_CLIENT_SECRET, and --code are required to complete Strava authorization.")
383
+ token = StravaReader.exchange_code(args.data_dir, args.code, args.redirect_uri, client_id, client_secret)
384
+ result_path_write(args.result, {"status": "connected", "athleteId": object_value(token.get("athlete")).get("id"), "scopes": SCOPES})
385
+ return
386
+ if not args.output or not args.run_id:
387
+ raise StravaConnectionError("Strava read operations require --output and --run-id.")
388
+ writer = StravaStagingWriter(args.data_dir, args.output)
389
+ writer.manifest(args.run_id, datetime.now(timezone.utc).date().isoformat())
390
+ reader = StravaReader(args.data_dir)
391
+ if args.mode == "gear":
392
+ result = stage_gear(reader, writer)
393
+ elif args.mode == "activity":
394
+ result = stage_activity(reader, writer, args)
395
+ else:
396
+ result = stage_segment_history(reader, writer, args)
397
+ result_path_write(args.result, result)
398
+ except Exception as error:
399
+ result_path_write(args.result, {"status": "partial" if "rate limited" in str(error).lower() else "error", "message": str(error)})
400
+ raise SystemExit(str(error))
401
+
402
+
403
+ if __name__ == "__main__":
404
+ main()
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ from dataclasses import dataclass
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+
12
+ def utc_now() -> str:
13
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
14
+
15
+
16
+ def safe_segment(value: str) -> str:
17
+ return "".join(character if character.isalnum() or character in "._-" else "_" for character in value)[:160] or "unknown"
18
+
19
+
20
+ @dataclass
21
+ class StravaStagingWriter:
22
+ """Archives the exact HTTP payload before emitting its normalizable envelope."""
23
+
24
+ data_dir: Path
25
+ output: Path
26
+
27
+ def __post_init__(self) -> None:
28
+ self.output.parent.mkdir(parents=True, exist_ok=True)
29
+ self.output.touch(exist_ok=True)
30
+
31
+ def emit(self, record: dict[str, Any]) -> None:
32
+ record["schemaVersion"] = 1
33
+ record["provider"] = "strava"
34
+ with self.output.open("a", encoding="utf-8") as stream:
35
+ stream.write(json.dumps(record, separators=(",", ":"), default=str))
36
+ stream.write("\n")
37
+
38
+ def manifest(self, run_id: str, from_date: str) -> None:
39
+ self.emit({"kind": "run_manifest", "runId": run_id, "fromDate": from_date, "createdAt": utc_now()})
40
+
41
+ def archive_json(self, endpoint: str, remote_id: str | None, payload: Any, scope: dict[str, Any] | None = None) -> str:
42
+ contents = (json.dumps(payload, indent=2, sort_keys=True, default=str) + "\n").encode("utf-8")
43
+ digest = hashlib.sha256(contents).hexdigest()
44
+ relative = Path("raw") / "strava" / safe_segment(endpoint) / safe_segment(remote_id or "collection") / f"{digest}.json"
45
+ destination = self.data_dir / relative
46
+ destination.parent.mkdir(parents=True, exist_ok=True)
47
+ if not destination.exists():
48
+ temporary = destination.with_suffix(".json.tmp")
49
+ temporary.write_bytes(contents)
50
+ os.replace(temporary, destination)
51
+ self.emit({
52
+ "kind": "raw_object", "endpoint": endpoint, "remoteId": remote_id,
53
+ "fetchedAt": utc_now(), "contentHash": digest, "contentType": "application/json",
54
+ "relativePath": str(relative), "scope": scope or {},
55
+ })
56
+ return digest
57
+
58
+ def source_entity(
59
+ self, entity_type: str, remote_id: str, payload: dict[str, Any], raw_hash: str | None,
60
+ parent_remote_id: str | None = None, occurred_on: str | None = None,
61
+ ) -> None:
62
+ self.emit({
63
+ "kind": "source_entity", "entityType": entity_type, "remoteId": str(remote_id),
64
+ "parentRemoteId": parent_remote_id, "occurredOn": occurred_on,
65
+ "sourceUpdatedAt": payload.get("updated_at") if isinstance(payload.get("updated_at"), str) else None,
66
+ "rawObjectHash": raw_hash, "payload": payload, "extension": {},
67
+ })
68
+
69
+ def error(self, endpoint: str, message: str, remote_id: str | None = None, retryable: bool = True) -> None:
70
+ self.emit({
71
+ "kind": "extraction_error", "endpoint": endpoint, "remoteId": remote_id,
72
+ "message": message, "retryable": retryable,
73
+ })