garsync 0.1.2__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.
garsync/db/schema.py ADDED
@@ -0,0 +1,88 @@
1
+ """DDL definitions and schema migration for garsync SQLite database."""
2
+
3
+ import sqlite3
4
+
5
+ CURRENT_VERSION = 1
6
+
7
+ _SCHEMA_V1 = """
8
+ CREATE TABLE IF NOT EXISTS activities (
9
+ activity_id INTEGER PRIMARY KEY,
10
+ activity_name TEXT,
11
+ activity_type TEXT,
12
+ start_time TEXT,
13
+ duration_seconds REAL,
14
+ distance_meters REAL,
15
+ average_heart_rate INTEGER,
16
+ max_heart_rate INTEGER,
17
+ calories REAL,
18
+ raw_data TEXT,
19
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
20
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
21
+ );
22
+
23
+ CREATE TABLE IF NOT EXISTS biometrics (
24
+ date TEXT PRIMARY KEY,
25
+ resting_heart_rate INTEGER,
26
+ hrv_balance TEXT,
27
+ body_battery_highest INTEGER,
28
+ body_battery_lowest INTEGER,
29
+ stress_average INTEGER,
30
+ raw_data TEXT,
31
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
32
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
33
+ );
34
+
35
+ CREATE TABLE IF NOT EXISTS sleep (
36
+ date TEXT PRIMARY KEY,
37
+ sleep_start TEXT,
38
+ sleep_end TEXT,
39
+ total_sleep_seconds INTEGER,
40
+ deep_sleep_seconds INTEGER,
41
+ light_sleep_seconds INTEGER,
42
+ rem_sleep_seconds INTEGER,
43
+ awake_sleep_seconds INTEGER,
44
+ sleep_score INTEGER,
45
+ raw_data TEXT,
46
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
47
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
48
+ );
49
+
50
+ CREATE TABLE IF NOT EXISTS sync_log (
51
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
52
+ sync_type TEXT NOT NULL,
53
+ records_synced INTEGER NOT NULL DEFAULT 0,
54
+ status TEXT NOT NULL DEFAULT 'success',
55
+ error_message TEXT,
56
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
57
+ );
58
+
59
+ CREATE TABLE IF NOT EXISTS schema_version (
60
+ version INTEGER PRIMARY KEY
61
+ );
62
+ """
63
+
64
+
65
+ def _get_version(conn: sqlite3.Connection) -> int | None:
66
+ """Get current schema version, or None if table doesn't exist."""
67
+ try:
68
+ cursor = conn.execute("SELECT version FROM schema_version")
69
+ row = cursor.fetchone()
70
+ return row["version"] if row else None
71
+ except sqlite3.OperationalError:
72
+ return None
73
+
74
+
75
+ def init_db(conn: sqlite3.Connection) -> None:
76
+ """Initialize database schema. Idempotent — safe to call repeatedly."""
77
+ current = _get_version(conn)
78
+ if current == CURRENT_VERSION:
79
+ return
80
+
81
+ conn.executescript(_SCHEMA_V1)
82
+
83
+ if current is None:
84
+ conn.execute(
85
+ "INSERT OR REPLACE INTO schema_version (version) VALUES (?)",
86
+ (CURRENT_VERSION,),
87
+ )
88
+ conn.commit()
garsync/exporter.py ADDED
@@ -0,0 +1,20 @@
1
+ import logging
2
+ from typing import TextIO
3
+
4
+ from garsync.models import SyncResult
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ class JSONExporter:
10
+ @staticmethod
11
+ def export(result: SyncResult, output: TextIO) -> None:
12
+ """Export the sync result to the provided file object or stdout."""
13
+ try:
14
+ # Output using pydantic's JSON generation
15
+ output.write(result.model_dump_json(indent=2))
16
+ output.write("\n")
17
+ logger.info("Successfully exported sync result.")
18
+ except Exception as e:
19
+ logger.error(f"Failed to export data: {e}")
20
+ raise
garsync/models.py ADDED
@@ -0,0 +1,47 @@
1
+ from datetime import date, datetime
2
+ from typing import Any, Optional
3
+
4
+ from pydantic import BaseModel, Field
5
+
6
+
7
+ class NormalizedActivity(BaseModel):
8
+ activity_id: int
9
+ activity_name: Optional[str] = None
10
+ activity_type: Optional[str] = None
11
+ start_time: Optional[datetime] = None
12
+ duration_seconds: float
13
+ distance_meters: Optional[float] = None
14
+ average_heart_rate: Optional[int] = None
15
+ max_heart_rate: Optional[int] = None
16
+ calories: Optional[float] = None
17
+ raw_data: dict[str, Any] = Field(default_factory=dict, exclude=True)
18
+
19
+
20
+ class DailyBiometrics(BaseModel):
21
+ date: date
22
+ resting_heart_rate: Optional[int] = None
23
+ hrv_balance: Optional[str] = None
24
+ body_battery_highest: Optional[int] = None
25
+ body_battery_lowest: Optional[int] = None
26
+ stress_average: Optional[int] = None
27
+ raw_data: dict[str, Any] = Field(default_factory=dict, exclude=True)
28
+
29
+
30
+ class SleepData(BaseModel):
31
+ date: date
32
+ sleep_start: Optional[datetime] = None
33
+ sleep_end: Optional[datetime] = None
34
+ total_sleep_seconds: Optional[int] = None
35
+ deep_sleep_seconds: Optional[int] = None
36
+ light_sleep_seconds: Optional[int] = None
37
+ rem_sleep_seconds: Optional[int] = None
38
+ awake_sleep_seconds: Optional[int] = None
39
+ sleep_score: Optional[int] = None
40
+ raw_data: dict[str, Any] = Field(default_factory=dict, exclude=True)
41
+
42
+
43
+ class SyncResult(BaseModel):
44
+ timestamp: datetime = Field(default_factory=datetime.utcnow)
45
+ activities: list[NormalizedActivity] = Field(default_factory=list)
46
+ biometrics: list[DailyBiometrics] = Field(default_factory=list)
47
+ sleep: list[SleepData] = Field(default_factory=list)
garsync/pipeline.py ADDED
@@ -0,0 +1,128 @@
1
+ """Core synchronization pipeline logic."""
2
+
3
+ import json
4
+ import logging
5
+ import sqlite3
6
+ from datetime import date
7
+ from typing import Any, Optional
8
+
9
+ from garsync.client import GarminClient
10
+ from garsync.db import (
11
+ ActivityRepository,
12
+ BiometricsRepository,
13
+ SleepRepository,
14
+ SyncLogRepository,
15
+ )
16
+ from garsync.models import DailyBiometrics, NormalizedActivity, SleepData
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def activity_to_row(activity: NormalizedActivity) -> dict[str, Any]:
22
+ """Convert activity model to DB row dict."""
23
+ return {
24
+ "activity_id": activity.activity_id,
25
+ "activity_name": activity.activity_name,
26
+ "activity_type": activity.activity_type,
27
+ "start_time": activity.start_time.isoformat() if activity.start_time else None,
28
+ "duration_seconds": activity.duration_seconds,
29
+ "distance_meters": activity.distance_meters,
30
+ "average_heart_rate": activity.average_heart_rate,
31
+ "max_heart_rate": activity.max_heart_rate,
32
+ "calories": activity.calories,
33
+ "raw_data": json.dumps(activity.raw_data),
34
+ }
35
+
36
+
37
+ def biometrics_to_row(bio: DailyBiometrics) -> dict[str, Any]:
38
+ """Convert biometrics model to DB row dict."""
39
+ return {
40
+ "date": bio.date.isoformat(),
41
+ "resting_heart_rate": bio.resting_heart_rate,
42
+ "hrv_balance": bio.hrv_balance,
43
+ "body_battery_highest": bio.body_battery_highest,
44
+ "body_battery_lowest": bio.body_battery_lowest,
45
+ "stress_average": bio.stress_average,
46
+ "raw_data": json.dumps(bio.raw_data),
47
+ }
48
+
49
+
50
+ def sleep_to_row(sleep: SleepData) -> dict[str, Any]:
51
+ """Convert sleep model to DB row dict."""
52
+ return {
53
+ "date": sleep.date.isoformat(),
54
+ "sleep_start": sleep.sleep_start.isoformat() if sleep.sleep_start else None,
55
+ "sleep_end": sleep.sleep_end.isoformat() if sleep.sleep_end else None,
56
+ "total_sleep_seconds": sleep.total_sleep_seconds,
57
+ "deep_sleep_seconds": sleep.deep_sleep_seconds,
58
+ "light_sleep_seconds": sleep.light_sleep_seconds,
59
+ "rem_sleep_seconds": sleep.rem_sleep_seconds,
60
+ "awake_sleep_seconds": sleep.awake_sleep_seconds,
61
+ "sleep_score": sleep.sleep_score,
62
+ "raw_data": json.dumps(sleep.raw_data),
63
+ }
64
+
65
+
66
+ class SyncService:
67
+ """Orchestrates the synchronization process between Garmin and SQLite."""
68
+
69
+ def __init__(self, client: GarminClient, db_conn: sqlite3.Connection):
70
+ self.client = client
71
+ self.db_conn = db_conn
72
+ self.activity_repo = ActivityRepository(db_conn)
73
+ self.biometrics_repo = BiometricsRepository(db_conn)
74
+ self.sleep_repo = SleepRepository(db_conn)
75
+ self.sync_log_repo = SyncLogRepository(db_conn)
76
+
77
+ def sync_range(self, dates: list[date], activities_limit: int = 100) -> dict[str, int]:
78
+ """Run a full sync for the given range of dates."""
79
+ results = {
80
+ "activities": 0,
81
+ "biometrics": 0,
82
+ "sleep": 0,
83
+ "errors": 0,
84
+ }
85
+
86
+ # 1. Activities (usually fetched in bulk, not per day)
87
+ try:
88
+ activities = self.client.fetch_activities(limit=activities_limit)
89
+ for activity in activities:
90
+ self.activity_repo.upsert(activity_to_row(activity))
91
+ results["activities"] = len(activities)
92
+ self.sync_log_repo.log("activities", len(activities), "success")
93
+ except Exception as e:
94
+ logger.error(f"Failed to sync activities: {e}")
95
+ self.sync_log_repo.log("activities", 0, "error", str(e))
96
+ results["errors"] += 1
97
+
98
+ # 2. Daily metrics (Biometrics and Sleep)
99
+ for d in dates:
100
+ date_str = d.isoformat()
101
+
102
+ # Biometrics
103
+ try:
104
+ bio = self.client.fetch_biometrics(d)
105
+ self.biometrics_repo.upsert(biometrics_to_row(bio))
106
+ results["biometrics"] += 1
107
+ self.sync_log_repo.log("biometrics", 1, "success")
108
+ except Exception as e:
109
+ logger.warning(f"Failed to sync biometrics for {date_str}: {e}")
110
+ self.sync_log_repo.log("biometrics", 0, "error", f"{date_str}: {e}")
111
+ results["errors"] += 1
112
+
113
+ # Sleep
114
+ try:
115
+ sleep = self.client.fetch_sleep(d)
116
+ self.sleep_repo.upsert(sleep_to_row(sleep))
117
+ results["sleep"] += 1
118
+ self.sync_log_repo.log("sleep", 1, "success")
119
+ except Exception as e:
120
+ logger.warning(f"Failed to sync sleep for {date_str}: {e}")
121
+ self.sync_log_repo.log("sleep", 0, "error", f"{date_str}: {e}")
122
+ results["errors"] += 1
123
+
124
+ return results
125
+
126
+ def get_latest_synced_date(self) -> Optional[str]:
127
+ """Get the most recent date present in the biometrics table."""
128
+ return self.biometrics_repo.get_latest_date()
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.4
2
+ Name: garsync
3
+ Version: 0.1.2
4
+ Summary:
5
+ Author: Manu
6
+ Author-email: mlorentedev@gmail.com
7
+ Requires-Python: >=3.12
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.12
10
+ Classifier: Programming Language :: Python :: 3.13
11
+ Classifier: Programming Language :: Python :: 3.14
12
+ Requires-Dist: fastapi (>=0.115.0,<1.0.0)
13
+ Requires-Dist: garminconnect (>=0.2.38,<0.3.0)
14
+ Requires-Dist: pandas (<3)
15
+ Requires-Dist: plotly (>=6.5.2,<7.0.0)
16
+ Requires-Dist: pydantic (>=2.12.5,<3.0.0)
17
+ Requires-Dist: rich (>=14.3.3,<15.0.0)
18
+ Requires-Dist: streamlit (>=1.54.0,<2.0.0)
19
+ Requires-Dist: tenacity (>=9.1.4,<10.0.0)
20
+ Requires-Dist: typer (>=0.24.1,<0.25.0)
21
+ Requires-Dist: uvicorn[standard] (>=0.34.0,<1.0.0)
22
+ Description-Content-Type: text/markdown
23
+
24
+ # GarSync
25
+
26
+ [![CI](https://github.com/mlorentedev/garsync/actions/workflows/ci.yml/badge.svg)](https://github.com/mlorentedev/garsync/actions)
27
+ [![Docs](https://img.shields.io/badge/docs-live-brightgreen.svg)](https://mlorentedev.github.io/garsync/)
28
+ [![Python](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/release/python-3120/)
29
+ [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
30
+
31
+ Personal fitness data pipeline that syncs Garmin Connect data to a local SQLite database and visualizes it through a modern Astro dashboard.
32
+
33
+ > **Explore the full documentation at: [mlorentedev.github.io/garsync](https://mlorentedev.github.io/garsync/)**
34
+
35
+ ## The Problem
36
+
37
+ Garmin Connect provides great data, but it's locked in a proprietary cloud.
38
+ - **Data Silos:** Hard to export full history for custom analysis.
39
+ - **Limited Visualization:** You are stuck with the official app's charts.
40
+ - **No Local Ownership:** If you lose access or the service is down, your training history is gone.
41
+
42
+ ## Value Proposition
43
+
44
+ | Feature | Garmin Connect App | GarSync |
45
+ |---|---|---|
46
+ | **Data Ownership** | Proprietary Cloud | Local SQLite (Full Control) |
47
+ | **Customization** | Fixed Dashboards | Extensible Astro + Chart.js |
48
+ | **Access** | Web/Mobile Only | REST API + SQL + CLI |
49
+ | **Automation** | Manual Export | Scheduled Incremental Sync |
50
+
51
+ ## Quick Start
52
+
53
+ ### 1. Prerequisites
54
+ - Python 3.12+ and [Poetry](https://python-poetry.org/)
55
+ - Node.js 22+ (for the dashboard)
56
+ - [SOPS](https://github.com/getsops/sops) + [Age](https://github.com/FiloSottile/age) (for secrets)
57
+
58
+ ### 2. Setup
59
+ ```bash
60
+ make setup
61
+ ```
62
+
63
+ ### 3. Configure Secrets
64
+ Edit your Garmin credentials using SOPS:
65
+ ```bash
66
+ sops secrets.env.enc
67
+ ```
68
+
69
+ ### 4. Sync & Launch
70
+ ```bash
71
+ make sync DAYS=30
72
+ make dev
73
+ ```
74
+ Visit `http://localhost:4321` (Dev UI).
75
+
76
+ ## Architecture
77
+
78
+ ```
79
+ Garmin Connect Cloud → GarSync CLI → SQLite DB → FastAPI → Astro Dashboard
80
+ ```
81
+
82
+ ## License
83
+
84
+ MIT — see [LICENSE](LICENSE).
85
+
@@ -0,0 +1,25 @@
1
+ garsync/__init__.py,sha256=N4JzTlVpYSC8_19wA5Wvy2EiaFN3RFVazgKXt2kNTQk,54
2
+ garsync/__main__.py,sha256=c7_Wqc0333GCquhDzrJlasL1w276YskcIrpN_FxnL48,66
3
+ garsync/api/__init__.py,sha256=g1bknUtmuhDLueNqTbAVMFRmqnsQ1oKS7d0zTBfuJlA,114
4
+ garsync/api/deps.py,sha256=8TIYd1uZLI1ZmupPzZQ4DgFnM7Vv5oJu_6k2Afx1gwI,1027
5
+ garsync/api/main.py,sha256=vc6bVGb9hDwcl-nV0fO8zSPHMqQ090VuejlN8fCbzyc,2600
6
+ garsync/api/routes/__init__.py,sha256=Y-_8J4QUygHeUp-gG7-xTsU9MQqX7sxdavkgXD2gYtk,41
7
+ garsync/api/routes/activities.py,sha256=dMuyDs9F540o7mmmVUiQXsTe-RshSG-fK8Z6EK0q-Rs,1206
8
+ garsync/api/routes/biometrics.py,sha256=fnVhrdIC4VEP8wnpm9eyTi8y5sTtw_RJtCFYlZXdp0c,845
9
+ garsync/api/routes/sleep.py,sha256=NBGDbM58zFK1ThAtknX4Z5grwfyiUM1cvHvgNttjkJ0,791
10
+ garsync/api/routes/stats.py,sha256=36ztYdPV_wdtxQeIaIjtNHmsN4AH4_EPiS_WzInRJi4,4398
11
+ garsync/api/routes/sync.py,sha256=a2X-LjtgJAxg3K_oeJd4YsCzEhPHeSLahDSu1psZ9I0,1212
12
+ garsync/api/schemas.py,sha256=i8gUUu0tPLuKQtjGbGPK1cx_I9HcEPtW7RX1rlH62VI,2884
13
+ garsync/cli.py,sha256=OYSkns9LaUEsTN6pW83r_BYjdu2AVPhc8Rl6U71nH3E,4021
14
+ garsync/client.py,sha256=0HUOOB_2mUdVD6CJIW3CYLUSym7ABVzz3PxqIm9-bfo,7518
15
+ garsync/db/__init__.py,sha256=El1UnEtMRWJspbTAAJ-ryk1eh2ATgHG_mKex42Hc850,479
16
+ garsync/db/connection.py,sha256=vLvu2WPIRnE9KtcpyE8EjK_rUTVjDAv0y2w7u0dYKIA,549
17
+ garsync/db/repository.py,sha256=4pprxqNUMYYmM1iPKCR31CdtZJe81ZfCwtC8yZBiDQc,15973
18
+ garsync/db/schema.py,sha256=jAFRTbKOhzQsJKRKw55Bqh1a7DhaOmzYzVqpPqi3BTc,2755
19
+ garsync/exporter.py,sha256=hSa7jpmWV5LW3MDy6GP4xGDp6K1wiZpWy1LMVcvQBOU,618
20
+ garsync/models.py,sha256=eAoNDHvrujg5v2Qxo_1CUhcaaVD3O_ihpHltk7b_jzs,1658
21
+ garsync/pipeline.py,sha256=Fl5Q7o6WfKDdlvObXWGGqeQvFkVieJ08I3TFBsjqHOY,4919
22
+ garsync-0.1.2.dist-info/METADATA,sha256=eBhAcJ6Ujed6wjITYrdyHRC1tRKyxEyK4sg9G7QwL5g,2785
23
+ garsync-0.1.2.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
24
+ garsync-0.1.2.dist-info/entry_points.txt,sha256=42438UHL-jkL4CYPs09g_kZ90MYjQou1XAQ0yxasjIE,43
25
+ garsync-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.3.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ garsync=garsync.cli:app
3
+