diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md deleted file mode 100644 index 32dc6d4..0000000 --- a/DEVELOPMENT.md +++ /dev/null @@ -1,184 +0,0 @@ -# ๐Ÿ”ง Garmy Development Guide - -Complete guide for local development and testing of the Garmy library. - -## ๐Ÿ“ฆ Quick Start - -### Initial Setup -```bash -# Clone and navigate to directory -git clone -cd garmy - -# Install in development mode -make install-dev -``` - -## ๐Ÿš€ Main Commands - -### Help -```bash -make help # Show all available commands -``` - -### Development Environment Setup -```bash -make install-dev # Install development dependencies -``` - -### Code Formatting and Linting -```bash -make format # Format code (black + isort) -make check # Check formatting without changes -make lint # Run all linters -make lint-ruff # Fast linting with ruff -make lint-mypy # Type checking with mypy -make lint-bandit # Security scanning with bandit -``` - -### Testing -```bash -make test # Run all tests with coverage -make test-core # Core module tests -make test-auth # Authentication tests -make test-metrics # Metrics tests -make test-mcp # MCP server tests -``` - -### CI/CD Pipelines -```bash -make quick-check # Quick check (format + ruff + mypy) -make ci # Full pipeline (format + lint + tests) -``` - -### Build and Cleanup -```bash -make build # Build package for distribution -make clean # Clean temporary files and cache -``` - -## ๐Ÿ’ก Recommended Workflows - -### Daily Development -```bash -# 1. Quick check during development -make quick-check - -# 2. Test specific module -make test-core # or other needed module -``` - -### Before Commit -```bash -# Full pipeline before commit -make ci -``` - -### Fixing Code Issues -```bash -# 1. Automatic formatting -make format - -# 2. Automatic ruff fixes -ruff check src/ tests/ examples/ --fix - -# 3. Check results -make quick-check -``` - -## ๐Ÿ“Š Test Coverage Analysis - -After running `make test`, a coverage report is created: - -- **Terminal**: Shows coverage percentage by file -- **HTML Report**: `htmlcov/index.html` (detailed view) - -## ๐Ÿ” Debugging and Diagnostics - -### Logging -```bash -# Enable debug mode for MCP -export GARMY_MCP_DEBUG=true - -# View server logs -garmy-mcp serve --debug -``` - -### Environment Variables -```bash -# Credentials for testing -export GARMIN_EMAIL="your-email@example.com" -export GARMIN_PASSWORD="your-password" - -# MCP configuration -export GARMY_MCP_DEBUG=true -export GARMY_MCP_CACHE_ENABLED=true -export GARMY_MCP_CACHE_SIZE=100 -``` - -## โšก Quick Commands - -| Purpose | Command | -|---------|---------| -| First run | `make install-dev` | -| Quick check | `make quick-check` | -| Before commit | `make ci` | -| Fix formatting | `make format` | -| Single module tests | `make test-mcp` | -| Clean all | `make clean` | - -## ๐Ÿ—๏ธ Project Architecture - -``` -garmy/ -โ”œโ”€โ”€ src/garmy/ # Main code -โ”‚ โ”œโ”€โ”€ auth/ # Authentication -โ”‚ โ”œโ”€โ”€ core/ # Library core -โ”‚ โ”œโ”€โ”€ mcp/ # MCP server -โ”‚ โ””โ”€โ”€ metrics/ # Health metrics -โ”œโ”€โ”€ tests/ # Tests -โ”œโ”€โ”€ examples/ # Usage examples -โ””โ”€โ”€ Makefile # Development commands -``` - -## ๐Ÿ› Troubleshooting - -### Dependency Issues -```bash -# Reinstall dependencies -pip uninstall garmy -make install-dev -``` - -### Type Checking Issues -```bash -# Install types -pip install types-requests - -# Check types -make lint-mypy -``` - -### Formatting Issues -```bash -# Force formatting -make format -make check -``` - -## ๐Ÿ“ Contributing - -1. Create branch: `git checkout -b feature/new-feature` -2. Develop changes -3. Run: `make ci` -4. Create pull request - -## ๐Ÿ”— Useful Links - -- [README.md](README.md) - Main documentation -- [CHANGELOG.md](CHANGELOG.md) - Change history -- [examples/](examples/) - Usage examples - ---- - -๐Ÿ’ก **Tip**: Use `make help` to view all available commands with descriptions. \ No newline at end of file diff --git a/Makefile b/Makefile index cd1fc8f..9f120e7 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help lint format check test test-mcp test-core test-auth test-metrics clean install-dev build ci +.PHONY: help lint format check test test-core test-auth test-metrics clean install-dev build ci # Default target help: @@ -22,7 +22,7 @@ help: @echo " test-core - Run core module tests" @echo " test-auth - Run authentication tests" @echo " test-metrics - Run metrics tests" - @echo " test-mcp - Run MCP server tests" + @echo "" @echo "" @echo "๐Ÿš€ CI/CD:" @echo " ci - Run full CI pipeline (format, lint, test)" @@ -106,11 +106,6 @@ test-metrics: pytest tests/test_metrics_*.py -v @echo "โœ… Metrics tests complete!" -# Run MCP server tests -test-mcp: - @echo "๐Ÿงช Running MCP server tests..." - pytest tests/test_mcp_*.py -v - @echo "โœ… MCP tests complete!" # Clean build artifacts clean: diff --git a/README.md b/README.md index c661d9a..1f360d6 100644 --- a/README.md +++ b/README.md @@ -5,18 +5,19 @@ [![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) [![Tests](https://github.com/bes-dev/garmy/workflows/Tests/badge.svg)](https://github.com/bes-dev/garmy/actions) -An AI-powered Python library for Garmin Connect API designed specifically for health data analysis and AI agent integration via Model Context Protocol (MCP). Build intelligent health assistants and data analysis tools with seamless access to Garmin's comprehensive fitness metrics. +An AI-powered Python library for Garmin Connect API designed specifically for health data analysis and AI agent integration. Build intelligent health assistants and data analysis tools with seamless access to Garmin's comprehensive fitness metrics. **Inspired by [garth](https://github.com/matin/garth)** - This project was heavily inspired by the excellent garth library, building upon its foundation with enhanced modularity, type safety, and AI integration capabilities. ## ๐ŸŽฏ Key Features - **๐Ÿค– AI-First Design**: Built specifically for AI health agents and intelligent assistants -- **๐Ÿ”Œ MCP Integration**: Native Model Context Protocol support for seamless AI interactions - **๐Ÿฅ Health Analytics**: Advanced data analysis capabilities for fitness and wellness insights - **๐Ÿ“Š Rich Metrics**: Complete access to sleep, heart rate, stress, training readiness, and more -- **๐Ÿ—ฃ๏ธ Natural Language**: Query health data using conversational commands -- **โšก Real-time Processing**: Async/await support for high-performance AI applications +- **๐Ÿ’พ Local Database**: Built-in SQLite database for local health data storage and sync +- **๐Ÿ–ฅ๏ธ CLI Tools**: Command-line interfaces for data synchronization and MCP server management +- **๐Ÿค– MCP Server**: Model Context Protocol server for AI assistant integration (Claude Desktop) +- **โšก High Performance**: Optimized for high-performance AI applications - **๐Ÿ›ก๏ธ Type Safe**: Full type hints and runtime validation for reliable AI workflows - **๐Ÿ”„ Auto-Discovery**: Automatic metric registration and API endpoint discovery @@ -27,55 +28,28 @@ An AI-powered Python library for Garmin Connect API designed specifically for he pip install garmy ``` -### With MCP Support (Recommended for AI Agents) +### With Optional Features ```bash +# For local database functionality +pip install garmy[localdb] + +# For MCP server functionality (AI assistants) pip install garmy[mcp] + +# For everything +pip install garmy[all] ``` ### Development Installation ```bash git clone https://github.com/bes-dev/garmy.git cd garmy -pip install -e ".[dev,mcp]" +pip install -e ".[dev]" ``` ## ๐Ÿš€ Quick Start -### AI Agent Example (Recommended) - -```python -from garmy import AuthClient, APIClient -import asyncio - -# Create an AI health agent -async def health_agent(): - auth_client = AuthClient() - api_client = APIClient(auth_client=auth_client) - - # Login using environment variables (secure for AI agents) - await auth_client.login_async( - email=os.getenv('GARMIN_EMAIL'), - password=os.getenv('GARMIN_PASSWORD') - ) - - # AI agent can now analyze multiple health metrics concurrently - sleep_task = api_client.metrics.get('sleep').get_async() - readiness_task = api_client.metrics.get('training_readiness').get_async() - hrv_task = api_client.metrics.get('hrv').get_async() - - sleep_data, readiness_data, hrv_data = await asyncio.gather( - sleep_task, readiness_task, hrv_task - ) - - # AI analysis logic here - health_score = analyze_health_trends(sleep_data, readiness_data, hrv_data) - return health_score - -# Run AI health agent -health_insights = asyncio.run(health_agent()) -``` - -### Basic Usage +### Basic API Usage ```python from garmy import AuthClient, APIClient @@ -94,439 +68,173 @@ print(f"Training Readiness Score: {readiness[0].score}/100") # Get sleep data for specific date sleep_data = api_client.metrics.get('sleep').get('2023-12-01') print(f"Sleep Score: {sleep_data[0].overall_sleep_score}") - -# Get multiple days of data -weekly_steps = api_client.metrics['steps'].list(days=7) ``` -### Async Usage - -```python -import asyncio -from garmy import AuthClient, APIClient - -async def main(): - auth_client = AuthClient() - api_client = APIClient(auth_client=auth_client) - - # Login - await auth_client.login_async("your_email@garmin.com", "your_password") - - # Get multiple metrics concurrently - sleep_task = api_client.metrics.get('sleep').get_async() - hr_task = api_client.metrics.get('heart_rate').get_async() - - sleep_data, hr_data = await asyncio.gather(sleep_task, hr_task) - -asyncio.run(main()) -``` - -## ๐Ÿ“Š Available Metrics - -Garmy provides access to a comprehensive set of Garmin Connect metrics: - -| Metric | Description | Example Usage | -|--------|-------------|---------------| -| `sleep` | Sleep tracking data including stages and scores | `api_client.metrics.get('sleep').get()` | -| `heart_rate` | Daily heart rate statistics | `api_client.metrics.get('heart_rate').get()` | -| `stress` | Stress level measurements | `api_client.metrics.get('stress').get()` | -| `steps` | Daily step counts and goals | `api_client.metrics.get('steps').list(days=7)` | -| `training_readiness` | Training readiness scores and factors | `api_client.metrics.get('training_readiness').get()` | -| `body_battery` | Body battery energy levels | `api_client.metrics.get('body_battery').get()` | -| `hrv` | Heart rate variability data | `api_client.metrics.get('hrv').get()` | -| `respiration` | Respiration rate measurements | `api_client.metrics.get('respiration').get()` | -| `calories` | Daily calorie burn data | `api_client.metrics.get('calories').get()` | -| `activities` | Activity summaries and details | `api_client.metrics.get('activities').list(days=30)` | -| `daily_summary` | Comprehensive daily health summary | `api_client.metrics.get('daily_summary').get()` | - -## ๐Ÿค– AI Health Agent Integration (MCP) - -Garmy is specifically designed for building **AI health agents** and intelligent assistants through native **Model Context Protocol (MCP)** integration. Transform your Garmin health data into actionable insights using natural language interactions. - -### What Makes Garmy AI-First? - -Garmy isn't just an API wrapper โ€“ it's a complete AI health agent platform that enables: - -- **๐Ÿง  Intelligent Health Analysis**: AI-powered insights into sleep patterns, training readiness, and recovery -- **๐Ÿ—ฃ๏ธ Natural Language Queries**: Ask questions like "How was my sleep quality this week?" or "Am I ready for training today?" -- **๐Ÿ“Š Predictive Analytics**: Build AI models that predict optimal training times, recovery needs, and health trends -- **๐Ÿ”„ Real-time Monitoring**: Create AI agents that continuously monitor health metrics and provide recommendations -- **๐ŸŽจ Custom Health Dashboards**: Generate AI-driven visualizations and reports tailored to individual health goals -- **๐Ÿ“ฑ Multi-modal Integration**: Combine Garmin data with other health sources for comprehensive AI analysis - -### MCP Installation +### Local Database & CLI Tools ```bash -# Install Garmy with MCP support -pip install garmy[mcp] - -# Verify installation -garmy-mcp --help -``` - -### MCP Server Setup - -#### Option 1: Command Line Interface - -```bash -# Start MCP server for Claude Desktop (STDIO transport) -garmy-mcp serve --transport stdio - -# Start HTTP server for web clients -garmy-mcp serve --transport http --port 8080 - -# Show server information -garmy-mcp info +# Sync recent health data to local database +garmy-sync sync --last-days 7 -# List available metrics -garmy-mcp metrics +# Check sync status +garmy-sync status -# Test server configuration -garmy-mcp test -``` - -#### Option 2: Programmatic Usage - -```python -from garmy.mcp import GarmyMCPServer, MCPConfig +# Start MCP server for AI assistants +garmy-mcp server --database health.db -# Create and configure server -config = MCPConfig.for_production() -server = GarmyMCPServer(config) +# Show database info +garmy-mcp info --database health.db -# Run server -server.run(transport="stdio") # For Claude Desktop -server.run(transport="http", port=8080) # For HTTP clients +# Get configuration examples +garmy-mcp config ``` -### Claude Desktop Configuration - -#### Secure Setup (Recommended) +### AI Assistant Integration (Claude Desktop) -Add this to your Claude Desktop MCP configuration file: +Add to your Claude Desktop configuration (`~/.claude_desktop_config.json`): ```json { "mcpServers": { - "garmy": { - "command": "/path/to/your/venv/bin/python", - "args": ["-m", "garmy.mcp", "serve", "--transport", "stdio"], - "env": { - "GARMIN_EMAIL": "your_email@example.com", - "GARMIN_PASSWORD": "your_password", - "GARMY_MCP_DEBUG": "false", - "GARMY_MCP_CACHE_ENABLED": "true" - } + "garmy-localdb": { + "command": "garmy-mcp", + "args": ["server", "--database", "/path/to/health.db", "--max-rows", "500"] } } } ``` -#### Configuration File Locations +Now ask Claude: *"What health data do I have available? Analyze my sleep patterns over the last month."* + +## ๐Ÿ“Š Available Health Metrics -- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` -- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` -- **Linux**: `~/.config/Claude/claude_desktop_config.json` +Garmy provides access to a comprehensive set of Garmin Connect metrics: + +| Metric | Description | Example Usage | +|--------|-------------|---------------| +| `sleep` | Sleep tracking data including stages and scores | `api_client.metrics.get('sleep').get()` | +| `heart_rate` | Daily heart rate statistics | `api_client.metrics.get('heart_rate').get()` | +| `stress` | Stress level measurements | `api_client.metrics.get('stress').get()` | +| `steps` | Daily step counts and goals | `api_client.metrics.get('steps').list(days=7)` | +| `training_readiness` | Training readiness scores and factors | `api_client.metrics.get('training_readiness').get()` | +| `body_battery` | Body battery energy levels | `api_client.metrics.get('body_battery').get()` | +| `activities` | Activity summaries and details | `api_client.metrics.get('activities').list(days=30)` | -### Authentication Options +## ๐Ÿง‘โ€๐Ÿ’ป Architecture Overview -Garmy MCP supports multiple authentication methods: +Garmy consists of three main modules: -#### 1. Environment Variables (Most Secure) -```bash -export GARMIN_EMAIL="your_email@example.com" -export GARMIN_PASSWORD="your_password" -``` -- โœ… Credentials never pass through AI servers -- โœ… Most secure method -- โœ… Use with: *"Auto-login to Garmin Connect"* - -#### 2. Manual Input (Less Secure) -- โš ๏ธ Credentials may be visible to AI servers -- โš ๏ธ Use only when necessary -- โš ๏ธ Use with: *"Log into Garmin Connect with email [email] and password [password]"* - -### AI Health Agent Commands - -Once configured, your AI health agent can interact with Garmin data using natural language. Here are examples of what your AI assistant can do: - -#### Intelligent Health Monitoring -- *"Analyze my recovery patterns and tell me if I should train today"* -- *"What's my sleep efficiency trend over the past month?"* -- *"Create a personalized training plan based on my readiness scores"* -- *"Identify correlations between my stress and sleep quality"* -- *"Generate a health report with actionable insights"* - -#### Predictive Health Analytics -- *"Predict my optimal training windows for next week"* -- *"What factors are affecting my sleep quality most?"* -- *"Alert me when my recovery metrics indicate overtraining"* -- *"Build a model to predict my daily energy levels"* - -#### Conversational Health Queries -- *"How am I progressing towards my fitness goals?"* -- *"What's unusual about my health data this week?"* -- *"Compare my current training load to last month"* -- *"Should I adjust my sleep schedule based on my data?"* - -#### Smart Data Export & Visualization -- *"Create an interactive dashboard of my health metrics"* -- *"Export my data in a format suitable for machine learning"* -- *"Generate a health summary for my doctor"* -- *"Build charts showing my progress over time"* - -### MCP Configuration Options - -#### Environment Variables - -| Variable | Description | Default | -|----------|-------------|---------| -| `GARMIN_EMAIL` | Garmin Connect email | None | -| `GARMIN_PASSWORD` | Garmin Connect password | None | -| `GARMY_MCP_DEBUG` | Enable debug logging | `false` | -| `GARMY_MCP_CACHE_ENABLED` | Enable data caching | `false` | -| `GARMY_MCP_CACHE_SIZE` | Cache size limit | `100` | -| `GARMY_MCP_MAX_HISTORY_DAYS` | Max historical data | `365` | -| `GARMY_MCP_DEFAULT_ANALYSIS_PERIOD` | Default analysis period | `30` | - -#### Configuration Presets +### ๐Ÿ”Œ **Core Library** +- **Garmin Connect API**: Type-safe access to all health metrics +- **High Performance**: Optimized concurrent operations +- **Auto-Discovery**: Automatic endpoint and metric detection -```python -from garmy.mcp.config import MCPConfig +### ๐Ÿ’พ **LocalDB Module** +- **SQLite Storage**: Local database for health data persistence +- **Data Sync**: Robust synchronization with conflict resolution +- **CLI Tools**: `garmy-sync` for data management -# Development setup -config = MCPConfig.for_development() +### ๐Ÿค– **MCP Server Module** +- **AI Integration**: Model Context Protocol server for AI assistants +- **Secure Access**: Read-only database access with query validation +- **Claude Desktop**: Native integration with Claude Desktop +- **CLI Tools**: `garmy-mcp` for server management -# Production setup -config = MCPConfig.for_production() +## ๐Ÿ“š Documentation -# Minimal setup -config = MCPConfig.minimal() -``` +### ๐Ÿ“– Getting Started +- **[Quick Start Guide](docs/quick-start.md)** - Get up and running in minutes +- **[Basic Examples](examples/README.md)** - Simple usage patterns -### Troubleshooting MCP - -#### Common Issues - -1. **"Server not responding"** - ```bash - # Check if server is running - garmy-mcp test - - # Restart with debug mode - GARMY_MCP_DEBUG=true garmy-mcp serve --transport stdio - ``` - -2. **"Authentication failed"** - ```bash - # Verify credentials - echo $GARMIN_EMAIL - echo $GARMIN_PASSWORD - - # Test authentication - garmy-mcp test --auth - ``` - -3. **"No data available"** - ```bash - # Check available metrics - garmy-mcp metrics - - # Verify date range - garmy-mcp test --date 2023-12-01 - ``` - -#### Debug Mode - -Enable debug logging for troubleshooting: +### ๐Ÿ—๏ธ Core Features +- **[Available Metrics](#-available-health-metrics)** - All supported health metrics in this README -```bash -# Enable debug mode -export GARMY_MCP_DEBUG=true +### ๐Ÿ’พ Local Database +- **[LocalDB Guide](docs/localdb-guide.md)** - Complete local storage guide +- **[Database Schema](docs/database-schema.md)** - Schema and table structure -# Run server with debug output -garmy-mcp serve --transport stdio --debug -``` +### ๐Ÿค– AI Integration +- **[MCP Usage Example](docs/mcp-example.md)** - Complete walkthrough from sync to AI analysis +- **[MCP Server Guide](docs/mcp-server-guide.md)** - AI assistant integration +- **[Claude Desktop Setup](docs/claude-desktop-integration.md)** - Step-by-step Claude integration -## ๐Ÿ“Š AI Health Data Analysis +### ๐Ÿ”ฌ Advanced Usage +- **[Examples Directory](examples/)** - Comprehensive usage examples -### Building AI Health Models +## ๐ŸŽฏ Use Cases +### For AI Developers ```python +# Build AI health monitoring agents from garmy import APIClient, AuthClient -import pandas as pd -import numpy as np -from sklearn.ensemble import RandomForestRegressor -from sklearn.preprocessing import StandardScaler -# Setup AI health analysis pipeline -auth_client = AuthClient() -api_client = APIClient(auth_client=auth_client) -auth_client.login("email", "password") - -# Gather comprehensive health data for AI model -async def build_health_dataset(days=90): - # Collect multiple health metrics concurrently - tasks = [ - api_client.metrics.get('sleep').list_async(days=days), - api_client.metrics.get('training_readiness').list_async(days=days), - api_client.metrics.get('hrv').list_async(days=days), - api_client.metrics.get('stress').list_async(days=days), - api_client.metrics.get('body_battery').list_async(days=days) - ] - - sleep_data, readiness_data, hrv_data, stress_data, battery_data = await asyncio.gather(*tasks) - - # Build comprehensive health dataset - health_df = pd.DataFrame() - # ... merge and process data for AI model training - - return health_df - -# Train AI model to predict training readiness -def train_readiness_predictor(health_df): - features = ['sleep_score', 'hrv_rmssd', 'stress_avg', 'body_battery_drained'] - X = health_df[features] - y = health_df['training_readiness_score'] - - model = RandomForestRegressor(n_estimators=100, random_state=42) - model.fit(X, y) - - return model - -# AI-powered health insights -def generate_health_insights(model, current_metrics): - predicted_readiness = model.predict([current_metrics])[0] +def health_agent(): + auth_client = AuthClient() + api_client = APIClient(auth_client=auth_client) - insights = { - 'readiness_prediction': predicted_readiness, - 'recommendation': 'high_intensity' if predicted_readiness > 75 else 'recovery', - 'confidence': model.score(X_test, y_test) - } + # Login and get metrics + auth_client.login(email, password) + sleep_data = api_client.metrics.get('sleep').get() + readiness_data = api_client.metrics.get('training_readiness').get() - return insights + # AI analysis logic here + return analyze_health_trends(sleep_data, readiness_data) ``` -### AI-Powered Health Monitoring +### For Data Analysts +```bash +# Local database analysis workflow +garmy-sync sync --last-days 90 # Sync 3 months of data +garmy-mcp server --database health.db # Start MCP server +# Use Claude Desktop or Python to analyze trends, correlations, patterns +``` +### For Health Researchers ```python -# Create an AI health monitoring agent -class HealthMonitoringAgent: - def __init__(self, api_client): - self.api_client = api_client - self.health_model = self.load_trained_model() - - async def daily_health_check(self): - """Perform daily AI-powered health analysis""" - # Get today's metrics - today_data = await self.get_current_metrics() - - # AI analysis - health_score = self.health_model.predict_health_score(today_data) - recommendations = self.generate_recommendations(health_score, today_data) - alerts = self.check_health_alerts(today_data) - - return { - 'health_score': health_score, - 'recommendations': recommendations, - 'alerts': alerts, - 'insights': self.generate_insights(today_data) - } - - def generate_recommendations(self, health_score, data): - """AI-generated personalized health recommendations""" - if health_score > 80: - return "Great day for high-intensity training!" - elif health_score > 60: - return "Moderate activity recommended. Focus on technique." - else: - return "Prioritize recovery today. Light movement only." - - async def weekly_health_report(self): - """Generate comprehensive AI health report""" - week_data = await self.api_client.metrics.get('daily_summary').list_async(days=7) - - # AI trend analysis - trends = self.analyze_trends(week_data) - predictions = self.predict_next_week(week_data) - - return self.format_health_report(trends, predictions) - -# Usage -agent = HealthMonitoringAgent(api_client) -daily_insights = await agent.daily_health_check() -weekly_report = await agent.weekly_health_report() +# Large-scale health data collection +from garmy.localdb import SyncManager + +sync_manager = SyncManager(db_path="research_data.db") +sync_manager.initialize(email, password) + +# Collect comprehensive health dataset +stats = sync_manager.sync_range( + user_id=1, + start_date=date(2023, 1, 1), + end_date=date.today(), + metrics=[MetricType.SLEEP, MetricType.HRV, MetricType.STRESS] +) ``` -## ๐Ÿง‘โ€๐Ÿ’ป Development +## ๐Ÿ›ก๏ธ Security & Privacy -### Running Examples +- **๐Ÿ”’ Local Data**: All health data stored locally in SQLite +- **๐Ÿ” Read-Only MCP**: AI assistants have read-only database access +- **๐Ÿ›ก๏ธ Query Validation**: SQL injection prevention and query limits +- **๐Ÿ”‘ Secure Auth**: OAuth token management with automatic refresh +- **๐Ÿšซ No Data Sharing**: Health data never leaves your local environment + +## ๐Ÿงช Examples Check out the `examples/` directory for comprehensive usage examples: ```bash -# Basic authentication example -python examples/basic_auth.py - -# Sleep analysis demo -python examples/sleep_demo.py +# Basic authentication and metrics +python examples/basic_usage.py -# Training readiness analysis -python examples/training_readiness_demo.py +# Local database operations +python examples/localdb_demo.py -# Comprehensive metrics sync -python examples/metrics_sync_demo.py +# MCP server configuration +python examples/mcp_server_example.py -# MCP server demo -python examples/mcp_integration_demo.py +# AI health analytics +python examples/ai_health_analytics.py ``` -### Adding Custom Metrics - -Garmy's modular architecture makes it easy to add new metrics: - -```python -from dataclasses import dataclass -from garmy.core.base import BaseMetric - -@dataclass -class CustomMetric(BaseMetric): - endpoint_path = "/usersummary-service/stats/custom/{date}" - - custom_field: int - timestamp: str - - def validate(self) -> bool: - """Custom validation logic""" - return self.custom_field > 0 -``` - -### Configuration - -Customize Garmy behavior with configuration: - -```python -from garmy.core.config import set_config, GarmyConfig - -# Create custom configuration -config = GarmyConfig( - request_timeout=30, - retries=3, - max_workers=10, - default_user_agent="MyApp/1.0" -) - -# Apply configuration -set_config(config) - -# Or use environment variables -import os -os.environ['GARMY_REQUEST_TIMEOUT'] = '30' -os.environ['GARMY_MAX_WORKERS'] = '10' -``` - -### Testing +## ๐Ÿ”ง Development +### Running Tests ```bash # Install development dependencies make install-dev @@ -536,8 +244,7 @@ make test # Run specific test modules make test-core # Core functionality -make test-auth # Authentication -make test-metrics # Metrics +make test-localdb # LocalDB module make test-mcp # MCP server # Check code quality @@ -545,156 +252,43 @@ make lint make quick-check ``` -## ๐Ÿ”ง Advanced Usage - -### Async Operations - +### Adding Custom Metrics ```python -import asyncio -from garmy import APIClient, AuthClient +from dataclasses import dataclass +from garmy.core.base import BaseMetric -async def analyze_weekly_data(): - auth_client = AuthClient() - api_client = APIClient(auth_client=auth_client) - - # Async login - await auth_client.login_async("email", "password") - - # Fetch multiple metrics concurrently - tasks = [ - api_client.metrics.get('sleep').list_async(days=7), - api_client.metrics.get('steps').list_async(days=7), - api_client.metrics.get('stress').list_async(days=7) - ] +@dataclass +class CustomMetric(BaseMetric): + endpoint_path = "/usersummary-service/stats/custom/{date}" - sleep_data, steps_data, stress_data = await asyncio.gather(*tasks) + custom_field: int + timestamp: str - return { - 'sleep': sleep_data, - 'steps': steps_data, - 'stress': stress_data - } - -# Run async analysis -data = asyncio.run(analyze_weekly_data()) -``` - -### Custom Error Handling - -```python -from garmy.core.exceptions import APIError, AuthError, GarmyError - -try: - auth_client.login("wrong_email", "wrong_password") -except AuthError as e: - print(f"Authentication failed: {e}") -except APIError as e: - print(f"API error: {e}") -except GarmyError as e: - print(f"General Garmy error: {e}") -``` - -### Rate Limiting & Retries - -```python -from garmy.core.config import set_config, GarmyConfig - -# Configure retry behavior -config = GarmyConfig( - retries=5, - backoff_factor=1.0, - max_workers=5 # Limit concurrent requests -) -set_config(config) -``` - -## ๐Ÿ›ก๏ธ Security for AI Health Applications - -### AI Agent Security Best Practices - -1. **Environment Variables**: Essential for AI agents - store credentials securely outside code -2. **MCP Security**: Use environment variables in MCP configuration to prevent credential exposure to AI servers -3. **OAuth Token Management**: Garmy handles OAuth tokens securely with automatic refresh for long-running AI agents -4. **HTTPS Only**: All communications use HTTPS with certificate verification -5. **AI Data Privacy**: Health data never leaves your local environment unless explicitly exported -6. **Secure AI Pipelines**: Design AI workflows that protect sensitive health information - -### Best Practices - -```python -import os -from garmy import AuthClient - -# โœ… Good: Use environment variables -email = os.getenv('GARMIN_EMAIL') -password = os.getenv('GARMIN_PASSWORD') - -# โŒ Bad: Hardcode credentials -# email = "your_email@example.com" -# password = "your_password" - -auth_client = AuthClient() -auth_client.login(email, password) + def validate(self) -> bool: + return self.custom_field > 0 ``` -## ๐Ÿ“ API Reference - -### Core Classes - -- **`AuthClient`**: Handles authentication and session management -- **`APIClient`**: Main interface for accessing Garmin Connect data -- **`MetricAccessor`**: Provides access to specific metrics -- **`GarmyMCPServer`**: MCP server for AI assistant integration - -### Configuration Classes - -- **`GarmyConfig`**: Main configuration class -- **`MCPConfig`**: MCP server configuration -- **`ConfigManager`**: Configuration management utilities - -### Metrics Classes - -Each metric has its own dataclass with type-safe fields. Examples: -- **`SleepData`**: Sleep tracking information -- **`HeartRateData`**: Heart rate statistics -- **`StepsData`**: Step count and goals -- **`TrainingReadinessData`**: Training readiness scores - ## ๐Ÿค Contributing -We welcome contributions! Please see our [Contributing Guide](DEVELOPMENT.md) for details. +We welcome contributions! Please see our [Contributing Guide](docs/contributing.md) for details. ### Development Setup - ```bash -# Clone repository git clone https://github.com/bes-dev/garmy.git cd garmy - -# Install in development mode make install-dev - -# Run quality checks -make ci +make ci # Run quality checks ``` -### Submitting Changes - -1. Fork the repository -2. Create a feature branch: `git checkout -b feature/new-feature` -3. Make your changes -4. Run tests: `make ci` -5. Submit a pull request - ## ๐Ÿ™ Acknowledgments Garmy was heavily inspired by the excellent [garth](https://github.com/matin/garth) library by [Matin Tamizi](https://github.com/matin). We're grateful for the foundational work that made this project possible. Garmy builds upon garth's concepts with: - Enhanced modularity and extensibility - Full type safety with mypy compliance -- Model Context Protocol (MCP) integration for AI assistants -- Comprehensive async/await support - Auto-discovery system for metrics +- Local database integration +- MCP server for AI assistants - Modern Python architecture and testing practices Special thanks to the garth project and its contributors for pioneering accessible Garmin Connect API access. @@ -709,11 +303,11 @@ This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENS ## ๐Ÿ”— Links -- **Documentation**: [DEVELOPMENT.md](DEVELOPMENT.md) -- **Examples**: [examples/](examples/) -- **Issues**: [GitHub Issues](https://github.com/bes-dev/garmy/issues) -- **PyPI**: [https://pypi.org/project/garmy/](https://pypi.org/project/garmy/) +- **[Documentation](docs/)** - Complete documentation +- **[PyPI Package](https://pypi.org/project/garmy/)** - Install via pip +- **[GitHub Issues](https://github.com/bes-dev/garmy/issues)** - Bug reports and feature requests +- **[Examples](examples/)** - Usage examples and tutorials --- -*Garmy makes Garmin Connect data accessible with modern Python practices, type safety, and AI assistant integration via MCP.* \ No newline at end of file +*Garmy makes Garmin Connect data accessible with modern Python practices, type safety, and AI assistant integration for building intelligent health applications.* \ No newline at end of file diff --git a/check_null_columns.py b/check_null_columns.py new file mode 100644 index 0000000..561c6cf --- /dev/null +++ b/check_null_columns.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Check database for columns with all NULL values.""" + +import sqlite3 +import sys +from pathlib import Path + +def check_null_columns(db_path: str): + """Check which columns have all NULL values in each table.""" + + if not Path(db_path).exists(): + print(f"โŒ Database file not found: {db_path}") + return + + try: + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + # Get all tables + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';") + tables = cursor.fetchall() + + print(f"๐Ÿ” Checking NULL columns in database: {db_path}") + print("=" * 80) + + total_null_columns = 0 + + for table_name, in tables: + # Get table info + cursor.execute(f"PRAGMA table_info({table_name});") + columns_info = cursor.fetchall() + + # Get row count + cursor.execute(f"SELECT COUNT(*) FROM {table_name};") + total_rows = cursor.fetchone()[0] + + if total_rows == 0: + print(f"\n๐Ÿ“Š Table: {table_name} (0 rows - skipping)") + continue + + null_columns = [] + + for col in columns_info: + col_name = col[1] # Column name is at index 1 + + # Count non-NULL values + cursor.execute(f"SELECT COUNT(*) FROM {table_name} WHERE {col_name} IS NOT NULL;") + non_null_count = cursor.fetchone()[0] + + if non_null_count == 0: + null_columns.append(col_name) + + # Report results for this table + print(f"\n๐Ÿ“Š Table: {table_name} ({total_rows} rows)") + + if null_columns: + print(f"โŒ Columns with ALL NULL values ({len(null_columns)} columns):") + for col in null_columns: + print(f" โ€ข {col}") + total_null_columns += len(null_columns) + else: + print("โœ… No columns with all NULL values") + + print("\n" + "=" * 80) + print(f"๐Ÿ“ˆ SUMMARY: Found {total_null_columns} columns with all NULL values across all tables") + + conn.close() + + except sqlite3.Error as e: + print(f"โŒ Database error: {e}") + except Exception as e: + print(f"โŒ Error: {e}") + +def check_specific_columns(db_path: str, table_name: str, columns: list): + """Check specific columns for NULL values.""" + + if not Path(db_path).exists(): + print(f"โŒ Database file not found: {db_path}") + return + + try: + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + # Get row count + cursor.execute(f"SELECT COUNT(*) FROM {table_name};") + total_rows = cursor.fetchone()[0] + + print(f"\n๐Ÿ” Checking specific columns in {table_name} ({total_rows} rows):") + print("-" * 50) + + for col in columns: + try: + # Count non-NULL values + cursor.execute(f"SELECT COUNT(*) FROM {table_name} WHERE {col} IS NOT NULL;") + non_null_count = cursor.fetchone()[0] + + null_count = total_rows - non_null_count + percentage = (null_count / total_rows * 100) if total_rows > 0 else 0 + + status = "โŒ" if non_null_count == 0 else "โœ…" if null_count == 0 else "โš ๏ธ " + print(f"{status} {col}: {non_null_count} non-NULL, {null_count} NULL ({percentage:.1f}%)") + + except sqlite3.Error as e: + print(f"โŒ {col}: Error - {e}") + + conn.close() + + except sqlite3.Error as e: + print(f"โŒ Database error: {e}") + except Exception as e: + print(f"โŒ Error: {e}") + +if __name__ == "__main__": + # Default database path + db_path = "health.db" + + if len(sys.argv) > 1: + db_path = sys.argv[1] + + # Check all columns for NULL values + check_null_columns(db_path) + + # Check specific sleep and health columns + sleep_columns = [ + 'sleep_duration_hours', 'deep_sleep_hours', 'light_sleep_hours', + 'rem_sleep_hours', 'awake_hours', 'deep_sleep_percentage', + 'light_sleep_percentage', 'rem_sleep_percentage', 'awake_percentage' + ] + + health_columns = [ + 'step_goal', 'resting_heart_rate', 'max_heart_rate', 'min_heart_rate', + 'average_heart_rate', 'avg_stress_level', 'average_spo2', 'average_respiration' + ] + + check_specific_columns(db_path, 'daily_health_metrics', sleep_columns + health_columns) \ No newline at end of file diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..1a4ebb6 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,42 @@ +# Garmy Documentation + +Complete documentation for the Garmy health data analysis library. + +## ๐Ÿ“š Documentation Index + +### Core Library +- **[Quick Start Guide](quick-start.md)** - Get up and running with Garmy in minutes + +### LocalDB Module +- **[LocalDB Guide](localdb-guide.md)** - Complete guide to local health data storage +- **[Database Schema](database-schema.md)** - Database structure and table relationships + +### MCP Server +- **[MCP Usage Example](mcp-example.md)** - Complete walkthrough from sync to AI analysis +- **[MCP Server Guide](mcp-server-guide.md)** - Model Context Protocol server for AI integration +- **[Claude Desktop Integration](claude-desktop-integration.md)** - Setup with Claude Desktop + +### Examples and Tutorials +- **[Examples Directory](../examples/)** - Comprehensive usage examples + +## ๐ŸŽฏ Quick Navigation + +### For Beginners +1. [Quick Start Guide](quick-start.md) - Start here! +2. [Examples Directory](../examples/) - Learn with examples + +### For AI Developers +1. [MCP Usage Example](mcp-example.md) - Complete walkthrough โญ +2. [MCP Server Guide](mcp-server-guide.md) - AI assistant integration +3. [Claude Desktop Integration](claude-desktop-integration.md) - Claude setup + +### For Data Analysts +1. [LocalDB Guide](localdb-guide.md) - Local data storage +2. [Database Schema](database-schema.md) - Understanding the data + +## ๐Ÿ”— External Resources + +- **[GitHub Repository](https://github.com/bes-dev/garmy)** - Source code and issues +- **[PyPI Package](https://pypi.org/project/garmy/)** - Installation and releases +- **[Garmin Connect API](https://connect.garmin.com/)** - Data source +- **[FastMCP Framework](https://github.com/jlowin/fastmcp)** - MCP server framework \ No newline at end of file diff --git a/docs/claude-desktop-integration.md b/docs/claude-desktop-integration.md new file mode 100644 index 0000000..6b6acde --- /dev/null +++ b/docs/claude-desktop-integration.md @@ -0,0 +1,399 @@ +# Claude Desktop Integration + +Complete guide to integrating Garmy's MCP server with Claude Desktop for AI-powered health data analysis. + +## ๐ŸŽฏ Overview + +Claude Desktop integration allows you to have natural conversations with Claude about your health data, enabling: +- **Natural language queries** about your health metrics +- **Trend analysis** and pattern recognition +- **Health insights** and recommendations +- **Data exploration** without writing SQL + +## ๐Ÿš€ Quick Setup + +### 1. Prerequisites +```bash +# Install Garmy with MCP support +pip install garmy[mcp] + +# Sync your health data +garmy-sync sync --last-days 30 +``` + +### 2. Verify MCP Server Works +```bash +# Test the server +garmy-mcp info --database health.db + +# Start server to verify it works +garmy-mcp server --database health.db --verbose +``` + +### 3. Configure Claude Desktop + +#### Find Configuration File +- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` +- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` +- **Linux**: `~/.config/Claude/claude_desktop_config.json` + +#### Add Garmy Configuration +```json +{ + "mcpServers": { + "garmy-localdb": { + "command": "garmy-mcp", + "args": ["server", "--database", "/full/path/to/health.db", "--max-rows", "500"] + } + } +} +``` + +**Alternative: Using python -m** +```json +{ + "mcpServers": { + "garmy-localdb": { + "command": "python", + "args": ["-m", "garmy.mcp", "server", "--database", "/full/path/to/health.db", "--max-rows", "500"] + } + } +} +``` + +### 4. Restart Claude Desktop +- Completely quit Claude Desktop +- Restart the application +- Look for the ๐Ÿ”Œ (plug) icon indicating MCP connection + +## โš™๏ธ Configuration Options + +### Basic Configuration +```json +{ + "mcpServers": { + "garmy-localdb": { + "command": "garmy-mcp", + "args": ["server", "--database", "/Users/yourname/health.db"] + } + } +} +``` + +### Production Configuration (Restrictive) +```json +{ + "mcpServers": { + "garmy-localdb": { + "command": "garmy-mcp", + "args": [ + "server", + "--database", "/path/to/health.db", + "--max-rows", "100", + "--max-rows-absolute", "500" + ] + } + } +} +``` + +**Alternative: Using python -m** +```json +{ + "mcpServers": { + "garmy-localdb": { + "command": "python", + "args": [ + "-m", "garmy.mcp", "server", + "--database", "/path/to/health.db", + "--max-rows", "100", + "--max-rows-absolute", "500" + ] + } + } +} +``` + +### Development Configuration (Verbose) +```json +{ + "mcpServers": { + "garmy-localdb": { + "command": "garmy-mcp", + "args": [ + "server", + "--database", "/path/to/health.db", + "--max-rows", "1000", + "--enable-query-logging", + "--verbose" + ] + } + } +} +``` + +**Alternative: Using python -m** +```json +{ + "mcpServers": { + "garmy-localdb": { + "command": "python", + "args": [ + "-m", "garmy.mcp", "server", + "--database", "/path/to/health.db", + "--max-rows", "1000", + "--enable-query-logging", + "--verbose" + ] + } + } +} +``` + +### Using Environment Variables +```json +{ + "mcpServers": { + "garmy-localdb": { + "command": "garmy-mcp", + "args": ["server", "--max-rows", "500"], + "env": { + "GARMY_DB_PATH": "/full/path/to/health.db" + } + } + } +} +``` + +## ๐Ÿ’ฌ Example Conversations + +### Getting Started +**You:** "What health data do I have available?" + +**Claude:** *Uses `explore_database_structure()` to show available tables and data* + +### Sleep Analysis +**You:** "How has my sleep been over the last month?" + +**Claude:** *Uses `execute_sql_query()` to analyze sleep patterns* + +```sql +SELECT + metric_date, + sleep_duration_hours, + deep_sleep_percentage, + rem_sleep_percentage +FROM daily_health_metrics +WHERE user_id = 1 + AND metric_date >= date('now', '-30 days') + AND sleep_duration_hours IS NOT NULL +ORDER BY metric_date DESC +``` + +### Activity Analysis +**You:** "What are my most common workouts and their intensity?" + +**Claude:** *Analyzes activities table* + +```sql +SELECT + activity_name, + COUNT(*) as workout_count, + AVG(avg_heart_rate) as avg_heart_rate, + AVG(training_load) as avg_training_load +FROM activities +WHERE user_id = 1 + AND activity_date >= date('now', '-90 days') +GROUP BY activity_name +ORDER BY workout_count DESC +``` + +### Health Correlations +**You:** "Is there a relationship between my stress levels and sleep quality?" + +**Claude:** *Performs correlation analysis* + +```sql +SELECT + metric_date, + avg_stress_level, + sleep_duration_hours, + deep_sleep_percentage +FROM daily_health_metrics +WHERE user_id = 1 + AND avg_stress_level IS NOT NULL + AND sleep_duration_hours IS NOT NULL + AND metric_date >= date('now', '-60 days') +ORDER BY metric_date +``` + +### Quick Health Summary +**You:** "Give me a quick health summary for the last week" + +**Claude:** *Uses `get_health_summary(user_id=1, days=7)`* + +## ๐Ÿ” Available Tools for Claude + +When properly configured, Claude has access to these tools: + +### ๐Ÿ” Discovery Tools +- **`explore_database_structure()`** - See what health data is available +- **`get_table_details(table_name)`** - Understand table structure and sample data + +### ๐Ÿ“Š Analysis Tools +- **`execute_sql_query(query, params)`** - Run custom SQL queries for analysis +- **`get_health_summary(user_id, days)`** - Get quick health overview + +### ๐Ÿ“š Reference +- **`health_data_guide()`** - Complete guide to the health data structure + +## ๐ŸŽจ Best Practices + +### 1. Start with Exploration +``` +"What health data do I have available? Show me the database structure." +``` + +### 2. Ask for Specific Analysis +``` +"Analyze my sleep patterns over the last 30 days. Look for trends in sleep duration and quality." +``` + +### 3. Request Correlations +``` +"Is there a correlation between my step count and sleep quality?" +``` + +### 4. Get Actionable Insights +``` +"Based on my health data, what recommendations do you have for improving my recovery?" +``` + +### 5. Explore Different Time Periods +``` +"Compare my fitness metrics from this month versus last month." +``` + +## ๐Ÿ› ๏ธ Troubleshooting + +### Claude Shows No MCP Connection + +1. **Check Configuration File Location** + ```bash + # macOS + ls -la ~/Library/Application\ Support/Claude/claude_desktop_config.json + + # Linux + ls -la ~/.config/Claude/claude_desktop_config.json + ``` + +2. **Validate JSON Syntax** + ```bash + # Use jq to validate JSON + cat claude_desktop_config.json | jq . + ``` + +3. **Check Database Path** + ```bash + # Verify database exists and is readable + garmy-mcp info --database /full/path/to/health.db + ``` + +4. **Test MCP Server Manually** + ```bash + # Run the exact command from your config + garmy-mcp server --database /path/to/health.db --max-rows 500 + ``` + +### Claude Can't Access Health Data + +1. **Check MCP Server Logs** + ```bash + # Enable verbose logging + garmy-mcp server --database health.db --verbose --enable-query-logging + ``` + +2. **Verify Database Permissions** + ```bash + # Check file permissions + ls -la health.db + + # Ensure read access + chmod 644 health.db + ``` + +3. **Test Database Content** + ```bash + # Verify data exists + garmy-mcp info --database health.db + ``` + +### Performance Issues + +1. **Reduce Row Limits** + ```json + { + "args": ["server", "--database", "/path/to/health.db", "--max-rows", "100"] + } + ``` + +2. **Enable Query Logging to Monitor Performance** + ```json + { + "args": ["server", "--database", "/path/to/health.db", "--enable-query-logging"] + } + ``` + +## ๐Ÿ”ง Advanced Configuration + +### Multiple Health Databases +```json +{ + "mcpServers": { + "garmy-personal": { + "command": "garmy-mcp", + "args": ["server", "--database", "/path/to/personal_health.db"] + }, + "garmy-family": { + "command": "garmy-mcp", + "args": ["server", "--database", "/path/to/family_health.db"] + } + } +} +``` + +### Custom Security Settings +```json +{ + "mcpServers": { + "garmy-localdb": { + "command": "garmy-mcp", + "args": [ + "server", + "--database", "/path/to/health.db", + "--max-rows", "50", + "--max-rows-absolute", "200" + ] + } + } +} +``` + +## ๐Ÿ“Š Example Health Insights + +With proper setup, you can ask Claude questions like: + +- "What's my average sleep duration and how has it changed over time?" +- "Show me my most challenging workouts based on heart rate and training load" +- "Are there patterns in my stress levels throughout the week?" +- "How does my step count correlate with my sleep quality?" +- "What days do I have the best training readiness scores?" +- "Analyze my heart rate variability trends" +- "Compare my activity levels between weekdays and weekends" + +## ๐Ÿ”— Related Documentation + +- **[MCP Usage Example](mcp-example.md)** - Complete walkthrough from sync to AI analysis โญ +- **[MCP Server Guide](mcp-server-guide.md)** - Complete MCP server documentation +- **[Database Schema](database-schema.md)** - Understanding your health data +- **[LocalDB Guide](localdb-guide.md)** - Setting up data synchronization \ No newline at end of file diff --git a/docs/database-schema.md b/docs/database-schema.md new file mode 100644 index 0000000..455d4d0 --- /dev/null +++ b/docs/database-schema.md @@ -0,0 +1,298 @@ +# Database Schema + +Complete reference for Garmy's LocalDB database schema and structure. + +## ๐ŸŽฏ Overview + +The Garmy LocalDB uses SQLite with optimized tables for health data storage: + +- **4 main tables** for different data types +- **Normalized structure** for efficient querying +- **Dedicated columns** for common health metrics +- **Sync tracking** for data integrity + +## ๐Ÿ“Š Schema Diagram + +``` +daily_health_metrics (Primary health data) +โ”œโ”€โ”€ user_id, metric_date (PK) +โ”œโ”€โ”€ Steps: total_steps, step_goal, total_distance_meters +โ”œโ”€โ”€ Sleep: sleep_duration_hours, deep_sleep_hours, rem_sleep_hours +โ”œโ”€โ”€ Heart Rate: resting_heart_rate, max_heart_rate, average_heart_rate +โ”œโ”€โ”€ Stress: avg_stress_level, max_stress_level +โ”œโ”€โ”€ Body Battery: body_battery_high, body_battery_low +โ”œโ”€โ”€ Training: training_readiness_score, training_readiness_level +โ””โ”€โ”€ HRV: hrv_weekly_avg, hrv_last_night_avg, hrv_status + +timeseries (High-frequency data) +โ”œโ”€โ”€ user_id, metric_type, timestamp (PK) +โ”œโ”€โ”€ value (Float) +โ””โ”€โ”€ meta_data (JSON) + +activities (Workouts and activities) +โ”œโ”€โ”€ user_id, activity_id (PK) +โ”œโ”€โ”€ activity_date, activity_name +โ”œโ”€โ”€ duration_seconds, avg_heart_rate +โ”œโ”€โ”€ training_load, start_time +โ””โ”€โ”€ created_at + +sync_status (Sync tracking) +โ”œโ”€โ”€ user_id, sync_date, metric_type (PK) +โ”œโ”€โ”€ status, synced_at +โ”œโ”€โ”€ error_message +โ””โ”€โ”€ created_at +``` + +## ๐Ÿ“‹ Table Details + +### `daily_health_metrics` +**Purpose:** Daily health summaries with normalized columns for efficient querying + +**Primary Key:** `(user_id, metric_date)` + +**Key Columns:** +```sql +-- Identity +user_id INTEGER -- User identifier +metric_date DATE -- Date of metrics + +-- Steps and Movement +total_steps INTEGER -- Daily step count +step_goal INTEGER -- Daily step goal +total_distance_meters FLOAT -- Distance in meters + +-- Calories +total_calories INTEGER -- Total calories burned +active_calories INTEGER -- Active calories +bmr_calories INTEGER -- Basal metabolic rate calories + +-- Heart Rate +resting_heart_rate INTEGER -- Morning resting HR +max_heart_rate INTEGER -- Maximum HR during day +min_heart_rate INTEGER -- Minimum HR during day +average_heart_rate INTEGER -- Average HR during day + +-- Stress and Recovery +avg_stress_level INTEGER -- Average stress (0-100) +max_stress_level INTEGER -- Maximum stress level +body_battery_high INTEGER -- Highest body battery +body_battery_low INTEGER -- Lowest body battery + +-- Sleep +sleep_duration_hours FLOAT -- Total sleep time +deep_sleep_hours FLOAT -- Deep sleep time +light_sleep_hours FLOAT -- Light sleep time +rem_sleep_hours FLOAT -- REM sleep time +awake_hours FLOAT -- Time awake +deep_sleep_percentage FLOAT -- % of sleep in deep +light_sleep_percentage FLOAT -- % of sleep in light +rem_sleep_percentage FLOAT -- % of sleep in REM +awake_percentage FLOAT -- % of time awake + +-- Respiration and SpO2 +average_spo2 FLOAT -- Average blood oxygen +average_respiration FLOAT -- Average respiration rate +avg_waking_respiration_value FLOAT +avg_sleep_respiration_value FLOAT +lowest_respiration_value FLOAT +highest_respiration_value FLOAT + +-- Training and HRV +training_readiness_score INTEGER -- Training readiness (0-100) +training_readiness_level TEXT -- Readiness level description +training_readiness_feedback TEXT -- Readiness feedback +hrv_weekly_avg FLOAT -- Weekly HRV average +hrv_last_night_avg FLOAT -- Last night HRV +hrv_status TEXT -- HRV status description + +-- Timestamps +created_at DATETIME -- Record creation time +updated_at DATETIME -- Last update time +``` + +### `timeseries` +**Purpose:** High-frequency data throughout the day (heart rate, stress, body battery) + +**Primary Key:** `(user_id, metric_type, timestamp)` + +**Columns:** +```sql +user_id INTEGER -- User identifier +metric_type STRING -- Type of metric (heart_rate, stress, body_battery) +timestamp INTEGER -- Unix timestamp in milliseconds +value FLOAT -- Metric value at timestamp +meta_data JSON -- Additional metadata (optional) +``` + +**Common Metric Types:** +- `heart_rate` - Heart rate readings +- `stress` - Stress level measurements +- `body_battery` - Body battery levels +- `respiration` - Respiration rate readings + +### `activities` +**Purpose:** Individual workouts and physical activities + +**Primary Key:** `(user_id, activity_id)` + +**Columns:** +```sql +user_id INTEGER -- User identifier +activity_id STRING -- Garmin activity ID +activity_date DATE -- Date of activity +activity_name STRING -- Activity type (e.g., "Running", "Cycling") +duration_seconds INTEGER -- Activity duration in seconds +avg_heart_rate INTEGER -- Average heart rate during activity +training_load FLOAT -- Training load/stress score +start_time STRING -- Activity start time +created_at DATETIME -- Record creation time +``` + +### `sync_status` +**Purpose:** Track synchronization status for each metric per date + +**Primary Key:** `(user_id, sync_date, metric_type)` + +**Columns:** +```sql +user_id INTEGER -- User identifier +sync_date DATE -- Date being synced +metric_type STRING -- Metric type being synced +status STRING -- Sync status (pending, completed, failed, skipped) +synced_at DATETIME -- When sync completed +error_message TEXT -- Error message if sync failed +created_at DATETIME -- Record creation time +``` + +**Status Values:** +- `pending` - Sync not yet attempted +- `completed` - Successfully synced +- `failed` - Sync failed with error +- `skipped` - No data available or already exists + +## ๐Ÿ” Common Queries + +### Daily Health Trends +```sql +SELECT + metric_date, + total_steps, + sleep_duration_hours, + resting_heart_rate, + avg_stress_level +FROM daily_health_metrics +WHERE user_id = 1 + AND metric_date >= date('now', '-30 days') +ORDER BY metric_date; +``` + +### Sleep Analysis +```sql +SELECT + metric_date, + sleep_duration_hours, + deep_sleep_percentage, + rem_sleep_percentage, + hrv_last_night_avg +FROM daily_health_metrics +WHERE user_id = 1 + AND sleep_duration_hours IS NOT NULL + AND metric_date >= date('now', '-7 days') +ORDER BY metric_date; +``` + +### Activity Performance +```sql +SELECT + activity_date, + activity_name, + duration_seconds / 60.0 as duration_minutes, + avg_heart_rate, + training_load +FROM activities +WHERE user_id = 1 + AND activity_date >= date('now', '-30 days') +ORDER BY activity_date DESC; +``` + +### Heart Rate Timeseries +```sql +SELECT + datetime(timestamp/1000, 'unixepoch') as time, + value as heart_rate +FROM timeseries +WHERE user_id = 1 + AND metric_type = 'heart_rate' + AND timestamp >= strftime('%s', date('now', '-1 day')) * 1000 +ORDER BY timestamp; +``` + +### Sync Status Check +```sql +SELECT + sync_date, + metric_type, + status, + synced_at, + error_message +FROM sync_status +WHERE user_id = 1 + AND status = 'failed' +ORDER BY sync_date DESC; +``` + +## ๐Ÿ“ˆ Data Relationships + +### User-Centric Design +All tables use `user_id` as the primary identifier, allowing multi-user support. + +### Date-Based Partitioning +- `daily_health_metrics`: Uses `metric_date` for daily aggregations +- `activities`: Uses `activity_date` for workout tracking +- `timeseries`: Uses `timestamp` for high-frequency data +- `sync_status`: Uses `sync_date` for sync tracking + +### Metric Type Enumeration +Supported metric types in `sync_status` and `timeseries`: +- `DAILY_SUMMARY` +- `SLEEP` +- `ACTIVITIES` +- `BODY_BATTERY` +- `STRESS` +- `HEART_RATE` +- `TRAINING_READINESS` +- `HRV` +- `RESPIRATION` +- `STEPS` +- `CALORIES` + +## ๐Ÿ”ง Performance Considerations + +### Indexes +The schema includes efficient indexes for: +- Primary key lookups +- Date range queries +- User-specific queries +- Metric type filtering + +### NULL Value Handling +Many health metrics can be NULL when: +- Data not available from Garmin +- Sensor not worn/active +- Sync incomplete + +Always use `IS NOT NULL` checks in analysis queries. + +### Data Types +- **INTEGER**: Used for whole numbers (steps, heart rate) +- **FLOAT**: Used for decimal values (sleep hours, HRV) +- **TEXT**: Used for descriptions and status +- **DATE**: Used for date-only fields +- **DATETIME**: Used for timestamps +- **JSON**: Used for flexible metadata storage + +## ๐Ÿ”— Related Documentation + +- **[LocalDB Guide](localdb-guide.md)** - Working with the database +- **[MCP Server Guide](mcp-server-guide.md)** - Querying via MCP +- **[Quick Start Guide](quick-start.md)** - Getting started \ No newline at end of file diff --git a/docs/localdb-guide.md b/docs/localdb-guide.md new file mode 100644 index 0000000..3b6c1c8 --- /dev/null +++ b/docs/localdb-guide.md @@ -0,0 +1,306 @@ +# LocalDB Guide + +Complete guide to Garmy's local database functionality for health data storage and synchronization. + +## ๐ŸŽฏ Overview + +The LocalDB module provides local SQLite storage for synchronized Garmin health data, enabling: +- **Offline analysis** of health metrics +- **Historical data preservation** +- **Fast querying** with SQL +- **Data integrity** tracking + +## ๐Ÿš€ Quick Start + +### 1. Install LocalDB Dependencies +```bash +pip install garmy[localdb] +``` + +### 2. Sync Health Data +```bash +# Sync last 7 days +garmy-sync sync --last-days 7 + +# Sync specific date range +garmy-sync sync --date-range 2024-01-01 2024-01-31 + +# Sync specific metrics only +garmy-sync sync --metrics DAILY_SUMMARY,SLEEP,BODY_BATTERY + +# Check sync status +garmy-sync status + +# Reset failed sync records +garmy-sync reset --force +``` + +## ๐Ÿ“Š Database Schema + +### Main Tables + +#### `daily_health_metrics` +Normalized daily health data with dedicated columns for efficient querying. + +**Key Fields:** +- `user_id`, `metric_date` (Primary Key) +- `total_steps`, `sleep_duration_hours`, `resting_heart_rate` +- `avg_stress_level`, `body_battery_high/low` +- `training_readiness_score`, `hrv_weekly_avg` + +#### `timeseries` +High-frequency data (heart rate, stress, body battery readings). + +**Key Fields:** +- `user_id`, `metric_type`, `timestamp` (Primary Key) +- `value`, `meta_data` + +#### `activities` +Individual workouts and activities with performance metrics. + +**Key Fields:** +- `user_id`, `activity_id` (Primary Key) +- `activity_name`, `duration_seconds`, `avg_heart_rate` +- `training_load`, `activity_date` + +#### `sync_status` +Sync status tracking for each metric per date. + +**Key Fields:** +- `user_id`, `sync_date`, `metric_type` (Primary Key) +- `status`, `synced_at`, `error_message` + +## ๐Ÿ”ง Programmatic Usage + +### Basic Sync Operations + +```python +from garmy.localdb import SyncManager +from datetime import date, timedelta + +# Initialize sync manager +sync_manager = SyncManager(db_path="my_health.db") +sync_manager.initialize("email@garmin.com", "password") + +# Sync data +end_date = date.today() +start_date = end_date - timedelta(days=30) + +stats = sync_manager.sync_range( + user_id=1, + start_date=start_date, + end_date=end_date +) + +print(f"Synced: {stats['completed']} records") +``` + +### Querying Health Data + +```python +# Query health metrics +health_data = sync_manager.query_health_metrics( + user_id=1, + start_date=start_date, + end_date=end_date +) + +# Query activities +activities = sync_manager.query_activities( + user_id=1, + start_date=start_date, + end_date=end_date, + activity_name="Running" # Optional filter +) + +# Query timeseries data +from datetime import datetime +timeseries_data = sync_manager.query_timeseries( + user_id=1, + metric_type=MetricType.HEART_RATE, + start_time=datetime(2024, 1, 1, 0, 0), + end_time=datetime(2024, 1, 1, 23, 59) +) +``` + +### Direct Database Access + +```python +from garmy.localdb import HealthDB + +# Initialize database +db = HealthDB(db_path="health.db") + +# Get health metrics for analysis +with db.get_session() as session: + from garmy.localdb.models import DailyHealthMetric + + metrics = session.query(DailyHealthMetric).filter( + DailyHealthMetric.user_id == 1, + DailyHealthMetric.total_steps > 10000 + ).all() + + for metric in metrics: + print(f"{metric.metric_date}: {metric.total_steps} steps") +``` + +## โš™๏ธ Configuration + +### Sync Configuration + +```python +from garmy.localdb.config import LocalDBConfig, SyncConfig, DatabaseConfig + +# Custom configuration +config = LocalDBConfig( + sync=SyncConfig( + max_sync_days=365, # Maximum sync range + retry_failed=True, + batch_size=10 + ), + database=DatabaseConfig( + connection_timeout=30, + query_timeout=60 + ) +) + +sync_manager = SyncManager(db_path="health.db", config=config) +``` + +### Environment Variables + +```bash +# Database path for CLI tools +export GARMY_DB_PATH="/path/to/health.db" + +# API credentials (optional) +export GARMIN_EMAIL="your_email@garmin.com" +export GARMIN_PASSWORD="your_password" +``` + +## ๐Ÿ“ˆ Data Analysis Examples + +### Sleep Analysis +```python +# Get sleep trends +sleep_query = """ + SELECT + metric_date, + sleep_duration_hours, + deep_sleep_percentage, + rem_sleep_percentage + FROM daily_health_metrics + WHERE user_id = 1 + AND sleep_duration_hours IS NOT NULL + AND metric_date >= date('now', '-30 days') + ORDER BY metric_date +""" + +with db.get_session() as session: + results = session.execute(text(sleep_query)).fetchall() + + for row in results: + print(f"{row.metric_date}: {row.sleep_duration_hours:.1f}h sleep, " + f"{row.deep_sleep_percentage:.1f}% deep") +``` + +### Activity Performance +```python +# Analyze workout intensity +activity_query = """ + SELECT + activity_name, + AVG(avg_heart_rate) as avg_hr, + AVG(training_load) as avg_load, + COUNT(*) as workout_count + FROM activities + WHERE user_id = 1 + AND activity_date >= date('now', '-90 days') + GROUP BY activity_name + HAVING workout_count >= 3 + ORDER BY avg_load DESC +""" + +with db.get_session() as session: + results = session.execute(text(activity_query)).fetchall() + + for row in results: + print(f"{row.activity_name}: {row.avg_hr:.0f} BPM avg, " + f"{row.avg_load:.1f} training load ({row.workout_count} workouts)") +``` + +## ๐Ÿ”„ Advanced Sync Operations + +### Selective Metric Sync +```python +from garmy.localdb.models import MetricType + +# Sync only specific metrics +metrics_to_sync = [ + MetricType.DAILY_SUMMARY, + MetricType.SLEEP, + MetricType.TRAINING_READINESS +] + +stats = sync_manager.sync_range( + user_id=1, + start_date=start_date, + end_date=end_date, + metrics=metrics_to_sync +) +``` + +### Progress Monitoring +```python +from garmy.localdb.progress import ProgressReporter + +# Enable progress monitoring +progress = ProgressReporter(use_tqdm=True) +sync_manager = SyncManager( + db_path="health.db", + progress_reporter=progress +) + +# Sync with progress bar +stats = sync_manager.sync_range(user_id=1, start_date=start_date, end_date=end_date) +``` + +## ๐Ÿ› ๏ธ Troubleshooting + +### Common Issues + +1. **Database Lock Errors** + ```python + # Ensure proper session management + with db.get_session() as session: + # Your database operations here + pass # Session automatically closed + ``` + +2. **Sync Failures** + ```bash + # Reset failed sync records + garmy-sync reset --force + + # Check sync status + garmy-sync status + ``` + +3. **Large Dataset Performance** + ```python + # Use smaller date ranges for large syncs + from datetime import timedelta + + current_date = start_date + while current_date <= end_date: + chunk_end = min(current_date + timedelta(days=7), end_date) + sync_manager.sync_range(user_id=1, start_date=current_date, end_date=chunk_end) + current_date = chunk_end + timedelta(days=1) + ``` + +## ๐Ÿ”— Related Documentation + +- **[Database Schema](database-schema.md)** - Detailed schema documentation +- **[MCP Server Guide](mcp-server-guide.md)** - AI integration with local data +- **[Quick Start Guide](quick-start.md)** - Getting started +- **[Examples](../examples/)** - Usage examples \ No newline at end of file diff --git a/docs/mcp-example.md b/docs/mcp-example.md new file mode 100644 index 0000000..0fab564 --- /dev/null +++ b/docs/mcp-example.md @@ -0,0 +1,263 @@ +# MCP Usage Example + +Complete walkthrough from data synchronization to AI analysis with Claude Desktop. + +## ๐ŸŽฏ Overview + +This example shows the complete workflow: +1. **Sync health data** from Garmin Connect to local database +2. **Setup MCP server** for AI access +3. **Configure Claude Desktop** for health data analysis +4. **Analyze data** with natural language queries + +## ๐Ÿ“‹ Prerequisites + +```bash +# Install Garmy with all features +pip install garmy[all] + +# Verify installation +garmy-sync --help +garmy-mcp --help +``` + +## Step 1: Sync Your Health Data ๐Ÿ“Š + +### Initial Setup +```bash +# Sync last 30 days of health data +garmy-sync --db-path health.db sync --last-days 30 +``` + +**What happens:** +- Downloads sleep, activity, heart rate, stress, and other metrics +- Stores data in local SQLite database (`health.db`) +- Creates normalized tables for efficient querying + +**Example output:** +``` +Syncing data from 2024-12-01 to 2024-12-30 +Enter your Garmin Connect credentials: +Email: your_email@garmin.com +Password: [hidden] +Connecting to Garmin Connect... +Syncing metrics: DAILY_SUMMARY, SLEEP, ACTIVITIES, BODY_BATTERY, STRESS, HEART_RATE, TRAINING_READINESS, HRV, RESPIRATION, STEPS, CALORIES + +Sync completed! + Completed: 287 + Skipped: 43 + Failed: 0 + Total tasks: 330 +``` + +### Verify Your Data +```bash +# Check sync status +garmy-sync --db-path health.db status + +# Show database information +garmy-mcp info --database health.db +``` + +## Step 2: Setup MCP Server ๐Ÿค– + +### Test MCP Server +```bash +# Start MCP server (test it works) +garmy-mcp server --database health.db --verbose +``` + +**Expected output:** +``` +[06/30/25 14:29:43] INFO Starting MCP server 'Garmin Health Data Explorer' with transport 'stdio' +``` + +Press `Ctrl+C` to stop the test server. + +### Configure for Claude Desktop + +**macOS:** Edit `~/Library/Application Support/Claude/claude_desktop_config.json` +**Windows:** Edit `%APPDATA%\Claude\claude_desktop_config.json` +**Linux:** Edit `~/.config/Claude/claude_desktop_config.json` + +```json +{ + "mcpServers": { + "garmy-health": { + "command": "garmy-mcp", + "args": [ + "server", + "--database", "/full/path/to/health.db", + "--max-rows", "500" + ] + } + } +} +``` + +**Important:** Use the **full absolute path** to your `health.db` file! + +## Step 3: Configure Claude Desktop ๐Ÿ–ฅ๏ธ + +### Restart Claude Desktop +1. **Completely quit** Claude Desktop +2. **Restart** the application +3. Look for the **๐Ÿ”Œ plug icon** indicating MCP connection + +### Verify Connection +You should see the MCP connection indicator in Claude Desktop. If not, check: +- JSON syntax is correct +- Database path is absolute and correct +- File permissions allow reading `health.db` + +## Step 4: Analyze Your Health Data ๐Ÿ’ฌ + +Now you can have natural conversations with Claude about your health data! + +### Getting Started Queries + +**Explore what data you have:** +> "What health data do I have available? Show me the database structure." + +**Claude will use** `explore_database_structure()` **and respond with:** +``` +I can see you have comprehensive health data with 4 main tables: + +๐Ÿ“Š daily_health_metrics (30 records): Daily summaries including steps, sleep, heart rate, stress +๐Ÿƒ activities (12 records): Individual workouts with performance metrics +๐Ÿ“ˆ timeseries (8,640 records): High-frequency heart rate, stress, body battery data +๐Ÿ“‹ sync_status (330 records): Data synchronization tracking + +You have data spanning from 2024-12-01 to 2024-12-30. +``` + +### Sleep Analysis Examples + +**Basic sleep overview:** +> "How has my sleep been over the last month?" + +**Detailed sleep analysis:** +> "Analyze my sleep patterns. Show me average sleep duration, deep sleep percentage, and any trends over time." + +**Sleep quality insights:** +> "What factors might be affecting my sleep quality? Look at correlations between sleep duration, stress levels, and activity." + +### Activity and Fitness Analysis + +**Workout summary:** +> "What are my most common workouts and how intense are they typically?" + +**Performance trends:** +> "Show me my fitness progression over the last month. Look at heart rate trends, training load, and recovery patterns." + +**Activity vs recovery:** +> "Is there a relationship between my workout intensity and my next-day recovery metrics like HRV and training readiness?" + +### Health Correlations + +**Stress and sleep:** +> "Is there a correlation between my daily stress levels and sleep quality?" + +**Steps and energy:** +> "How does my daily step count relate to my body battery levels and energy throughout the day?" + +**Weekly patterns:** +> "Do I have different health patterns on weekdays vs weekends? Compare my sleep, activity, and stress." + +### Advanced Analysis + +**Custom time periods:** +> "Compare my health metrics from the first week of December vs the last week. What changed?" + +**Specific insights:** +> "What days did I have the best training readiness scores? What factors contributed to those high scores?" + +**Data-driven recommendations:** +> "Based on my health data patterns, what recommendations do you have for improving my recovery and performance?" + +## ๐Ÿ“Š Example Claude Conversation + +**You:** "What health data do I have available?" + +**Claude:** Uses `explore_database_structure()` and shows available tables and data ranges. + +**You:** "Analyze my sleep over the last 2 weeks" + +**Claude:** Uses `execute_sql_query()` with: +```sql +SELECT + metric_date, + sleep_duration_hours, + deep_sleep_percentage, + rem_sleep_percentage, + light_sleep_percentage +FROM daily_health_metrics +WHERE metric_date >= date('now', '-14 days') + AND sleep_duration_hours IS NOT NULL +ORDER BY metric_date +``` + +**Claude:** Provides analysis like: +- Average sleep duration: 7.3 hours +- Deep sleep average: 22% +- Trend: Sleep duration improving over time +- Best sleep: December 15th (8.2 hours, 28% deep) + +**You:** "What correlates with my best sleep days?" + +**Claude:** Analyzes multiple factors and finds patterns like: +- Lower stress days (< 25) correlate with better sleep +- Days with 8,000+ steps show 15% more deep sleep +- Workout days followed by better sleep quality + +## ๐Ÿ”ง Troubleshooting + +### MCP Server Issues + +**Claude shows no MCP connection:** +```bash +# Test server manually +garmy-mcp server --database health.db --verbose + +# Check database exists and is readable +ls -la health.db +garmy-mcp info --database health.db +``` + +**JSON configuration errors:** +```bash +# Validate JSON syntax +cat ~/.config/Claude/claude_desktop_config.json | python -m json.tool +``` + +### Data Issues + +**No data available:** +```bash +# Check if sync worked +garmy-sync --db-path health.db status + +# Re-sync if needed +garmy-sync --db-path health.db sync --last-days 7 +``` + +**Missing specific metrics:** +```bash +# Check specific tables +sqlite3 health.db "SELECT COUNT(*) FROM daily_health_metrics WHERE sleep_duration_hours IS NOT NULL;" +``` + +## ๐ŸŽฏ Next Steps + +- **[Database Schema](database-schema.md)** - Understand your data structure +- **[MCP Server Guide](mcp-server-guide.md)** - Advanced MCP configuration +- **[LocalDB Guide](localdb-guide.md)** - Advanced sync operations +- **[Claude Desktop Integration](claude-desktop-integration.md)** - Detailed Claude setup + +## ๐Ÿ’ก Pro Tips + +1. **Regular syncing:** Set up daily sync with `garmy-sync sync --last-days 1` +2. **Data exploration:** Start with `explore_database_structure()` to understand your data +3. **Specific queries:** Be specific about time ranges and metrics for better analysis +4. **Multiple perspectives:** Ask Claude to analyze from different angles (weekly patterns, correlations, trends) +5. **Actionable insights:** Ask for recommendations based on your data patterns \ No newline at end of file diff --git a/docs/mcp-server-guide.md b/docs/mcp-server-guide.md new file mode 100644 index 0000000..020fd7b --- /dev/null +++ b/docs/mcp-server-guide.md @@ -0,0 +1,376 @@ +# MCP Server Guide + +Complete guide to Garmy's Model Context Protocol (MCP) server for AI assistant integration. + +## ๐ŸŽฏ Overview + +The Garmy MCP Server provides secure, read-only access to synchronized health data through the Model Context Protocol, enabling AI assistants like Claude to analyze health metrics safely. + +## ๐Ÿš€ Quick Start + +### 1. Install MCP Dependencies +```bash +pip install garmy[mcp] +``` + +### 2. Prepare Health Data +```bash +# Sync recent health data first +garmy-sync sync --last-days 30 +``` + +### 3. Start MCP Server +```bash +# Basic usage +garmy-mcp server --database health.db + +# Alternative: via python -m +python -m garmy.mcp server --database health.db + +# With custom configuration +garmy-mcp server --database health.db --max-rows 500 --enable-query-logging +python -m garmy.mcp server --database health.db --max-rows 500 --enable-query-logging +``` + +### 4. Claude Desktop Integration +Add to `~/.claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "garmy-localdb": { + "command": "garmy-mcp", + "args": ["server", "--database", "/path/to/health.db", "--max-rows", "500"] + } + } +} +``` + +**Alternative: Using python -m** +```json +{ + "mcpServers": { + "garmy-localdb": { + "command": "python", + "args": ["-m", "garmy.mcp", "server", "--database", "/path/to/health.db", "--max-rows", "500"] + } + } +} +``` + +## ๐Ÿ“‹ Available Commands + +### `garmy-mcp server` +Start the MCP server with specified configuration. + +```bash +garmy-mcp server --database health.db [options] +``` + +**Configuration Options:** +- `--max-rows N`: Maximum rows per query (default: 1000, max: 5000) +- `--max-rows-absolute N`: Hard security limit (default: 5000, max: 10000) +- `--enable-query-logging`: Log SQL queries for debugging +- `--disable-strict-validation`: Relax SQL validation (not recommended) +- `--verbose`: Show detailed configuration and startup info + +### `garmy-mcp info` +Display database information and available tools. + +```bash +garmy-mcp info --database health.db +``` + +Shows: +- Database file size and accessibility +- Available tables with record counts +- MCP tools and their purposes +- Startup command suggestions + +### `garmy-mcp config` +Show configuration examples for different use cases. + +```bash +garmy-mcp config +``` + +## ๐Ÿ› ๏ธ Available MCP Tools + +### ๐Ÿ” Database Discovery + +#### `explore_database_structure()` +**When to use:** Starting point for any health data analysis + +Your first tool for understanding what health data is available. Always use this before running specific queries. + +**Returns:** +- Available tables with descriptions and row counts +- Supported metric types +- Usage guidance + +#### `get_table_details(table_name)` +**When to use:** When you need to understand the structure of a specific table + +Use after `explore_database_structure` to see column details and sample data. + +**Example:** +```python +get_table_details("daily_health_metrics") +``` + +### ๐Ÿ“Š Data Analysis + +#### `execute_sql_query(query, params)` +**When to use:** For specific data analysis using SQL queries + +Main tool for querying any data from the database. Use it to analyze health metrics, activities, sync status, or find patterns across any tables. + +**Security Features:** +- Only `SELECT` and `WITH` statements allowed +- Automatic row limiting (configurable) +- SQL injection prevention through parameterization +- Comprehensive validation + +**Example Queries:** +```sql +-- Health metrics: Recent sleep trends +SELECT metric_date, sleep_duration_hours, deep_sleep_hours +FROM daily_health_metrics +WHERE user_id = 1 +ORDER BY metric_date DESC LIMIT 30 + +-- Activities: Workout analysis +SELECT activity_date, activity_name, duration_seconds/60 as minutes +FROM activities +WHERE user_id = 1 + +-- Timeseries: Heart rate data +SELECT timestamp, value +FROM timeseries +WHERE metric_type = 'heart_rate' AND user_id = 1 +``` + +#### `get_health_summary(user_id, days)` +**When to use:** For quick health overview without writing SQL + +Ready-made summary of key health metrics over a specified period. + +**Example:** +```python +get_health_summary(user_id=1, days=30) +``` + +### ๐Ÿ“š Documentation Resource + +#### `health_data_guide()` +Complete guide to understanding and querying Garmin health data, including: +- Quick start workflow for new users +- Table descriptions with common query examples +- Available health metrics and their meanings +- Analysis tips and best practices + +## โš™๏ธ Configuration Examples + +### Production Configuration (Restrictive) +```bash +garmy-mcp server --database health.db \ + --max-rows 100 \ + --max-rows-absolute 500 + +# Alternative: via python -m +python -m garmy.mcp server --database health.db \ + --max-rows 100 \ + --max-rows-absolute 500 +``` + +### Development Configuration (Permissive with Logging) +```bash +garmy-mcp server --database health.db \ + --max-rows 2000 \ + --enable-query-logging \ + --verbose + +# Alternative: via python -m +python -m garmy.mcp server --database health.db \ + --max-rows 2000 \ + --enable-query-logging \ + --verbose +``` + +### Debug Configuration (Relaxed Validation) +```bash +garmy-mcp server --database health.db \ + --disable-strict-validation \ + --enable-query-logging \ + --verbose + +# Alternative: via python -m +python -m garmy.mcp server --database health.db \ + --disable-strict-validation \ + --enable-query-logging \ + --verbose +``` + +## ๐Ÿ” Security Features + +### Query Validation +1. **Statement Type Validation**: Only `SELECT` and `WITH` allowed +2. **Keyword Filtering**: Blocks modification keywords (`INSERT`, `UPDATE`, etc.) +3. **Multi-Statement Prevention**: Prevents SQL injection via statement chaining +4. **Parameter Binding**: All user inputs are properly parameterized +5. **Row Limiting**: Automatic limits prevent excessive resource usage + +### Database Access +- **Read-Only Connection**: Database opened in read-only mode +- **Input Sanitization**: Table names validated with regex patterns +- **Error Handling**: Comprehensive error catching and sanitization +- **Resource Management**: Automatic connection cleanup + +## ๐Ÿ“Š Health Data Analysis Examples + +### Sleep Analysis +```sql +-- Get sleep trends over the last month +SELECT + metric_date, + sleep_duration_hours, + deep_sleep_percentage, + rem_sleep_percentage +FROM daily_health_metrics +WHERE user_id = 1 + AND metric_date >= date('now', '-30 days') + AND sleep_duration_hours IS NOT NULL +ORDER BY metric_date; +``` + +### Activity Performance +```sql +-- Analyze workout intensity and heart rate +SELECT + activity_date, + activity_name, + duration_seconds / 60.0 as duration_minutes, + avg_heart_rate, + training_load +FROM activities +WHERE user_id = 1 + AND activity_date >= date('now', '-7 days') +ORDER BY activity_date DESC; +``` + +### Stress and Recovery Correlation +```sql +-- Correlate stress levels with sleep quality +SELECT + metric_date, + avg_stress_level, + sleep_duration_hours, + body_battery_high - body_battery_low as battery_drain, + training_readiness_score +FROM daily_health_metrics +WHERE user_id = 1 + AND metric_date >= date('now', '-14 days') + AND avg_stress_level IS NOT NULL +ORDER BY metric_date; +``` + +### Heart Rate Variability Trends +```sql +-- Track HRV patterns over time +SELECT + metric_date, + hrv_weekly_avg, + hrv_last_night_avg, + hrv_status, + resting_heart_rate +FROM daily_health_metrics +WHERE user_id = 1 + AND hrv_weekly_avg IS NOT NULL + AND metric_date >= date('now', '-60 days') +ORDER BY metric_date; +``` + +## ๐Ÿ”ง Advanced Configuration + +### Custom Configuration Class +```python +from garmy.mcp import MCPConfig, create_mcp_server +from pathlib import Path + +# Create custom configuration +config = MCPConfig( + db_path=Path("health.db"), + max_rows=500, + max_rows_absolute=2000, + enable_query_logging=True, + strict_validation=True +) + +# Create server with custom config +mcp_server = create_mcp_server(config) +``` + +### Environment Variables +```bash +# Alternative to --database argument +export GARMY_DB_PATH="/path/to/health.db" +garmy-mcp server --max-rows 500 +``` + +### Query Logging +When `--enable-query-logging` is enabled, you'll see detailed logs: + +``` +2024-06-30 12:00:00 - garmy.mcp.database - INFO - Executing query: SELECT * FROM daily_health_metrics LIMIT 1000 +2024-06-30 12:00:00 - garmy.mcp.database - INFO - Parameters: [1] +2024-06-30 12:00:00 - garmy.mcp.database - INFO - Query returned 245 rows +``` + +## ๐Ÿ› ๏ธ Troubleshooting + +### Common Issues + +1. **FastMCP Not Installed** + ```bash + pip install garmy[mcp] + # or + pip install fastmcp + ``` + +2. **Database Not Found** + ```bash + # Ensure database path is correct + garmy-mcp info --database health.db + + # Or set environment variable + export GARMY_DB_PATH="/full/path/to/health.db" + ``` + +3. **Permission Denied** + ```bash + # Check database file permissions + ls -la health.db + chmod 644 health.db # If needed + ``` + +4. **Query Validation Errors** + ```bash + # Use debug mode to see detailed errors + garmy-mcp server --database health.db --verbose --enable-query-logging + ``` + +### Debug Mode +```bash +# Enable maximum verbosity for troubleshooting +garmy-mcp server --database health.db \ + --verbose \ + --enable-query-logging \ + --disable-strict-validation +``` + +## ๐Ÿ”— Related Documentation + +- **[MCP Usage Example](mcp-example.md)** - Complete walkthrough from sync to AI analysis โญ +- **[Claude Desktop Integration](claude-desktop-integration.md)** - Detailed Claude setup +- **[Database Schema](database-schema.md)** - Understanding the data structure +- **[LocalDB Guide](localdb-guide.md)** - Setting up local data storage \ No newline at end of file diff --git a/docs/quick-start.md b/docs/quick-start.md new file mode 100644 index 0000000..92d688a --- /dev/null +++ b/docs/quick-start.md @@ -0,0 +1,78 @@ +# Quick Start Guide + +Get up and running with Garmy in minutes. + +## ๐Ÿš€ Installation + +### Standard Installation +```bash +pip install garmy +``` + +### With Optional Features +```bash +# For local database functionality +pip install garmy[localdb] + +# For MCP server functionality (AI assistants) +pip install garmy[mcp] + +# For everything +pip install garmy[all] +``` + +## ๐Ÿ”ง Basic Setup + +### 1. Basic API Usage + +```python +from garmy import AuthClient, APIClient + +# Create clients +auth_client = AuthClient() +api_client = APIClient(auth_client=auth_client) + +# Login +auth_client.login("your_email@garmin.com", "your_password") + +# Get today's training readiness +readiness = api_client.metrics.get('training_readiness').get() +print(f"Training Readiness Score: {readiness.score}/100") + +# Get sleep data for specific date +sleep_data = api_client.metrics.get('sleep').get('2023-12-01') +print(f"Sleep Score: {sleep_data.sleep_duration_hours}") +``` + +### 2. Local Database Setup + +```bash +# Sync recent health data to local database +garmy-sync sync --last-days 7 + +# Check sync status +garmy-sync status +``` + +### 3. AI Assistant Integration + +```bash +# Start MCP server for AI assistants +garmy-mcp server --database health.db + +# Show database info +garmy-mcp info --database health.db +``` + +## ๐Ÿ“– Next Steps + +- **[LocalDB Guide](localdb-guide.md)** - Set up local data storage +- **[MCP Server Guide](mcp-server-guide.md)** - Integrate with AI assistants +- **[Database Schema](database-schema.md)** - Understand your data +- **[Examples](../examples/)** - See more examples + +## ๐Ÿ†˜ Need Help? + +- Check the [examples directory](../examples/) for comprehensive usage examples +- Review the [database schema](database-schema.md) to understand available data +- See [Claude Desktop integration](claude-desktop-integration.md) for AI setup \ No newline at end of file diff --git a/examples/README.md b/examples/README.md index f7ca2a7..d5f3a8d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -53,6 +53,16 @@ This directory contains practical examples demonstrating how to use Garmy for ac python examples/sleep_phases_analysis.py ``` +8. **๐Ÿฅ Health Database System (NEW!)**: + ```bash + python examples/health_db_demo.py + ``` + +9. **๐Ÿ—„๏ธ Database Schema Architecture (NEW!)**: + ```bash + python examples/schema_demo.py + ``` + ## ๐Ÿ“ Example Files ### ๐Ÿ” `basic_auth.py` @@ -208,6 +218,60 @@ python examples/sleep_phases_analysis.py - Daily metrics compilation - Health trend analysis +### ๐Ÿฅ `health_db_demo.py` โญ **NEW!** +**Purpose**: Complete health database system demonstration + +**Features**: +- **Database synchronization** with normalized schema +- **Progress tracking** with multiple display options (Rich, TQDM, logging) +- **Health analytics** with sleep, activity, and wellness insights +- **Data export** capabilities (JSON, CSV) +- **Advanced SQL queries** for health correlations +- **Real-time progress** updates during sync + +**Usage**: +```bash +# Set your credentials +export GARMIN_EMAIL="your_email@example.com" +export GARMIN_PASSWORD="your_password" + +# Run the comprehensive demo +python examples/health_db_demo.py +``` + +**What it demonstrates**: +- ๐Ÿ“Š Different progress reporting styles +- ๐Ÿ’พ Normalized database storage for efficient queries +- ๐Ÿ“ˆ Health trends and correlations analysis +- ๐Ÿƒโ€โ™‚๏ธ Activity patterns and performance metrics +- ๐Ÿ˜ด Sleep quality analysis with phase breakdowns +- ๐Ÿ“ค Data export for external analysis +- ๐Ÿ” Advanced SQL queries for health insights + +### ๐Ÿ—„๏ธ `schema_demo.py` โญ **NEW!** +**Purpose**: Database schema architecture demonstration + +**Features**: +- **Clean schema separation** from database implementation logic +- **Centralized schema management** with version tracking +- **Schema validation** and introspection capabilities +- **Direct data extraction** using attribute access +- **Evolution support** for future schema changes +- **Self-documenting** schema with descriptions and metadata + +**Usage**: +```bash +python examples/schema_demo.py +``` + +**What it demonstrates**: +- ๐Ÿ—๏ธ Structured schema definition with TableDefinition classes +- ๐Ÿ“š Comprehensive documentation for each table and column +- ๐Ÿ” Runtime schema validation and introspection +- ๐Ÿ”ง Direct attribute extraction from API responses to database +- ๐Ÿš€ Foundation for schema migrations and evolution +- ๐Ÿงน Clean separation of concerns in database architecture + ## ๐Ÿ›  Usage Patterns ### Basic Authentication @@ -237,16 +301,12 @@ for day in week_data: print("Available metrics:", list(api_client.metrics.keys())) ``` -### Async Operations +### Multiple Metrics Access ```python -import asyncio - -async def get_multiple_metrics(): - # Fetch multiple metrics concurrently - sleep_task = api_client.metrics.get('sleep').get_async() - hrv_task = api_client.metrics.get('hrv').get_async() - - sleep_data, hrv_data = await asyncio.gather(sleep_task, hrv_task) +# Access multiple metrics +def get_multiple_metrics(): + sleep_data = api_client.metrics.get('sleep').get() + hrv_data = api_client.metrics.get('hrv').get() return sleep_data, hrv_data ``` @@ -282,7 +342,9 @@ async def get_multiple_metrics(): ### Getting Help -- Check the main documentation +- Check the [main documentation](../docs/README.md) +- Review [Quick Start Guide](../docs/quick-start.md) for setup help +- See [API Reference](../docs/api-reference.md) for detailed usage - Review error messages for specific guidance - Ensure your device supports the requested metric type diff --git a/examples/health_db_demo.py b/examples/health_db_demo.py new file mode 100644 index 0000000..3edab53 --- /dev/null +++ b/examples/health_db_demo.py @@ -0,0 +1,425 @@ +#!/usr/bin/env python3 +""" +Comprehensive demo of the Garmin Health Database system. + +This script demonstrates: +- Database synchronization with progress tracking +- Normalized health metrics storage +- Activity tracking and analytics +- Different progress reporting styles +- Data export capabilities +- Advanced SQL queries for health analysis + +Usage: + export GARMIN_EMAIL="your_email@example.com" + export GARMIN_PASSWORD="your_password" + python examples/health_db_demo.py +""" + +import asyncio +import json +import os +import sys +from datetime import date, timedelta +from pathlib import Path + +# Add project root to path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from src.garmy.localdb.sync import SyncManager +from src.garmy.localdb.config import LocalDBConfig +from src.garmy.localdb.progress import create_reporter, MultiReporter + + +class HealthDBDemo: + """Comprehensive demo of the health database system.""" + + def __init__(self): + self.db_path = Path("health_demo.db") + self.user_id = 1 + self.sync_manager = None + + async def run_complete_demo(self): + """Run the complete demonstration.""" + print("๐Ÿฅ Garmin Health Database System Demo") + print("=" * 50) + + # Get credentials + email = os.getenv('GARMIN_EMAIL') + password = os.getenv('GARMIN_PASSWORD') + + if not email or not password: + print("โŒ Please set GARMIN_EMAIL and GARMIN_PASSWORD environment variables") + return + + try: + await self._demo_progress_types() + await self._demo_sync_and_analytics() + await self._demo_data_export() + await self._demo_advanced_queries() + self._cleanup() + + except Exception as e: + print(f"โŒ Demo failed: {e}") + import traceback + traceback.print_exc() + + async def _demo_progress_types(self): + """Demo different progress reporting styles.""" + print("\n๐Ÿ“Š Progress Reporting Demo") + print("-" * 30) + + email = os.getenv('GARMIN_EMAIL') + password = os.getenv('GARMIN_PASSWORD') + + # Demo period (small for quick demo) + end_date = date.today() + start_date = end_date - timedelta(days=2) + + # 1. Rich progress (if available) + try: + print("๐ŸŽจ Rich Progress (beautiful terminal UI):") + rich_reporter = create_reporter("rich", name="Health Sync", show_stats_table=True) + + config = LocalDBConfig() + sync_manager = SyncManager( + db_path=Path("demo_rich.db"), + config=config, + progress_reporter=rich_reporter + ) + + await sync_manager.initialize(email, password) + await sync_manager.sync_range(self.user_id, start_date, end_date) + print("โœ… Rich demo completed\n") + + except ImportError: + print("โš ๏ธ Rich not available (install: pip install rich)\n") + + # 2. TQDM progress bar + try: + print("๐Ÿ“Š TQDM Progress Bar:") + tqdm_reporter = create_reporter("tqdm", name="Health Sync", show_details=True) + + config = LocalDBConfig() + sync_manager = SyncManager( + db_path=Path("demo_tqdm.db"), + config=config, + progress_reporter=tqdm_reporter + ) + + await sync_manager.initialize(email, password) + await sync_manager.sync_range(self.user_id, start_date, end_date) + print("โœ… TQDM demo completed\n") + + except ImportError: + print("โš ๏ธ TQDM not available (install: pip install tqdm)\n") + + # 3. Combined reporting + print("๐Ÿ”„ Combined Progress (Logging + JSON):") + multi_reporter = MultiReporter("Combined Sync") + multi_reporter.add_reporter(create_reporter("logging", name="Health Sync")) + multi_reporter.add_reporter(create_reporter("json", output_file="sync_report.json", real_time=False)) + + config = LocalDBConfig() + sync_manager = SyncManager( + db_path=Path("demo_combined.db"), + config=config, + progress_reporter=multi_reporter + ) + + await sync_manager.initialize(email, password) + await sync_manager.sync_range(self.user_id, start_date, end_date) + print("โœ… Combined demo completed (see sync_report.json)\n") + + async def _demo_sync_and_analytics(self): + """Demo main synchronization and analytics.""" + print("\n๐Ÿ’š Health Data Synchronization & Analytics") + print("-" * 45) + + # Clean start + if self.db_path.exists(): + self.db_path.unlink() + + # Setup with automatic progress selection + config = LocalDBConfig() + + try: + progress_reporter = create_reporter("rich", name="Health Analytics") + print("๐ŸŽจ Using Rich progress display") + except ImportError: + try: + progress_reporter = create_reporter("tqdm", name="Health Analytics") + print("๐Ÿ“Š Using TQDM progress display") + except ImportError: + progress_reporter = create_reporter("logging", name="Health Analytics") + print("๐Ÿ“ Using logging progress display") + + self.sync_manager = SyncManager( + db_path=self.db_path, + config=config, + progress_reporter=progress_reporter + ) + + # Initialize + email = os.getenv('GARMIN_EMAIL') + password = os.getenv('GARMIN_PASSWORD') + await self.sync_manager.initialize(email, password) + + # Sync recent data + end_date = date.today() + start_date = end_date - timedelta(days=7) + + print(f"\n๐Ÿ“… Syncing health data: {start_date} to {end_date}") + stats = await self.sync_manager.sync_range(self.user_id, start_date, end_date) + + print(f"\n๐Ÿ“Š Sync Results:") + print(f" โœ… Success: {stats['completed']}") + print(f" โญ๏ธ Skipped: {stats['skipped']}") + print(f" โŒ Failed: {stats['failed']}") + print(f" ๐Ÿ“ˆ Total: {stats['total_tasks']}") + + # Simple database statistics using direct SQL + with self.sync_manager.db.connection() as conn: + health_count = conn.execute("SELECT COUNT(*) FROM daily_health_metrics").fetchone()[0] + activities_count = conn.execute("SELECT COUNT(*) FROM activities").fetchone()[0] + timeseries_count = conn.execute("SELECT COUNT(*) FROM timeseries").fetchone()[0] + + print(f"\n๐Ÿ—๏ธ Database Statistics:") + print(f" ๐Ÿ“‹ Health metrics: {health_count}") + print(f" ๐Ÿƒโ€โ™‚๏ธ Activities: {activities_count}") + print(f" ๐Ÿ“Š Timeseries points: {timeseries_count}") + + # Show simple analytics using direct SQL + await self._show_simple_analytics(start_date, end_date) + + async def _show_simple_analytics(self, start_date: date, end_date: date): + """Show simple analytics using direct SQL queries.""" + print(f"\n๐Ÿ“Š Simple Analytics (Direct SQL)") + + with self.sync_manager.db.connection() as conn: + # Health trends + trends = conn.execute(""" + SELECT + AVG(total_steps) as avg_daily_steps, + AVG(resting_heart_rate) as avg_resting_hr, + AVG(sleep_duration_hours) as avg_sleep_hours, + COUNT(CASE WHEN total_steps > 10000 THEN 1 END) as days_over_10k_steps + FROM daily_health_metrics + WHERE user_id = ? AND metric_date BETWEEN ? AND ? + """, (self.user_id, start_date.isoformat(), end_date.isoformat())).fetchone() + + if trends and trends[0]: + print(f" ๐Ÿ‘Ÿ Average daily steps: {trends[0]:,.0f}") + print(f" โค๏ธ Average resting HR: {trends[1]:.0f} bpm" if trends[1] else " โค๏ธ No HR data") + print(f" ๐Ÿ˜ด Average sleep: {trends[2]:.1f} hours" if trends[2] else " ๐Ÿ˜ด No sleep data") + print(f" ๐ŸŽฏ Days >10k steps: {trends[3]}") + + # Activities summary + activities = conn.execute(""" + SELECT COUNT(*) as total_activities, COUNT(DISTINCT activity_name) as activity_types + FROM activities + WHERE user_id = ? AND activity_date BETWEEN ? AND ? + """, (self.user_id, start_date.isoformat(), end_date.isoformat())).fetchone() + + if activities and activities[0] > 0: + print(f"\n๐Ÿƒโ€โ™‚๏ธ Activities:") + print(f" ๐Ÿ“ˆ Total activities: {activities[0]}") + print(f" ๐ŸŽฏ Activity types: {activities[1]}") + else: + print(f"\n๐Ÿƒโ€โ™‚๏ธ No activities found in this period") + + async def _demo_data_export(self): + """Demo data export capabilities.""" + print(f"\n๐Ÿ“ค Data Export Demo") + print("-" * 20) + + if not self.sync_manager: + print("โš ๏ธ No sync manager available for export demo") + return + + end_date = date.today() + start_date = end_date - timedelta(days=7) + + # Export health metrics + health_data = self.sync_manager.query_health_metrics(self.user_id, start_date, end_date) + if health_data: + # Save to JSON + export_file = "health_export.json" + with open(export_file, 'w') as f: + json.dump(health_data, f, indent=2, default=str) + print(f"โœ… Health metrics exported to {export_file} ({len(health_data)} records)") + + # Export activities + activities = self.sync_manager.query_activities(self.user_id, start_date, end_date) + if activities: + activities_file = "activities_export.json" + with open(activities_file, 'w') as f: + json.dump(activities, f, indent=2, default=str) + print(f"โœ… Activities exported to {activities_file} ({len(activities)} records)") + + # Export timeseries (last day only) + if health_data: + from src.garmy.localdb.models import MetricType + from datetime import datetime + + last_date = datetime.strptime(health_data[-1]['metric_date'], '%Y-%m-%d').date() + start_time = datetime.combine(last_date, datetime.min.time()) + end_time = start_time + timedelta(days=1) + + hr_data = self.sync_manager.query_timeseries(self.user_id, MetricType.HEART_RATE, start_time, end_time) + if hr_data: + hr_file = "heart_rate_timeseries.json" + with open(hr_file, 'w') as f: + json.dump(hr_data, f, indent=2, default=str) + print(f"โœ… Heart rate timeseries exported to {hr_file} ({len(hr_data)} points)") + + async def _demo_advanced_queries(self): + """Demo advanced SQL queries.""" + print(f"\n๐Ÿ” Advanced Health Analytics") + print("-" * 35) + + if not self.sync_manager: + print("โš ๏ธ No sync manager available for queries demo") + return + + # Direct SQL queries for advanced analytics + with self.sync_manager.db.connection() as conn: + + # 1. Sleep quality vs training readiness correlation + print("๐Ÿ“Š Sleep Quality vs Training Readiness:") + correlation = conn.execute(""" + SELECT + CASE + WHEN sleep_duration_hours >= 8 THEN 'Good Sleep (8+ hrs)' + WHEN sleep_duration_hours >= 6 THEN 'Fair Sleep (6-8 hrs)' + ELSE 'Poor Sleep (<6 hrs)' + END as sleep_quality, + AVG(training_readiness_score) as avg_readiness, + COUNT(*) as days + FROM daily_health_metrics + WHERE user_id = ? AND sleep_duration_hours IS NOT NULL + AND training_readiness_score IS NOT NULL + GROUP BY 1 + ORDER BY avg_readiness DESC + """, (self.user_id,)).fetchall() + + for row in correlation: + print(f" {row[0]}: Readiness {row[1]:.0f}, {row[2]} days") + + # 2. Activity patterns by day of week + print(f"\n๐Ÿ“… Activity Patterns by Day of Week:") + weekly_pattern = conn.execute(""" + SELECT + CASE strftime('%w', activity_date) + WHEN '0' THEN 'Sunday' + WHEN '1' THEN 'Monday' + WHEN '2' THEN 'Tuesday' + WHEN '3' THEN 'Wednesday' + WHEN '4' THEN 'Thursday' + WHEN '5' THEN 'Friday' + WHEN '6' THEN 'Saturday' + END as day_of_week, + COUNT(*) as activities, + AVG(duration_seconds/60) as avg_duration_min + FROM activities + WHERE user_id = ? + GROUP BY strftime('%w', activity_date) + ORDER BY strftime('%w', activity_date) + """, (self.user_id,)).fetchall() + + for row in weekly_pattern: + print(f" {row[0]}: {row[1]} activities, {row[2]:.0f} min avg") + + # 3. Most active days + print(f"\n๐Ÿ† Most Active Days:") + active_days = conn.execute(""" + SELECT + metric_date, + total_steps, + (SELECT COUNT(*) FROM activities a + WHERE a.user_id = dhm.user_id AND a.activity_date = dhm.metric_date) as activities_count + FROM daily_health_metrics dhm + WHERE user_id = ? AND total_steps IS NOT NULL + ORDER BY total_steps DESC + LIMIT 5 + """, (self.user_id,)).fetchall() + + for row in active_days: + print(f" ๐Ÿ“… {row[0]}: {row[1]:,} steps, {row[2]} activities") + + # 4. Recovery analysis + print(f"\n๐Ÿ”‹ Recovery Analysis (Body Battery vs Stress):") + recovery = conn.execute(""" + SELECT + metric_date, + body_battery_high, + body_battery_low, + (body_battery_high - body_battery_low) as battery_recovery, + avg_stress_level + FROM daily_health_metrics + WHERE user_id = ? + AND body_battery_high IS NOT NULL + AND avg_stress_level IS NOT NULL + ORDER BY battery_recovery DESC + LIMIT 5 + """, (self.user_id,)).fetchall() + + for row in recovery: + print(f" ๐Ÿ“… {row[0]}: ๐Ÿ”‹ Recovery {row[3]}, ๐Ÿ˜ฐ Stress {row[4]}") + + def _cleanup(self): + """Clean up demo files.""" + print(f"\n๐Ÿงน Cleanup") + print("-" * 10) + + # Show file sizes + demo_files = [ + "health_demo.db", "demo_rich.db", "demo_tqdm.db", "demo_combined.db", + "health_export.json", "activities_export.json", "heart_rate_timeseries.json", + "sync_report.json" + ] + + print("๐Ÿ“ Generated files:") + total_size = 0 + for file_path in demo_files: + path = Path(file_path) + if path.exists(): + size_kb = path.stat().st_size / 1024 + total_size += size_kb + print(f" ๐Ÿ“„ {file_path}: {size_kb:.1f} KB") + + print(f" ๐Ÿ“Š Total size: {total_size:.1f} KB") + + # Option to clean up + response = input("\n๐Ÿ—‘๏ธ Delete demo files? (y/N): ").lower().strip() + if response == 'y': + for file_path in demo_files: + path = Path(file_path) + if path.exists(): + path.unlink() + print(f" โœ… Deleted {file_path}") + else: + print(" ๐Ÿ“‚ Demo files kept for inspection") + + +async def main(): + """Main demo function.""" + demo = HealthDBDemo() + await demo.run_complete_demo() + + print(f"\n๐ŸŽ‰ Demo completed!") + print(f"\n๐Ÿ’ก Next steps:") + print(f" โ€ข Explore the generated files") + print(f" โ€ข Check out other examples in the examples/ directory") + print(f" โ€ข Read PROGRESS_SYSTEM.md for progress customization") + print(f" โ€ข Integrate the health DB into your own projects") + + +if __name__ == "__main__": + print("๐Ÿฅ Garmin Health Database Demo") + print("๐Ÿ” Make sure GARMIN_EMAIL and GARMIN_PASSWORD are set") + print("๐Ÿ“ฆ Optional dependencies for better progress display:") + print(" pip install rich tqdm") + print() + + asyncio.run(main()) \ No newline at end of file diff --git a/examples/mcp_server_example.py b/examples/mcp_server_example.py new file mode 100644 index 0000000..88bf6b9 --- /dev/null +++ b/examples/mcp_server_example.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Example usage of the Garmin LocalDB MCP Server. + +This example demonstrates how to programmatically create and configure +the MCP server with custom settings. +""" + +import os +from pathlib import Path + +try: + from garmy.mcp import MCPConfig, create_mcp_server +except ImportError: + print("FastMCP not installed. Install with: pip install garmy[mcp]") + exit(1) + + +def main(): + """Demonstrate MCP server configuration and creation.""" + + # Example 1: Create config from database path + db_path = Path("health.db") + + # Check if database exists (for demo purposes) + if not db_path.exists(): + print(f"Database {db_path} not found. Please run garmy-sync first to create health data.") + print("Example: garmy-sync sync --last-days 7") + return + + # Create custom configuration + config = MCPConfig.from_db_path( + db_path=db_path, + max_rows=500, # Limit to 500 rows per query + enable_query_logging=True, # Enable query logging for debugging + strict_validation=True # Enable strict SQL validation + ) + + print("MCP Server Configuration:") + print(f" Database: {config.db_path}") + print(f" Max rows per query: {config.max_rows}") + print(f" Query logging: {config.enable_query_logging}") + print(f" Strict validation: {config.strict_validation}") + + # Validate configuration + try: + config.validate() + print("โœ… Configuration is valid") + except Exception as e: + print(f"โŒ Configuration error: {e}") + return + + # Create MCP server with custom config + print("\\nCreating MCP server...") + mcp_server = create_mcp_server(config) + + print(f"โœ… MCP server created: {mcp_server.name}") + print("\\nAvailable tools:") + print(" ๐Ÿ“Š explore_database_structure() - Start here to see available data") + print(" ๐Ÿ” get_table_details(table_name) - Get table structure and samples") + print(" ๐Ÿ“ˆ execute_sql_query(query, params) - Run custom SQL queries on any table") + print(" ๐Ÿ“‹ get_health_summary(user_id, days) - Quick health overview") + print("\\nAvailable resources:") + print(" ๐Ÿ“š health_data_guide() - Complete usage guide") + + print("\\n๐Ÿš€ To start the server, run:") + print(f" garmy-mcp server --database {db_path}") + print("\\n๐Ÿ“‹ With custom configuration:") + print(f" garmy-mcp server --database {db_path} --max-rows 500 --enable-query-logging") + print("\\n๐Ÿ”ง Or use environment variable:") + print(f" export GARMY_DB_PATH={db_path}") + print(" garmy-mcp server --max-rows 200 --verbose") + print("\\n๐Ÿ“Š Get database information:") + print(f" garmy-mcp info --database {db_path}") + print("\\n๐Ÿ“‹ Show configuration examples:") + print(" garmy-mcp config") + + # Example 2: Environment-based configuration (backwards compatibility) + print("\\n" + "="*50) + print("Environment-based configuration example:") + + os.environ['GARMY_DB_PATH'] = str(db_path) + env_server = create_mcp_server() # Uses environment variable + print(f"โœ… Environment-based server created: {env_server.name}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/schema_demo.py b/examples/schema_demo.py new file mode 100644 index 0000000..4bf350b --- /dev/null +++ b/examples/schema_demo.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +""" +Demo of the new database schema architecture. + +This script demonstrates: +- Clean separation of schema definition from database logic +- Schema validation and introspection +- Centralized schema management +- Easy schema evolution and migration planning +""" + +import sys +from pathlib import Path + +# Add project root to path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from src.garmy.localdb.schema import ( + HEALTH_DB_SCHEMA, + get_schema_info, + get_table_names, + SchemaVersion +) +from src.garmy.localdb.db import HealthDB + + +def demo_schema_info(): + """Demo schema introspection capabilities.""" + print("๐Ÿ—„๏ธ Database Schema Information") + print("=" * 50) + + schema_info = get_schema_info() + + print(f"๐Ÿ“Š Schema Version: {schema_info['version']}") + print(f"๐Ÿ“‹ Total Tables: {schema_info['total_tables']}") + print(f"๐Ÿ” Total Indexes: {schema_info['total_indexes']}") + print() + + print("๐Ÿ“ Tables:") + for table_name, info in schema_info['tables'].items(): + print(f" โ€ข {table_name}") + print(f" Description: {info['description']}") + print(f" Primary Key: {', '.join(info['primary_key'])}") + print(f" Indexes: {info['indexes_count']}") + print() + + +def demo_schema_definition(): + """Demo clean schema definition structure.""" + print("\n๐Ÿ—๏ธ Schema Definition Structure") + print("=" * 40) + + print(f"Schema contains {len(HEALTH_DB_SCHEMA.tables)} tables:") + + for table in HEALTH_DB_SCHEMA.tables: + print(f"\n๐Ÿ“‹ {table.name.upper()}") + print(f" Purpose: {table.description}") + print(f" Primary Key: [{', '.join(table.primary_key)}]") + print(f" Indexes: {len(table.indexes)} performance indexes") + + # Show table SQL (first few lines) + sql_lines = table.sql.strip().split('\n') + print(f" Schema Preview:") + for i, line in enumerate(sql_lines[:4]): + if line.strip(): + print(f" {line.strip()}") + if len(sql_lines) > 4: + print(" ...") + + +def demo_data_extraction(): + """Demo how sync process extracts data to database columns.""" + print("\n๐Ÿ”„ Data Extraction Process") + print("=" * 30) + + print("The sync process uses direct attribute access:") + print() + print("๐Ÿ“Š Example extraction logic:") + print(" API Response โ†’ Database Column") + print(" data.total_steps โ†’ total_steps") + print(" data.resting_heart_rate โ†’ resting_heart_rate") + print(" data.sleep_duration_hours โ†’ sleep_duration_hours") + print(" data.training_readiness.score โ†’ training_readiness_score") + print() + print("๐Ÿ”ง Implementation uses getattr() for safe extraction:") + print(" getattr(data, 'total_steps', None)") + print(" getattr(training_readiness, 'score', None)") + print() + print("โœ… No mapping table needed - direct attribute access!") + + +def demo_database_integration(): + """Demo how the schema integrates with the database.""" + print("\n๐Ÿ’พ Database Integration Demo") + print("=" * 35) + + # Create temporary database for demo + db_path = Path("schema_demo.db") + db = HealthDB(db_path) + + print("โœ… Database initialized with new schema architecture") + + # Validate schema + is_valid = db.validate_schema() + print(f"๐Ÿ” Schema validation: {'โœ… PASSED' if is_valid else 'โŒ FAILED'}") + + # Show schema info from database + db_schema_info = db.get_schema_info() + print(f"๐Ÿ“Š Schema version: {db_schema_info['version']}") + print(f"๐Ÿ“‹ Tables created: {db_schema_info['total_tables']}") + + print("\n๐Ÿ“ Expected vs Created Tables:") + expected_tables = set(get_table_names()) + print(f" Expected: {', '.join(sorted(expected_tables))}") + + # Check actual tables in database + with db.connection() as conn: + actual_tables = { + row[0] for row in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall() + } + print(f" Created: {', '.join(sorted(actual_tables))}") + + missing = expected_tables - actual_tables + extra = actual_tables - expected_tables + + if missing: + print(f" โŒ Missing: {', '.join(missing)}") + if extra: + print(f" โž• Extra: {', '.join(extra)}") + if not missing and not extra: + print(" โœ… Perfect match!") + + # Clean up demo database + if db_path.exists(): + db_path.unlink() + print(f"\n๐Ÿงน Cleaned up demo database: {db_path}") + + +def demo_benefits(): + """Demo the benefits of this architecture.""" + print("\n๐ŸŒŸ Benefits of Centralized Schema Management") + print("=" * 55) + + benefits = [ + "๐Ÿงน Clean separation: Schema definition is separate from database logic", + "๐Ÿ“š Documentation: Each table has clear description and purpose", + "๐Ÿ” Introspection: Easy to query schema info programmatically", + "๐Ÿš€ Evolution: Schema changes are centralized and trackable", + "๐Ÿ”ง Validation: Can validate database matches expected schema", + "๐Ÿ“Š Mapping: Clear mapping from API data to database columns", + "๐Ÿงช Testing: Easy to create test schemas and validate migrations", + "๐Ÿ—๏ธ Maintenance: Single source of truth for all schema changes" + ] + + for benefit in benefits: + print(f" {benefit}") + + +def main(): + """Run all schema demos.""" + print("๐Ÿ—„๏ธ Health Database Schema Architecture Demo") + print("=" * 60) + print("This demo shows the clean separation of schema definition") + print("from database implementation logic.\n") + + demo_schema_info() + demo_schema_definition() + demo_data_extraction() + demo_database_integration() + demo_benefits() + + print(f"\n๐ŸŽ‰ Schema Demo Complete!") + print(f"๐Ÿ’ก The schema is now:") + print(f" โ€ข Documented and well-structured") + print(f" โ€ข Separated from database implementation") + print(f" โ€ข Easy to evolve and maintain") + print(f" โ€ข Self-validating and introspectable") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 857de34..4cda6cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "garmy" version = "1.0.0" -description = "AI-powered Garmin Connect API library with MCP integration for health agents and data analysis" +description = "AI-powered Garmin Connect API library for health agents and data analysis" readme = "README.md" license = {text = "Apache-2.0"} authors = [ @@ -26,9 +26,12 @@ keywords = [ "lightweight", "modular", "garth", - "mcp", "ai-integration", - "type-safe" + "type-safe", + "mcp", + "model-context-protocol", + "localdb", + "sqlite" ] classifiers = [ "Development Status :: 4 - Beta", @@ -56,8 +59,16 @@ dependencies = [ [project.optional-dependencies] mcp = [ - "fastmcp>=1.0.0", - "click>=8.0.0" + "fastmcp>=0.4.0" +] +localdb = [ + "sqlalchemy>=1.4.0", + "tqdm>=4.0.0" +] +all = [ + "fastmcp>=0.4.0", + "sqlalchemy>=1.4.0", + "tqdm>=4.0.0" ] dev = [ "pytest>=7.0.0", @@ -89,14 +100,16 @@ examples = [ [project.urls] Homepage = "https://github.com/bes-dev/garmy" -Documentation = "https://github.com/bes-dev/garmy/blob/master/README.md" +Documentation = "https://github.com/bes-dev/garmy/blob/master/docs/" Repository = "https://github.com/bes-dev/garmy.git" "Bug Tracker" = "https://github.com/bes-dev/garmy/issues" Changelog = "https://github.com/bes-dev/garmy/blob/master/CHANGELOG.md" [project.scripts] +garmy-sync = "garmy.localdb.cli:main" garmy-mcp = "garmy.mcp.cli:main" + [tool.setuptools] package-dir = {"" = "src"} @@ -144,19 +157,11 @@ warn_unused_ignores = true warn_no_return = true warn_unreachable = true strict_equality = true -exclude = ["src/garmy/mcp/"] [[tool.mypy.overrides]] module = "requests_oauthlib.*" ignore_missing_imports = true -[[tool.mypy.overrides]] -module = "fastmcp.*" -ignore_missing_imports = true - -[[tool.mypy.overrides]] -module = "anyio.*" -ignore_missing_imports = true [tool.pytest.ini_options] testpaths = ["tests"] @@ -171,26 +176,17 @@ addopts = [ "--cov-report=term-missing", "--cov-report=html", "--cov-report=xml", - "--ignore=tests/test_mcp_analysis.py", - "--ignore=tests/test_mcp_auth.py", - "--ignore=tests/test_mcp_config.py", - "--ignore=tests/test_mcp_metrics.py", - "--ignore=tests/test_mcp_prompts.py", - "--ignore=tests/test_mcp_resources.py", - "--ignore=tests/test_mcp_server.py" ] markers = [ "integration: marks tests as integration tests (deselect with '-m \"not integration\"')", "slow: marks tests as slow (deselect with '-m \"not slow\"')", - "mcp: marks tests as MCP tests requiring fastmcp dependencies (deselect with '-m \"not mcp\"')" ] [tool.coverage.run] source = ["src/garmy"] omit = [ "*/tests/*", - "*/test_*", - "src/garmy/mcp/*" + "*/test_*" ] [tool.coverage.report] @@ -251,7 +247,6 @@ ignore = [ "tests/*" = ["T201", "PLR2004", "SIM117", "F841"] # Allow print statements, magic values, nested with, unused vars in tests "examples/*" = ["T201", "C901", "PLR0912"] # Allow print statements and complexity in examples "src/garmy/core/discovery.py" = ["T201"] # Allow prints in discovery module -"src/garmy/mcp/*" = ["T201"] # Allow print statements in MCP modules for debugging [tool.flake8] max-line-length = 100 diff --git a/src/garmy/localdb/__init__.py b/src/garmy/localdb/__init__.py new file mode 100644 index 0000000..22e00d5 --- /dev/null +++ b/src/garmy/localdb/__init__.py @@ -0,0 +1,8 @@ +"""Simple local database module for Garmin health metrics storage and synchronization.""" + +from .db import HealthDB +from .sync import SyncManager +from .models import MetricType +from .config import LocalDBConfig + +__all__ = ['HealthDB', 'SyncManager', 'MetricType', 'LocalDBConfig'] \ No newline at end of file diff --git a/src/garmy/localdb/__main__.py b/src/garmy/localdb/__main__.py new file mode 100644 index 0000000..7bd265e --- /dev/null +++ b/src/garmy/localdb/__main__.py @@ -0,0 +1,7 @@ +#!/usr/bin/env python3 +"""Entry point for running localdb as a module: python -m garmy.localdb""" + +from .cli import main + +if __name__ == '__main__': + exit(main()) \ No newline at end of file diff --git a/src/garmy/localdb/activities_iterator.py b/src/garmy/localdb/activities_iterator.py new file mode 100644 index 0000000..7f88a9c --- /dev/null +++ b/src/garmy/localdb/activities_iterator.py @@ -0,0 +1,148 @@ +"""Activity pagination and iteration utilities.""" + +from datetime import date +from typing import Any, List, Optional +import asyncio + + +class ActivitiesIterator: + """Iterator-based activities synchronization with automatic pagination.""" + + def __init__(self, api_client, sync_config, progress_reporter): + """Initialize activities iterator. + + Args: + api_client: Garmin API client for data access + sync_config: Sync configuration with batch sizes + progress_reporter: Progress reporting interface + """ + self.api_client = api_client + self.sync_config = sync_config + self.progress = progress_reporter + + # Iterator state + self.current_activity = None + self.current_activity_date = None + self.activities_cache = [] + self.batch_offset = 0 + self.has_more_data = True + + def initialize(self): + """Initialize the iterator by loading first batch.""" + self._load_next_batch() + self._advance_to_next_activity() + + def _load_next_batch(self) -> bool: + """Load next batch of activities from API.""" + if not self.has_more_data: + return False + + try: + batch_size = self.sync_config.activities_batch_size + activities_batch = self.api_client.metrics.get('activities').list( + limit=batch_size, + start=self.batch_offset + ) + + if not activities_batch or len(activities_batch) == 0: + self.has_more_data = False + return False + + # Append to cache and update offset + self.activities_cache.extend(activities_batch) + self.batch_offset += len(activities_batch) + + # Check if we got less than requested (indicates end of data) + if len(activities_batch) < batch_size: + self.has_more_data = False + + return True + + except Exception as e: + self.progress.warning(f"Failed to load activities batch at offset {self.batch_offset}: {e}") + self.has_more_data = False + return False + + def _advance_to_next_activity(self) -> bool: + """Advance to next activity, loading batches as needed.""" + while True: + # If cache is empty, try to load more + if not self.activities_cache: + if not self._load_next_batch(): + self.current_activity = None + self.current_activity_date = None + return False + + # Get next activity from cache + if self.activities_cache: + self.current_activity = self.activities_cache.pop(0) + self.current_activity_date = self._extract_activity_date(self.current_activity) + return True + else: + # No more activities available + self.current_activity = None + self.current_activity_date = None + return False + + def _extract_activity_date(self, activity) -> Optional[date]: + """Extract activity date from various possible fields.""" + start_time = None + + # Try different attribute names for start time + for attr in ['start_time_local', 'startTimeLocal', 'start_time', 'activityDate']: + if hasattr(activity, attr): + start_time = getattr(activity, attr) + break + + if start_time: + try: + # Handle ISO string format + if isinstance(start_time, str): + from datetime import datetime + start_time = start_time.replace('Z', '+00:00') + if '.' in start_time and '+' in start_time: + dt = datetime.fromisoformat(start_time) + else: + dt = datetime.fromisoformat(start_time) + return dt.date() + elif hasattr(start_time, 'date'): + return start_time.date() + except Exception: + pass + return None + + def get_activities_for_date(self, target_date: date) -> List[Any]: + """Get all activities for a specific date.""" + activities = [] + + # Ensure we have a current activity + if self.current_activity is None: + if not self._advance_to_next_activity(): + return activities + + # Process activities while they match or are newer than target_date + while self.current_activity is not None: + if self.current_activity_date is None: + # Skip activities without dates + if not self._advance_to_next_activity(): + break + continue + + if self.current_activity_date > target_date: + # Activity is newer than target - skip it + if not self._advance_to_next_activity(): + break + continue + + elif self.current_activity_date == target_date: + # Activity matches target date - collect it + activities.append(self.current_activity) + if not self._advance_to_next_activity(): + break + continue + + else: # self.current_activity_date < target_date + # Activity is older than target - we're done for this date + break + + return activities \ No newline at end of file diff --git a/src/garmy/localdb/cli.py b/src/garmy/localdb/cli.py new file mode 100644 index 0000000..185e692 --- /dev/null +++ b/src/garmy/localdb/cli.py @@ -0,0 +1,295 @@ +#!/usr/bin/env python3 +"""Command-line interface for Garmy LocalDB synchronization.""" + +import argparse +import getpass +import sys +from datetime import date, timedelta +from pathlib import Path +from typing import List, Optional + +from .sync import SyncManager +from .progress import ProgressReporter +from .models import MetricType +from .config import LocalDBConfig + + +def parse_date(date_str: str) -> date: + """Parse date string in YYYY-MM-DD format.""" + try: + return date.fromisoformat(date_str) + except ValueError: + raise argparse.ArgumentTypeError(f"Invalid date format: {date_str}. Use YYYY-MM-DD") + + +def parse_metrics(metrics_str: str) -> List[MetricType]: + """Parse comma-separated list of metrics.""" + if not metrics_str: + return list(MetricType) + + metric_names = [name.strip().upper() for name in metrics_str.split(',')] + metrics = [] + + for name in metric_names: + try: + metric = MetricType[name] + metrics.append(metric) + except KeyError: + available = ', '.join([m.name for m in MetricType]) + raise argparse.ArgumentTypeError( + f"Invalid metric: {name}. Available: {available}" + ) + + return metrics + + +def get_credentials() -> tuple[str, str]: + """Safely get Garmin credentials from user input.""" + print("Enter your Garmin Connect credentials:") + email = input("Email: ").strip() + + if not email: + print("Error: Email cannot be empty") + sys.exit(1) + + password = getpass.getpass("Password: ") + + if not password: + print("Error: Password cannot be empty") + sys.exit(1) + + return email, password + + +def cmd_sync(args) -> int: + """Execute sync command.""" + try: + # Determine date range + if args.last_days: + end_date = date.today() + start_date = end_date - timedelta(days=args.last_days - 1) + elif args.date_range: + start_date, end_date = args.date_range + else: + # Default: last 7 days + end_date = date.today() + start_date = end_date - timedelta(days=6) + + print(f"Syncing data from {start_date} to {end_date}") + + # Get credentials + email, password = get_credentials() + + # Setup progress reporter + progress_reporter = ProgressReporter(use_tqdm=args.progress == 'tqdm') + + # Initialize sync manager + config = LocalDBConfig() + manager = SyncManager( + db_path=args.db_path, + config=config, + progress_reporter=progress_reporter + ) + + # Initialize with credentials + print("Connecting to Garmin Connect...") + manager.initialize(email, password) + + # Parse metrics + metrics = parse_metrics(args.metrics) if args.metrics else list(MetricType) + + print(f"Syncing metrics: {', '.join([m.name for m in metrics])}") + + # Execute sync + stats = manager.sync_range( + user_id=args.user_id, + start_date=start_date, + end_date=end_date, + metrics=metrics + ) + + # Print results + print(f"\nSync completed!") + print(f" Completed: {stats['completed']}") + print(f" Skipped: {stats['skipped']}") + print(f" Failed: {stats['failed']}") + print(f" Total tasks: {stats['total_tasks']}") + + return 0 if stats['failed'] == 0 else 1 + + except KeyboardInterrupt: + print("\nSync interrupted by user") + return 130 + except Exception as e: + print(f"Error: {e}") + return 1 + + +def cmd_status(args) -> int: + """Show sync status.""" + try: + from .db import HealthDB + + db = HealthDB(args.db_path) + + # Show overall statistics + with db.get_session() as session: + from .models import SyncStatus + + # Count by status + status_counts = {} + from sqlalchemy import func + all_statuses = session.query(SyncStatus.status, + func.count(SyncStatus.status)).group_by(SyncStatus.status).all() + + for status, count in all_statuses: + status_counts[status] = count + + print("=== SYNC STATUS OVERVIEW ===") + for status in ['completed', 'pending', 'failed', 'skipped']: + count = status_counts.get(status, 0) + print(f"{status.capitalize()}: {count}") + + # Show failed records if any + if status_counts.get('failed', 0) > 0: + print(f"\n=== FAILED RECORDS ===") + failed_records = session.query(SyncStatus).filter( + SyncStatus.status == 'failed' + ).order_by(SyncStatus.sync_date.desc()).limit(10).all() + + for record in failed_records: + print(f"{record.sync_date} {record.metric_type}: {record.error_message}") + + # Show recent activity + print(f"\n=== RECENT SYNC ACTIVITY ===") + recent_records = session.query(SyncStatus).filter( + SyncStatus.synced_at.isnot(None) + ).order_by(SyncStatus.synced_at.desc()).limit(5).all() + + for record in recent_records: + print(f"{record.synced_at} {record.sync_date} {record.metric_type}: {record.status}") + + return 0 + + except Exception as e: + print(f"Error: {e}") + return 1 + + +def cmd_reset(args) -> int: + """Reset failed sync statuses to pending.""" + try: + from .db import HealthDB + + db = HealthDB(args.db_path) + + with db.get_session() as session: + from .models import SyncStatus + + # Count failed records + failed_count = session.query(SyncStatus).filter(SyncStatus.status == 'failed').count() + + if failed_count == 0: + print("No failed records found") + return 0 + + # Confirm reset + if not args.force: + response = input(f"Reset {failed_count} failed records to pending? (y/N): ") + if response.lower() != 'y': + print("Reset cancelled") + return 0 + + # Reset failed to pending + updated = session.query(SyncStatus).filter(SyncStatus.status == 'failed').update({ + 'status': 'pending', + 'error_message': None, + 'synced_at': None + }) + + session.commit() + print(f"Reset {updated} failed records to pending") + + return 0 + + except Exception as e: + print(f"Error: {e}") + return 1 + + +def create_parser() -> argparse.ArgumentParser: + """Create command-line argument parser.""" + parser = argparse.ArgumentParser( + description="Garmy LocalDB - Synchronize Garmin health data to local database", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + %(prog)s sync --last-days 7 # Sync last 7 days + %(prog)s sync --date-range 2024-01-01 2024-01-31 # Sync date range + %(prog)s sync --metrics DAILY_SUMMARY,SLEEP # Sync specific metrics + %(prog)s status # Show sync status + %(prog)s reset --force # Reset failed records + """ + ) + + # Global options + parser.add_argument('--db-path', type=Path, default=Path('health.db'), + help='Path to SQLite database file (default: health.db)') + parser.add_argument('--user-id', type=int, default=1, + help='User ID for database records (default: 1)') + + # Subcommands + subparsers = parser.add_subparsers(dest='command', help='Available commands') + + # Sync command + sync_parser = subparsers.add_parser('sync', help='Synchronize data from Garmin Connect') + + # Date range options (mutually exclusive) + date_group = sync_parser.add_mutually_exclusive_group() + date_group.add_argument('--last-days', type=int, metavar='N', + help='Sync data for last N days') + date_group.add_argument('--date-range', nargs=2, type=parse_date, + metavar=('START', 'END'), + help='Sync data between START and END dates (YYYY-MM-DD)') + + # Sync options + sync_parser.add_argument('--metrics', type=str, + help='Comma-separated list of metrics to sync (default: all)') + sync_parser.add_argument('--progress', choices=['tqdm', 'simple', 'silent'], + default='tqdm', + help='Progress display mode (default: tqdm)') + + # Status command + status_parser = subparsers.add_parser('status', help='Show synchronization status') + + # Reset command + reset_parser = subparsers.add_parser('reset', help='Reset failed sync records to pending') + reset_parser.add_argument('--force', action='store_true', + help='Reset without confirmation prompt') + + return parser + + +def main() -> int: + """Main CLI entry point.""" + parser = create_parser() + args = parser.parse_args() + + if not args.command: + parser.print_help() + return 1 + + # Execute command + if args.command == 'sync': + return cmd_sync(args) + elif args.command == 'status': + return cmd_status(args) + elif args.command == 'reset': + return cmd_reset(args) + else: + print(f"Unknown command: {args.command}") + return 1 + + +if __name__ == '__main__': + sys.exit(main()) \ No newline at end of file diff --git a/src/garmy/localdb/config.py b/src/garmy/localdb/config.py new file mode 100644 index 0000000..0f112ad --- /dev/null +++ b/src/garmy/localdb/config.py @@ -0,0 +1,52 @@ +"""Configuration for localdb module.""" + +from dataclasses import dataclass, field +from typing import Optional +from pathlib import Path + + +@dataclass +class SyncConfig: + """Sync operation configuration.""" + + # Retry settings + max_retries: int = 3 + retry_exponential_base: int = 2 + + # Rate limiting + rate_limit_delay: float = 0.5 + + # Progress reporting + progress_reporter: str = "logging" # logging, tqdm, rich, json, silent + progress_show_details: bool = True + progress_log_interval: int = 50 # For logging reporter + + # Activities API (handled by iterator) + activities_batch_size: int = 50 + + # Timeseries validation + min_timeseries_fields: int = 2 + + # Sync range limits + max_sync_days: int = 3650 # ~10 years maximum sync range + + +@dataclass +class DatabaseConfig: + """Database configuration.""" + + # Connection settings + timeout: float = 30.0 + enable_wal_mode: bool = True + + # Timestamp conversion + ms_per_second: int = 1000 + seconds_per_day: int = 24 * 60 * 60 + + +@dataclass +class LocalDBConfig: + """Complete localdb configuration.""" + + sync: SyncConfig = field(default_factory=SyncConfig) + database: DatabaseConfig = field(default_factory=DatabaseConfig) \ No newline at end of file diff --git a/src/garmy/localdb/db.py b/src/garmy/localdb/db.py new file mode 100644 index 0000000..46c3868 --- /dev/null +++ b/src/garmy/localdb/db.py @@ -0,0 +1,311 @@ +"""SQLAlchemy database for health metrics storage.""" + +from datetime import date +from pathlib import Path +from typing import List, Dict, Any, Optional, TYPE_CHECKING + +from sqlalchemy import create_engine, and_ +from sqlalchemy.orm import sessionmaker, Session + +from .models import Base, TimeSeries, Activity, DailyHealthMetric, SyncStatus, MetricType + +if TYPE_CHECKING: + from .config import DatabaseConfig +else: + DatabaseConfig = None + + +def _get_default_config() -> 'DatabaseConfig': + """Get default database configuration.""" + if DatabaseConfig is None: + from .config import DatabaseConfig as _DatabaseConfig + return _DatabaseConfig() + return DatabaseConfig() + + +class HealthDB: + """SQLAlchemy database for health metrics.""" + + def __init__(self, + db_path: Path = Path("health.db"), + config: Optional['DatabaseConfig'] = None): + """Initialize database. + + Args: + db_path: Path to SQLite database file. + config: Database configuration. + """ + self.db_path = db_path + self.config = config if config is not None else _get_default_config() + + self.engine = create_engine(f"sqlite:///{db_path}") + self.SessionLocal = sessionmaker(bind=self.engine) + + Base.metadata.create_all(self.engine) + + def get_session(self) -> Session: + """Get database session.""" + return self.SessionLocal() + + def get_schema_info(self) -> Dict[str, Any]: + """Get database schema information.""" + return { + "tables": [table.name for table in Base.metadata.tables.values()], + "db_path": str(self.db_path) + } + + def validate_schema(self) -> bool: + """Validate database schema.""" + try: + expected_tables = {'timeseries', 'activities', 'daily_health_metrics', 'sync_status'} + actual_tables = set(Base.metadata.tables.keys()) + return expected_tables.issubset(actual_tables) + except Exception: + return False + + def store_timeseries_batch(self, user_id: int, metric_type: MetricType, data: List[tuple]): + """Store batch of timeseries data.""" + with self.get_session() as session: + for timestamp, value, metadata in data: + timeseries = TimeSeries( + user_id=user_id, + metric_type=metric_type.value, + timestamp=timestamp, + value=value, + meta_data=metadata + ) + session.merge(timeseries) + session.commit() + + def store_activity(self, user_id: int, activity_data: Dict[str, Any]): + """Store activity data.""" + with self.get_session() as session: + activity = Activity( + user_id=user_id, + activity_id=activity_data['activity_id'], + activity_date=activity_data['activity_date'], + activity_name=activity_data.get('activity_name'), + duration_seconds=activity_data.get('duration_seconds'), + avg_heart_rate=activity_data.get('avg_heart_rate'), + training_load=activity_data.get('training_load'), + start_time=activity_data.get('start_time') + ) + session.merge(activity) + session.commit() + + def store_health_metric(self, user_id: int, metric_date: date, **kwargs): + """Store daily health metric data.""" + with self.get_session() as session: + # Get existing record or create new one + metric = session.query(DailyHealthMetric).filter( + and_( + DailyHealthMetric.user_id == user_id, + DailyHealthMetric.metric_date == metric_date + ) + ).first() + + if metric is None: + metric = DailyHealthMetric(user_id=user_id, metric_date=metric_date) + + # Update fields from kwargs + for field, value in kwargs.items(): + if hasattr(metric, field): + setattr(metric, field, value) + + session.merge(metric) + session.commit() + + + def create_sync_status(self, user_id: int, sync_date: date, metric_type: MetricType, status: str = 'pending'): + """Create sync status record.""" + with self.get_session() as session: + sync_status = SyncStatus( + user_id=user_id, + sync_date=sync_date, + metric_type=metric_type.value, + status=status + ) + session.merge(sync_status) + session.commit() + + def update_sync_status(self, user_id: int, sync_date: date, metric_type: MetricType, + status: str, error_message: Optional[str] = None): + """Update sync status record.""" + with self.get_session() as session: + from datetime import datetime + sync_status = session.query(SyncStatus).filter( + and_( + SyncStatus.user_id == user_id, + SyncStatus.sync_date == sync_date, + SyncStatus.metric_type == metric_type.value + ) + ).first() + + if sync_status: + sync_status.status = status + sync_status.synced_at = datetime.utcnow() + if error_message: + sync_status.error_message = error_message + session.commit() + + def get_sync_status(self, user_id: int, sync_date: date, metric_type: MetricType) -> Optional[str]: + """Get sync status for specific metric.""" + with self.get_session() as session: + sync_status = session.query(SyncStatus).filter( + and_( + SyncStatus.user_id == user_id, + SyncStatus.sync_date == sync_date, + SyncStatus.metric_type == metric_type.value + ) + ).first() + return sync_status.status if sync_status else None + + def get_pending_metrics(self, user_id: int, sync_date: date) -> List[str]: + """Get list of pending metrics for date.""" + with self.get_session() as session: + pending_statuses = session.query(SyncStatus).filter( + and_( + SyncStatus.user_id == user_id, + SyncStatus.sync_date == sync_date, + SyncStatus.status == 'pending' + ) + ).all() + return [status.metric_type for status in pending_statuses] + + def sync_status_exists(self, user_id: int, sync_date: date, metric_type: MetricType) -> bool: + """Check if sync status record exists.""" + with self.get_session() as session: + return session.query(SyncStatus).filter( + and_( + SyncStatus.user_id == user_id, + SyncStatus.sync_date == sync_date, + SyncStatus.metric_type == metric_type.value + ) + ).first() is not None + + + def activity_exists(self, user_id: int, activity_id: str) -> bool: + """Check if activity exists.""" + with self.get_session() as session: + return session.query(Activity).filter( + and_( + Activity.user_id == user_id, + Activity.activity_id == activity_id + ) + ).first() is not None + + def health_metric_exists(self, user_id: int, metric_date: date) -> bool: + """Check if health metric exists for date.""" + with self.get_session() as session: + return session.query(DailyHealthMetric).filter( + and_( + DailyHealthMetric.user_id == user_id, + DailyHealthMetric.metric_date == metric_date + ) + ).first() is not None + + + def get_health_metrics(self, user_id: int, start_date: date, end_date: date) -> List[Dict[str, Any]]: + """Query health metrics for date range.""" + with self.get_session() as session: + metrics = session.query(DailyHealthMetric).filter( + and_( + DailyHealthMetric.user_id == user_id, + DailyHealthMetric.metric_date >= start_date, + DailyHealthMetric.metric_date <= end_date + ) + ).order_by(DailyHealthMetric.metric_date).all() + + return [self._metric_to_dict(metric) for metric in metrics] + + def get_activities(self, user_id: int, start_date: date, end_date: date, + activity_name: Optional[str] = None) -> List[Dict[str, Any]]: + """Query activities for date range.""" + with self.get_session() as session: + query = session.query(Activity).filter( + and_( + Activity.user_id == user_id, + Activity.activity_date >= start_date, + Activity.activity_date <= end_date + ) + ) + + if activity_name: + query = query.filter(Activity.activity_name == activity_name) + + activities = query.order_by(Activity.activity_date).all() + return [self._activity_to_dict(activity) for activity in activities] + + def get_timeseries(self, user_id: int, metric_type: MetricType, + start_timestamp: int, end_timestamp: int) -> List[tuple]: + """Query timeseries data for time range.""" + with self.get_session() as session: + timeseries = session.query(TimeSeries).filter( + and_( + TimeSeries.user_id == user_id, + TimeSeries.metric_type == metric_type.value, + TimeSeries.timestamp >= start_timestamp, + TimeSeries.timestamp <= end_timestamp + ) + ).order_by(TimeSeries.timestamp).all() + + return [(ts.timestamp, ts.value, ts.meta_data) for ts in timeseries] + + + def _metric_to_dict(self, metric: DailyHealthMetric) -> Dict[str, Any]: + """Convert DailyHealthMetric to dictionary.""" + return { + 'user_id': metric.user_id, + 'metric_date': metric.metric_date, + 'total_steps': metric.total_steps, + 'step_goal': metric.step_goal, + 'total_distance_meters': metric.total_distance_meters, + 'total_calories': metric.total_calories, + 'active_calories': metric.active_calories, + 'bmr_calories': metric.bmr_calories, + 'resting_heart_rate': metric.resting_heart_rate, + 'max_heart_rate': metric.max_heart_rate, + 'min_heart_rate': metric.min_heart_rate, + 'average_heart_rate': metric.average_heart_rate, + 'avg_stress_level': metric.avg_stress_level, + 'max_stress_level': metric.max_stress_level, + 'body_battery_high': metric.body_battery_high, + 'body_battery_low': metric.body_battery_low, + 'sleep_duration_hours': metric.sleep_duration_hours, + 'deep_sleep_hours': metric.deep_sleep_hours, + 'light_sleep_hours': metric.light_sleep_hours, + 'rem_sleep_hours': metric.rem_sleep_hours, + 'awake_hours': metric.awake_hours, + 'deep_sleep_percentage': metric.deep_sleep_percentage, + 'light_sleep_percentage': metric.light_sleep_percentage, + 'rem_sleep_percentage': metric.rem_sleep_percentage, + 'awake_percentage': metric.awake_percentage, + 'average_spo2': metric.average_spo2, + 'average_respiration': metric.average_respiration, + 'training_readiness_score': metric.training_readiness_score, + 'training_readiness_level': metric.training_readiness_level, + 'training_readiness_feedback': metric.training_readiness_feedback, + 'hrv_weekly_avg': metric.hrv_weekly_avg, + 'hrv_last_night_avg': metric.hrv_last_night_avg, + 'hrv_status': metric.hrv_status, + 'avg_waking_respiration_value': metric.avg_waking_respiration_value, + 'avg_sleep_respiration_value': metric.avg_sleep_respiration_value, + 'lowest_respiration_value': metric.lowest_respiration_value, + 'highest_respiration_value': metric.highest_respiration_value, + 'created_at': metric.created_at, + 'updated_at': metric.updated_at + } + + def _activity_to_dict(self, activity: Activity) -> Dict[str, Any]: + """Convert Activity to dictionary.""" + return { + 'user_id': activity.user_id, + 'activity_id': activity.activity_id, + 'activity_date': activity.activity_date, + 'activity_name': activity.activity_name, + 'duration_seconds': activity.duration_seconds, + 'avg_heart_rate': activity.avg_heart_rate, + 'training_load': activity.training_load, + 'start_time': activity.start_time, + 'created_at': activity.created_at + } \ No newline at end of file diff --git a/src/garmy/localdb/extractors.py b/src/garmy/localdb/extractors.py new file mode 100644 index 0000000..c867863 --- /dev/null +++ b/src/garmy/localdb/extractors.py @@ -0,0 +1,234 @@ +"""Data extraction utilities for converting API responses to database format.""" + +from datetime import date +from typing import Any, Dict, List, Optional, Tuple +from .models import MetricType + + +class DataExtractor: + """Extracts and normalizes data from API responses for database storage.""" + + def extract_metric_data(self, data: Any, metric_type: MetricType) -> Optional[Dict]: + """Extract data based on metric type.""" + if metric_type == MetricType.DAILY_SUMMARY: + return self._extract_daily_summary_data(data) + elif metric_type == MetricType.SLEEP: + return self._extract_sleep_data(data) + elif metric_type == MetricType.TRAINING_READINESS: + return self._extract_training_readiness_data(data) + elif metric_type == MetricType.HRV: + return self._extract_hrv_data(data) + elif metric_type == MetricType.RESPIRATION: + return self._extract_respiration_summary(data) + elif metric_type == MetricType.ACTIVITIES: + return self._extract_activity_data(data) + elif metric_type == MetricType.STEPS: + return self._extract_steps_data(data) + elif metric_type == MetricType.CALORIES: + return self._extract_calories_data(data) + elif metric_type == MetricType.HEART_RATE: + return self._extract_heart_rate_summary(data) + elif metric_type == MetricType.STRESS: + return self._extract_stress_summary(data) + elif metric_type == MetricType.BODY_BATTERY: + return self._extract_body_battery_summary(data) + else: + return None + + def _extract_daily_summary_data(self, data: Any) -> Dict[str, Any]: + """Extract daily summary data.""" + return { + # Steps and movement + 'total_steps': getattr(data, 'total_steps', None), + 'step_goal': getattr(data, 'daily_step_goal', None), # Correct attribute name! + 'total_distance_meters': getattr(data, 'total_distance_meters', None), + + # Calories + 'total_calories': getattr(data, 'total_kilocalories', None), + 'active_calories': getattr(data, 'active_kilocalories', None), + 'bmr_calories': getattr(data, 'bmr_kilocalories', None), + + # Heart rate + 'resting_heart_rate': getattr(data, 'resting_heart_rate', None), + 'max_heart_rate': getattr(data, 'max_heart_rate', None), + 'min_heart_rate': getattr(data, 'min_heart_rate', None), + 'average_heart_rate': getattr(data, 'average_heart_rate', None), + + # Stress and recovery + 'avg_stress_level': getattr(data, 'avg_stress_level', None) or getattr(data, 'stress_avg', None), + 'max_stress_level': getattr(data, 'max_stress_level', None) or getattr(data, 'stress_max', None), + 'body_battery_high': getattr(data, 'body_battery_highest_value', None), + 'body_battery_low': getattr(data, 'body_battery_lowest_value', None), + + # Additional metrics that might be in daily summary + 'average_spo2': getattr(data, 'average_sp_o2_value', None), + 'average_respiration': getattr(data, 'average_respiration_value', None) + } + + def _extract_sleep_data(self, data: Any) -> Dict[str, Any]: + """Extract sleep data from Sleep object - use the properties, stupid!""" + return { + # Use the built-in properties from Sleep class + 'sleep_duration_hours': getattr(data, 'sleep_duration_hours', None), + 'deep_sleep_percentage': getattr(data, 'deep_sleep_percentage', None), + 'light_sleep_percentage': getattr(data, 'light_sleep_percentage', None), + 'rem_sleep_percentage': getattr(data, 'rem_sleep_percentage', None), + 'awake_percentage': getattr(data, 'awake_percentage', None), + + # Calculate hours from the summary if available + 'deep_sleep_hours': getattr(data.sleep_summary, 'deep_sleep_seconds', 0) / 3600 if hasattr(data, 'sleep_summary') and data.sleep_summary and getattr(data.sleep_summary, 'deep_sleep_seconds', 0) > 0 else None, + 'light_sleep_hours': getattr(data.sleep_summary, 'light_sleep_seconds', 0) / 3600 if hasattr(data, 'sleep_summary') and data.sleep_summary and getattr(data.sleep_summary, 'light_sleep_seconds', 0) > 0 else None, + 'rem_sleep_hours': getattr(data.sleep_summary, 'rem_sleep_seconds', 0) / 3600 if hasattr(data, 'sleep_summary') and data.sleep_summary and getattr(data.sleep_summary, 'rem_sleep_seconds', 0) > 0 else None, + 'awake_hours': getattr(data.sleep_summary, 'awake_sleep_seconds', 0) / 3600 if hasattr(data, 'sleep_summary') and data.sleep_summary and getattr(data.sleep_summary, 'awake_sleep_seconds', 0) > 0 else None, + + # Physiological data from summary + 'average_spo2': getattr(data.sleep_summary, 'average_sp_o2_value', None) if hasattr(data, 'sleep_summary') and data.sleep_summary else None, + 'average_respiration': getattr(data.sleep_summary, 'average_respiration_value', None) if hasattr(data, 'sleep_summary') and data.sleep_summary else None + } + + def _extract_heart_rate_summary(self, data: Any) -> Dict[str, Any]: + """Extract heart rate summary data.""" + # Heart rate data is in heart_rate_summary nested object + summary = getattr(data, 'heart_rate_summary', data) + + return { + 'resting_heart_rate': getattr(summary, 'resting_heart_rate', None), + 'max_heart_rate': getattr(summary, 'max_heart_rate', None), + 'min_heart_rate': getattr(summary, 'min_heart_rate', None), + 'average_heart_rate': getattr(data, 'average_heart_rate', None) # This is on main object + } + + def _extract_stress_summary(self, data: Any) -> Dict[str, Any]: + """Extract stress summary data.""" + return { + 'avg_stress_level': getattr(data, 'avg_stress_level', None) or getattr(data, 'stress_avg', None), + 'max_stress_level': getattr(data, 'max_stress_level', None) or getattr(data, 'stress_max', None) + } + + def _extract_body_battery_summary(self, data: Any) -> Dict[str, Any]: + """Extract body battery summary data.""" + return { + 'body_battery_high': getattr(data, 'body_battery_highest_value', None) or getattr(data, 'highest_value', None), + 'body_battery_low': getattr(data, 'body_battery_lowest_value', None) or getattr(data, 'lowest_value', None) + } + + def _extract_training_readiness_data(self, data: Any) -> Dict[str, Any]: + """Extract training readiness nested data.""" + return { + 'score': getattr(data, 'score', None), + 'level': getattr(data, 'level', None), + 'feedback': getattr(data, 'feedback_short', None) + } + + def _extract_hrv_data(self, data: Any) -> Dict[str, Any]: + """Extract HRV using nested summary.""" + hrv_summary = getattr(data, 'hrv_summary', None) + if hrv_summary: + return { + 'weekly_avg': getattr(hrv_summary, 'weekly_avg', None), + 'last_night_avg': getattr(hrv_summary, 'last_night_avg', None), + 'status': getattr(hrv_summary, 'status', None) + } + return {} + + def _extract_respiration_summary(self, data: Any) -> Dict[str, Any]: + """Extract respiration summary - unique respiratory metrics.""" + # Try different possible locations for respiration data + summary = getattr(data, 'respiration_summary', None) + if summary: + return { + 'average_respiration': getattr(summary, 'average_respiration_value', None), + 'avg_waking_respiration_value': getattr(summary, 'avg_waking_respiration_value', None), + 'avg_sleep_respiration_value': getattr(summary, 'avg_sleep_respiration_value', None), + 'lowest_respiration_value': getattr(summary, 'lowest_respiration_value', None), + 'highest_respiration_value': getattr(summary, 'highest_respiration_value', None) + } + + # Also try direct attributes + result = { + 'average_respiration': getattr(data, 'average_respiration_value', None), + 'avg_waking_respiration_value': getattr(data, 'avg_waking_respiration_value', None), + 'avg_sleep_respiration_value': getattr(data, 'avg_sleep_respiration_value', None), + 'lowest_respiration_value': getattr(data, 'lowest_respiration_value', None), + 'highest_respiration_value': getattr(data, 'highest_respiration_value', None) + } + + # Return only if we have any data + if any(v is not None for v in result.values()): + return result + + return {} + + def _extract_activity_data(self, data: Any) -> Dict[str, Any]: + """Extract activity data from both parsed and raw formats.""" + # Handle both object attributes and dict keys + def get_value(obj, *keys): + for key in keys: + if hasattr(obj, key): + return getattr(obj, key, None) + elif isinstance(obj, dict) and key in obj: + return obj[key] + return None + + activity_id = get_value(data, 'activity_id', 'activityId') + if activity_id: + return { + 'activity_id': activity_id, + 'activity_name': get_value(data, 'activity_name', 'activityName', 'activityTypeName'), + 'duration_seconds': get_value(data, 'duration', 'movingDuration', 'elapsedDuration'), + 'avg_heart_rate': get_value(data, 'average_hr', 'averageHR', 'avgHR'), + 'training_load': get_value(data, 'activity_training_load', 'trainingLoad'), + 'start_time': get_value(data, 'start_time_local', 'startTimeLocal', 'start_time') + } + return {} + + def extract_timeseries_data(self, data: Any, metric_type: MetricType) -> List[Tuple]: + """Extract timeseries data points from Garmy metrics.""" + timeseries_data = [] + + if metric_type == MetricType.BODY_BATTERY: + if hasattr(data, 'body_battery_readings') and data.body_battery_readings: + for reading in data.body_battery_readings: + metadata = { + 'status': getattr(reading, 'status', None), + 'version': getattr(reading, 'version', None) + } + timeseries_data.append((reading.timestamp, reading.level, metadata)) + + elif metric_type == MetricType.STRESS: + if hasattr(data, 'stress_readings') and data.stress_readings: + for reading in data.stress_readings: + metadata = {} + if hasattr(reading, 'stress_category'): + metadata['stress_category'] = reading.stress_category + timeseries_data.append((reading.timestamp, reading.stress_level, metadata)) + + elif metric_type == MetricType.HEART_RATE: + if hasattr(data, 'heart_rate_values_array') and data.heart_rate_values_array: + for reading in data.heart_rate_values_array: + if isinstance(reading, (list, tuple)) and len(reading) >= 2: + timestamp, heart_rate = reading[0], reading[1] + timeseries_data.append((timestamp, heart_rate, {})) + + elif metric_type == MetricType.RESPIRATION: + # Respiration might have different format - check if it has readings + if hasattr(data, 'respiration_readings') and data.respiration_readings: + for reading in data.respiration_readings: + timeseries_data.append((reading.timestamp, reading.value, {})) + + return timeseries_data + + def _extract_steps_data(self, data: Any) -> Dict[str, Any]: + """Extract steps data.""" + return { + 'total_steps': getattr(data, 'total_steps', None), + 'step_goal': getattr(data, 'step_goal', None) + } + + def _extract_calories_data(self, data: Any) -> Dict[str, Any]: + """Extract calories data.""" + return { + 'total_calories': getattr(data, 'total_kilocalories', None), + 'active_calories': getattr(data, 'active_kilocalories', None), + 'bmr_calories': getattr(data, 'bmr_kilocalories', None) + } \ No newline at end of file diff --git a/src/garmy/localdb/models.py b/src/garmy/localdb/models.py new file mode 100644 index 0000000..cb510bc --- /dev/null +++ b/src/garmy/localdb/models.py @@ -0,0 +1,121 @@ +"""SQLAlchemy models and enums for health database.""" + +from datetime import date, datetime +from enum import Enum + +from sqlalchemy import Column, Integer, String, Float, Date, DateTime, JSON, Text +from sqlalchemy.orm import declarative_base + + +Base = declarative_base() + + +class MetricType(Enum): + """Health metric types that can be stored in the database.""" + DAILY_SUMMARY = "daily_summary" + SLEEP = "sleep" + ACTIVITIES = "activities" + BODY_BATTERY = "body_battery" + STRESS = "stress" + HEART_RATE = "heart_rate" + TRAINING_READINESS = "training_readiness" + HRV = "hrv" + RESPIRATION = "respiration" + STEPS = "steps" + CALORIES = "calories" + + +class TimeSeries(Base): + """High-frequency timeseries data (heart rate, stress, body battery, etc.).""" + __tablename__ = "timeseries" + + user_id = Column(Integer, primary_key=True, nullable=False) + metric_type = Column(String, primary_key=True, nullable=False) + timestamp = Column(Integer, primary_key=True, nullable=False) + value = Column(Float, nullable=False) + meta_data = Column(JSON) + + +class Activity(Base): + """Individual activities and workouts with key metrics.""" + __tablename__ = "activities" + + user_id = Column(Integer, primary_key=True, nullable=False) + activity_id = Column(String, primary_key=True, nullable=False) + activity_date = Column(Date, nullable=False) + activity_name = Column(String) + duration_seconds = Column(Integer) + avg_heart_rate = Column(Integer) + training_load = Column(Float) + start_time = Column(String) + created_at = Column(DateTime, default=datetime.utcnow) + + +class DailyHealthMetric(Base): + """Normalized daily health metrics with dedicated columns for efficient querying.""" + __tablename__ = "daily_health_metrics" + + user_id = Column(Integer, primary_key=True, nullable=False) + metric_date = Column(Date, primary_key=True, nullable=False) + + total_steps = Column(Integer) + step_goal = Column(Integer) + total_distance_meters = Column(Float) + + total_calories = Column(Integer) + active_calories = Column(Integer) + bmr_calories = Column(Integer) + + resting_heart_rate = Column(Integer) + max_heart_rate = Column(Integer) + min_heart_rate = Column(Integer) + average_heart_rate = Column(Integer) + + avg_stress_level = Column(Integer) + max_stress_level = Column(Integer) + + body_battery_high = Column(Integer) + body_battery_low = Column(Integer) + + sleep_duration_hours = Column(Float) + deep_sleep_hours = Column(Float) + light_sleep_hours = Column(Float) + rem_sleep_hours = Column(Float) + awake_hours = Column(Float) + + deep_sleep_percentage = Column(Float) + light_sleep_percentage = Column(Float) + rem_sleep_percentage = Column(Float) + awake_percentage = Column(Float) + + average_spo2 = Column(Float) + average_respiration = Column(Float) + + training_readiness_score = Column(Integer) + training_readiness_level = Column(Text) + training_readiness_feedback = Column(Text) + + hrv_weekly_avg = Column(Float) + hrv_last_night_avg = Column(Float) + hrv_status = Column(Text) + + avg_waking_respiration_value = Column(Float) + avg_sleep_respiration_value = Column(Float) + lowest_respiration_value = Column(Float) + highest_respiration_value = Column(Float) + + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + +class SyncStatus(Base): + """Sync status tracking for each metric per date.""" + __tablename__ = "sync_status" + + user_id = Column(Integer, primary_key=True, nullable=False) + sync_date = Column(Date, primary_key=True, nullable=False) + metric_type = Column(String, primary_key=True, nullable=False) + status = Column(String, nullable=False) + synced_at = Column(DateTime) + error_message = Column(Text) + created_at = Column(DateTime, default=datetime.utcnow) \ No newline at end of file diff --git a/src/garmy/localdb/progress.py b/src/garmy/localdb/progress.py new file mode 100644 index 0000000..208530c --- /dev/null +++ b/src/garmy/localdb/progress.py @@ -0,0 +1,67 @@ +"""Progress reporting for sync operations.""" + +import logging +from datetime import date +from typing import Optional +from tqdm import tqdm + + +class ProgressReporter: + """Simple progress reporter with date tracking.""" + + def __init__(self, use_tqdm: bool = False): + self.use_tqdm = use_tqdm + self.logger = logging.getLogger("garmy.sync") + self.pbar: Optional[tqdm] = None + self.current_date = None + + def start_sync(self, total: int): + """Start sync progress tracking.""" + if self.use_tqdm: + self.pbar = tqdm(total=total) + + def task_complete(self, task: str, sync_date: date): + """Mark task as completed.""" + msg = f"[{sync_date}] {task}" + if self.pbar: + self.pbar.update(1) + if self.current_date != sync_date: + self.current_date = sync_date + self.pbar.set_description(f"Syncing {sync_date}") + else: + self.logger.info(msg) + + def task_skipped(self, task: str, sync_date: date): + """Mark task as skipped.""" + msg = f"[{sync_date}] {task} (skipped)" + if self.pbar: + self.pbar.update(1) + if self.current_date != sync_date: + self.current_date = sync_date + self.pbar.set_description(f"Syncing {sync_date}") + else: + self.logger.info(msg) + + def task_failed(self, task: str, sync_date: date): + """Mark task as failed.""" + msg = f"[{sync_date}] {task} (failed)" + if self.pbar: + self.pbar.update(1) + if self.current_date != sync_date: + self.current_date = sync_date + self.pbar.set_description(f"Syncing {sync_date}") + else: + self.logger.warning(msg) + + def info(self, message: str): + """Log info message.""" + self.logger.info(message) + + def error(self, message: str): + """Log error message.""" + self.logger.error(message) + + def end_sync(self): + """End sync progress tracking.""" + if self.pbar: + self.pbar.close() \ No newline at end of file diff --git a/src/garmy/localdb/sync.py b/src/garmy/localdb/sync.py new file mode 100644 index 0000000..e71bbe6 --- /dev/null +++ b/src/garmy/localdb/sync.py @@ -0,0 +1,253 @@ +"""Synchronization manager for Garmin health data.""" + +import asyncio +from datetime import date, datetime, timedelta +from typing import List, Dict, Any, Optional +from pathlib import Path + +from .db import HealthDB +from .config import LocalDBConfig +from .models import MetricType +from .progress import ProgressReporter +from .extractors import DataExtractor +from .activities_iterator import ActivitiesIterator + + +class SyncManager: + """Synchronization manager for health metrics.""" + + def __init__(self, + db_path: Path = Path("health.db"), + config: Optional[LocalDBConfig] = None, + progress_reporter: Optional[ProgressReporter] = None): + """Initialize sync manager. + + Args: + db_path: Path to SQLite database file. + config: Configuration object. + progress_reporter: Custom progress reporter. + """ + self.db_path = db_path + self.config = config if config is not None else LocalDBConfig() + + self.db = HealthDB(db_path, self.config.database) + self.progress = progress_reporter or ProgressReporter() + + self.extractor = DataExtractor() + self.api_client = None + self.activities_iterator = None + + def initialize(self, email: str, password: str): + """Initialize with Garmin credentials.""" + try: + from garmy import AuthClient, APIClient + + auth_client = AuthClient() + auth_client.login(email, password) + self.api_client = APIClient(auth_client=auth_client) + + self.activities_iterator = ActivitiesIterator( + self.api_client, + self.config.sync, + self.progress + ) + self.activities_iterator.initialize() + + self.progress.info("Successfully initialized Garmin API connection") + + except Exception as e: + self.progress.error(f"Failed to initialize: {e}") + raise + + def sync_range(self, user_id: int, start_date: date, end_date: date, + metrics: Optional[List[MetricType]] = None) -> Dict[str, int]: + """Sync metrics for date range. + + Args: + user_id: User identifier + start_date: Start of sync range + end_date: End of sync range + metrics: List of metrics to sync (default: all) + + Returns: + Dict with sync statistics + """ + if not self.api_client: + raise RuntimeError("Must call initialize() before syncing") + + date_count = abs((end_date - start_date).days) + 1 + + if date_count > self.config.sync.max_sync_days: + raise ValueError(f"Date range too large: {date_count} days. Maximum allowed: {self.config.sync.max_sync_days} days") + + if metrics is None: + metrics = list(MetricType) + + non_activities_metrics = [m for m in metrics if m != MetricType.ACTIVITIES] + total_tasks = date_count * len(metrics) + + self.progress.start_sync(total_tasks) + + stats = {'completed': 0, 'skipped': 0, 'failed': 0, 'total_tasks': total_tasks} + + try: + for current_date in self._date_range(start_date, end_date): + for metric_type in metrics: + if not self.db.sync_status_exists(user_id, current_date, metric_type): + self.db.create_sync_status(user_id, current_date, metric_type, 'pending') + + for current_date in self._date_range(start_date, end_date): + self._sync_date(user_id, current_date, metrics, stats) + + except Exception as e: + raise + finally: + self.progress.end_sync() + + return stats + + def _sync_date(self, user_id: int, sync_date: date, metrics: List[MetricType], stats: Dict[str, int]): + """Sync all metrics for a single date.""" + for metric_type in metrics: + try: + if metric_type == MetricType.ACTIVITIES: + self._sync_activities_for_date(user_id, sync_date, stats) + else: + self._sync_metric_for_date(user_id, sync_date, metric_type, stats) + + except Exception as e: + self.db.update_sync_status(user_id, sync_date, metric_type, 'failed', str(e)) + self.progress.task_failed(f"{metric_type.value}", sync_date) + stats['failed'] += 1 + + def _sync_metric_for_date(self, user_id: int, sync_date: date, metric_type: MetricType, stats: Dict[str, int]): + """Sync a single metric for a date.""" + if self._is_metric_completed(user_id, metric_type, sync_date): + stats['skipped'] += 1 + self.progress.task_skipped(f"{metric_type.value}", sync_date) + return + + try: + data = self.api_client.metrics.get(metric_type.value).get(sync_date) + + # Extract summary/daily data for health metrics table + extracted_data = self.extractor.extract_metric_data(data, metric_type) + summary_stored = False + + + if extracted_data and any(v is not None for v in extracted_data.values()): + self._store_health_metric(user_id, sync_date, metric_type, extracted_data) + summary_stored = True + + # Also extract timeseries data for applicable metrics + timeseries_stored = False + if metric_type in [MetricType.BODY_BATTERY, MetricType.STRESS, MetricType.HEART_RATE, MetricType.RESPIRATION]: + timeseries_data = self.extractor.extract_timeseries_data(data, metric_type) + if timeseries_data: + self.db.store_timeseries_batch(user_id, metric_type, timeseries_data) + timeseries_stored = True + + # Update status based on what was stored + if summary_stored or timeseries_stored: + self.db.update_sync_status(user_id, sync_date, metric_type, 'completed') + stats['completed'] += 1 + else: + self.db.update_sync_status(user_id, sync_date, metric_type, 'skipped') + stats['skipped'] += 1 + + self.progress.task_complete(f"{metric_type.value}", sync_date) + + except Exception as e: + self.db.update_sync_status(user_id, sync_date, metric_type, 'failed', str(e)) + self.progress.task_failed(f"{metric_type.value}", sync_date) + stats['failed'] += 1 + + def _sync_activities_for_date(self, user_id: int, sync_date: date, stats: Dict[str, int]): + """Sync activities for a specific date.""" + if not self.activities_iterator: + stats['failed'] += 1 + return + + try: + activities = self.activities_iterator.get_activities_for_date(sync_date) + + for activity in activities: + activity_data = self.extractor.extract_metric_data(activity, MetricType.ACTIVITIES) + if not activity_data or 'activity_id' not in activity_data: + continue + + activity_id = activity_data['activity_id'] + + if self.db.activity_exists(user_id, activity_id): + stats['skipped'] += 1 + continue + + activity_data['activity_date'] = sync_date + + self.db.store_activity(user_id, activity_data) + stats['completed'] += 1 + + self.progress.task_complete("activities", sync_date) + + except Exception as e: + self.progress.task_failed("activities", sync_date) + stats['failed'] += 1 + + def _store_health_metric(self, user_id: int, sync_date: date, metric_type: MetricType, data: Dict): + """Store health metric data in normalized table.""" + if metric_type == MetricType.DAILY_SUMMARY: + self.db.store_health_metric(user_id, sync_date, **data) + elif metric_type == MetricType.SLEEP: + self.db.store_health_metric(user_id, sync_date, **data) + elif metric_type == MetricType.TRAINING_READINESS: + self.db.store_health_metric( + user_id, sync_date, + training_readiness_score=data.get('score'), + training_readiness_level=data.get('level'), + training_readiness_feedback=data.get('feedback') + ) + elif metric_type == MetricType.HRV: + self.db.store_health_metric( + user_id, sync_date, + hrv_weekly_avg=data.get('weekly_avg'), + hrv_last_night_avg=data.get('last_night_avg'), + hrv_status=data.get('status') + ) + elif metric_type in [MetricType.RESPIRATION, MetricType.HEART_RATE, MetricType.STRESS, MetricType.BODY_BATTERY, MetricType.STEPS, MetricType.CALORIES]: + # Store all extracted data for these metrics + self.db.store_health_metric(user_id, sync_date, **data) + + def _is_metric_completed(self, user_id: int, metric_type: MetricType, sync_date: date) -> bool: + """Check if metric is already completed.""" + status = self.db.get_sync_status(user_id, sync_date, metric_type) + return status == 'completed' + + def _date_range(self, start_date: date, end_date: date): + """Generate date range in either direction.""" + step = 1 if start_date <= end_date else -1 + current = start_date + while (step > 0 and current <= end_date) or (step < 0 and current >= end_date): + yield current + current += timedelta(days=step) + + def query_health_metrics(self, user_id: int, start_date: date, end_date: date) -> List[Dict]: + """Query normalized health metrics for analysis.""" + return self.db.get_health_metrics(user_id, start_date, end_date) + + def query_activities(self, user_id: int, start_date: date, end_date: date, + activity_name: Optional[str] = None) -> List[Dict]: + """Query activities for date range.""" + return self.db.get_activities(user_id, start_date, end_date, activity_name) + + def query_timeseries(self, user_id: int, metric_type: MetricType, + start_time: datetime, end_time: datetime) -> List[Dict]: + """Query timeseries data for time range.""" + start_ts = int(start_time.timestamp()) * self.config.database.ms_per_second + end_ts = int(end_time.timestamp()) * self.config.database.ms_per_second + + data = self.db.get_timeseries(user_id, metric_type, start_ts, end_ts) + return [{ + 'timestamp': ts, + 'value': value, + 'metadata': metadata + } for ts, value, metadata in data] diff --git a/src/garmy/mcp/__init__.py b/src/garmy/mcp/__init__.py index 6ccb30b..07946ca 100644 --- a/src/garmy/mcp/__init__.py +++ b/src/garmy/mcp/__init__.py @@ -1,77 +1,13 @@ -"""Garmy MCP (Model Context Protocol) module. +"""MCP server for Garmin LocalDB database access. -This module provides MCP server functionality for Garmy, enabling AI assistants -to access Garmin Connect health and fitness data through the standardized MCP protocol. - -Example: - >>> from garmy.mcp import create_server - >>> server = create_server() - >>> server.run() - -Classes: - GarmyMCPServer: Main MCP server implementation. - MCPConfig: Configuration for MCP server. - -Functions: - create_server: Factory function to create a configured MCP server. - run_server: Convenience function to run a server with default settings. +Provides secure, read-only access to synchronized health data through +the Model Context Protocol, enabling AI assistants to query health metrics. """ -from typing import Optional - -from .config import MCPConfig -from .server import GarmyMCPServer - - -def create_server(config: Optional[MCPConfig] = None) -> GarmyMCPServer: - """Create a configured Garmy MCP server. - - Args: - config: Optional configuration. Uses defaults if not provided. - - Returns: - Configured GarmyMCPServer instance. - - Example: - >>> from garmy.mcp import create_server - >>> server = create_server() - >>> server.run() - """ - if config is None: - config = MCPConfig() - - return GarmyMCPServer(config) - - -def run_server( - transport: str = "stdio", - host: str = "127.0.0.1", - port: int = 8000, - path: str = "/mcp", - config: Optional[MCPConfig] = None, -) -> None: - """Create and run a Garmy MCP server with the specified transport. - - Args: - transport: Transport type ("stdio" or "http"). - host: Host for HTTP transport. - port: Port for HTTP transport. - path: Path for HTTP transport. - config: Optional server configuration. - - Example: - >>> from garmy.mcp import run_server - >>> run_server("stdio") # For Claude Desktop - >>> run_server("http", port=8080) # For HTTP clients - """ - server = create_server(config) - - if transport == "http": - server.run(transport="streamable-http", host=host, port=port, path=path) - elif transport == "stdio": - server.run(transport="stdio") - else: - raise ValueError(f"Unknown transport: {transport}. Use 'stdio' or 'http'") - - -__all__ = ["GarmyMCPServer", "MCPConfig", "create_server", "run_server"] +try: + from .config import MCPConfig + from .server import create_mcp_server + __all__ = ["MCPConfig", "create_mcp_server"] +except ImportError: + # FastMCP not installed + __all__ = [] \ No newline at end of file diff --git a/src/garmy/mcp/__main__.py b/src/garmy/mcp/__main__.py index 1af63d8..3ccc60f 100644 --- a/src/garmy/mcp/__main__.py +++ b/src/garmy/mcp/__main__.py @@ -1,10 +1,6 @@ -"""Entry point for running Garmy MCP CLI as a module. - -This allows the MCP CLI to be executed using: - python -m garmy.mcp.cli -""" +"""Main entry point for Garmin LocalDB MCP server module.""" from .cli import main if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/src/garmy/mcp/cli.py b/src/garmy/mcp/cli.py index ed7d8b6..ddca701 100644 --- a/src/garmy/mcp/cli.py +++ b/src/garmy/mcp/cli.py @@ -1,159 +1,312 @@ -"""CLI for Garmy MCP server. +#!/usr/bin/env python3 +"""Command-line interface for Garmin LocalDB MCP Server.""" -Provides command-line interface for running and managing the MCP server. -""" - -import logging +import argparse +import os +import sys +from pathlib import Path from typing import Optional -import click - -from .config import ConfigManager, MCPConfig -from .server import GarmyMCPServer +from .config import MCPConfig +try: + from .server import create_mcp_server +except ImportError: + def create_mcp_server(*args, **kwargs): + raise ImportError( + "FastMCP is required for MCP server functionality. " + "Install with: pip install garmy[mcp] or pip install fastmcp" + ) -@click.group() -@click.option("--debug", is_flag=True, help="Enable debug logging") -@click.option("--config-file", type=click.Path(exists=True), help="Path to config file") -@click.pass_context -def cli(ctx: click.Context, debug: bool, config_file: Optional[str]): - """Garmy MCP Server CLI. - Manage and run MCP servers for Garmin Connect health data. +def validate_database_path(db_path: str) -> Path: + """Validate database path exists and is accessible. + + Args: + db_path: Path to SQLite database file + + Returns: + Validated Path object + + Raises: + FileNotFoundError: If database file doesn't exist + PermissionError: If database file is not readable """ - # Setup logging - if debug: - logging.basicConfig(level=logging.DEBUG) - else: - logging.basicConfig(level=logging.INFO) - - # Load configuration - config = MCPConfig() if config_file else ConfigManager.load_from_env() - - # Store in context - ctx.ensure_object(dict) - ctx.obj["config"] = config - - -@cli.command() -@click.option( - "--transport", - type=click.Choice(["stdio", "http"]), - default="stdio", - help="Transport protocol", -) -@click.option("--host", default="127.0.0.1", help="Host for HTTP transport") -@click.option("--port", default=8000, help="Port for HTTP transport") -@click.option("--path", default="/mcp", help="Path for HTTP transport") -@click.pass_context -def run(ctx: click.Context, transport: str, host: str, port: int, path: str): - """Run the MCP server.""" - config = ctx.obj["config"] - - # Suppress output for MCP stdio to avoid JSON parsing issues - if transport != "stdio": - click.echo("Starting Garmy MCP Server") - click.echo(f"Transport: {transport}") - - server = GarmyMCPServer(config) + path = Path(db_path).resolve() + + if not path.exists(): + raise FileNotFoundError(f"Database file not found: {path}") + + if not path.is_file(): + raise ValueError(f"Path is not a file: {path}") + + if not os.access(path, os.R_OK): + raise PermissionError(f"Database file is not readable: {path}") + + return path + +def cmd_server(args): + """Start MCP server with specified configuration.""" + # Determine database path + db_path_str = args.database or os.environ.get('GARMY_DB_PATH') + + if not db_path_str: + print("Error: Database path must be provided via --database argument or GARMY_DB_PATH environment variable", file=sys.stderr) + sys.exit(1) + try: - if transport == "stdio": - server.run(transport="stdio") - elif transport == "http": - click.echo(f"HTTP Server: http://{host}:{port}{path}") - server.run(transport="streamable-http", host=host, port=port, path=path) + # Validate database path + db_path = validate_database_path(db_path_str) + + # Validate configuration parameters + if args.max_rows > args.max_rows_absolute: + print(f"Error: --max-rows ({args.max_rows}) cannot exceed --max-rows-absolute ({args.max_rows_absolute})", file=sys.stderr) + sys.exit(1) + + if args.max_rows <= 0: + print("Error: --max-rows must be positive", file=sys.stderr) + sys.exit(1) + + if args.max_rows_absolute > 10000: + print("Error: --max-rows-absolute cannot exceed 10000 for security reasons", file=sys.stderr) + sys.exit(1) + + # Create config with CLI parameters + config = MCPConfig( + db_path=db_path, + max_rows=args.max_rows, + max_rows_absolute=args.max_rows_absolute, + enable_query_logging=args.enable_query_logging, + strict_validation=not args.disable_strict_validation + ) + + if args.verbose: + print(f"Starting Garmin LocalDB MCP Server...") + print(f"Database: {db_path}") + print(f"Configuration:") + print(f" - Read-only access: enabled") + print(f" - Max rows per query: {config.max_rows}") + print(f" - Max rows absolute limit: {config.max_rows_absolute}") + print(f" - Query logging: {config.enable_query_logging}") + print(f" - Strict validation: {config.strict_validation}") + print(f"Available tools: explore_database_structure, get_table_details, execute_sql_query, get_health_summary") + + # Create and run server with explicit config + mcp_server = create_mcp_server(config) + mcp_server.run() + + except (FileNotFoundError, PermissionError, ValueError) as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) except KeyboardInterrupt: - if transport != "stdio": - click.echo("\nServer stopped") + print("\\nServer stopped by user") + sys.exit(0) except Exception as e: - click.echo(f"Error: {e}", err=True) - raise click.Abort() from e - - -@cli.command() -@click.pass_context -def info(ctx: click.Context): - """Show server information.""" - config = ctx.obj["config"] - - click.echo("Garmy MCP Server Information") - click.echo("=" * 40) - click.echo(f"Server Name: {config.server_name}") - click.echo(f"Version: {config.server_version}") - click.echo() - - click.echo("Features:") - click.echo(f" Auth Tools: {config.enable_auth_tools}") - click.echo(f" Metric Tools: {config.enable_metric_tools}") - click.echo(f" Analysis Tools: {config.enable_analysis_tools}") - click.echo(f" Resources: {config.enable_resources}") - click.echo(f" Prompts: {config.enable_prompts}") - click.echo() - - click.echo("Limits:") - click.echo(f" Max History Days: {config.max_history_days}") - click.echo(f" Analysis Period: {config.default_analysis_period}") - click.echo(f" Cache: {'Enabled' if config.cache_enabled else 'Disabled'}") - if config.cache_enabled: - click.echo(f" Cache Size: {config.cache_size}") - - -@cli.command() -@click.pass_context -def metrics(ctx: click.Context): - """List available metrics.""" - from ..core.discovery import MetricDiscovery + print(f"Failed to start MCP server: {e}", file=sys.stderr) + sys.exit(1) + +def cmd_info(args): + """Show information about the database and MCP server configuration.""" + # Determine database path + db_path_str = args.database or os.environ.get('GARMY_DB_PATH') + + if not db_path_str: + print("Error: Database path must be provided via --database argument or GARMY_DB_PATH environment variable", file=sys.stderr) + sys.exit(1) + try: - discovered_metrics = MetricDiscovery.discover_metrics() - MetricDiscovery.validate_metrics(discovered_metrics) + db_path = validate_database_path(db_path_str) + + # Get database info + file_size = db_path.stat().st_size + file_size_mb = file_size / (1024 * 1024) + + print("Garmin LocalDB MCP Server Information") + print("=" * 40) + print(f"Database file: {db_path}") + print(f"File size: {file_size_mb:.2f} MB") + print(f"Read access: {'โœ… Available' if os.access(db_path, os.R_OK) else 'โŒ Denied'}") + + # Try to get table info + try: + from .server import DatabaseManager + config = MCPConfig.from_db_path(db_path) + db_manager = DatabaseManager(config) + + # Get table information + tables_query = "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" + tables = db_manager.execute_safe_query(tables_query) + + print(f"\\nAvailable tables: {len(tables)}") + for table in tables: + table_name = table['name'] + count_query = f"SELECT COUNT(*) as count FROM {table_name}" + count_result = db_manager.execute_safe_query(count_query) + row_count = count_result[0]['count'] if count_result else 0 + print(f" - {table_name}: {row_count:,} records") + + except Exception as e: + print(f"\\nWarning: Could not analyze database structure: {e}") + + print("\\nMCP Server Tools:") + print(" - explore_database_structure() - Discover available data") + print(" - get_table_details(name) - Get table schema and samples") + print(" - execute_sql_query(sql, params) - Run SQL queries safely") + print(" - get_health_summary(user_id, days) - Quick health overview") + + print("\\nTo start MCP server:") + print(f" garmy-mcp server --database {db_path}") + + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) - click.echo(f"Available Metrics ({len(discovered_metrics)})") - click.echo("=" * 40) - for name, config in discovered_metrics.items(): - status = " (DEPRECATED)" if config.deprecated else "" - click.echo(f"{name}{status}") - click.echo(f" Class: {config.metric_class.__name__}") - click.echo(f" Endpoint: {config.endpoint or 'Dynamic'}") - if config.description: - click.echo(f" Description: {config.description}") - click.echo() +def cmd_config(args): + """Show example configurations for different use cases.""" + print("Garmin LocalDB MCP Server - Configuration Examples") + print("=" * 50) + + print("\\n๐Ÿ“‹ Basic Usage:") + print(" garmy-mcp server --database health.db") + + print("\\n๐Ÿญ Production Configuration (restrictive):") + print(" garmy-mcp server --database health.db \\\\") + print(" --max-rows 100 \\\\") + print(" --max-rows-absolute 500") + + print("\\n๐Ÿ”ง Development Configuration (permissive with logging):") + print(" garmy-mcp server --database health.db \\\\") + print(" --max-rows 2000 \\\\") + print(" --enable-query-logging \\\\") + print(" --verbose") + + print("\\n๐Ÿ› Debug Configuration (relaxed validation):") + print(" garmy-mcp server --database health.db \\\\") + print(" --disable-strict-validation \\\\") + print(" --enable-query-logging \\\\") + print(" --verbose") + + print("\\n๐Ÿค– Claude Desktop Integration:") + print(' {') + print(' "mcpServers": {') + print(' "garmy-localdb": {') + print(' "command": "garmy-mcp",') + print(' "args": ["server", "--database", "/path/to/health.db", "--max-rows", "500"]') + print(' }') + print(' }') + print(' }') + + print("\\n๐Ÿ” Security Settings:") + print(" --max-rows: Limit rows per query (default: 1000, max: 5000)") + print(" --max-rows-absolute: Hard security limit (default: 5000, max: 10000)") + print(" --enable-query-logging: Log all SQL queries for debugging") + print(" --disable-strict-validation: Allow relaxed SQL validation (not recommended)") - except Exception as e: - click.echo(f"Error discovering metrics: {e}", err=True) - raise click.Abort() from e - - -@cli.command() -@click.option( - "--profile", - type=click.Choice(["development", "production", "minimal"]), - help="Configuration profile", -) -def config(profile: Optional[str]): - """Show or generate configuration.""" - if profile: - if profile == "development": - config = MCPConfig.for_development() - elif profile == "production": - config = MCPConfig.for_production() - elif profile == "minimal": - config = MCPConfig.minimal() - - click.echo(f"{profile.title()} Configuration:") - click.echo("=" * 40) - click.echo(f"Server Name: {config.server_name}") - click.echo(f"Debug Mode: {config.debug_mode}") - click.echo(f"Cache Enabled: {config.cache_enabled}") - click.echo(f"Cache Size: {config.cache_size}") - click.echo(f"Max History Days: {config.max_history_days}") - else: - click.echo("Available profiles: development, production, minimal") - click.echo("Use --profile to see configuration details") + +def create_parser(): + """Create argument parser with subcommands.""" + parser = argparse.ArgumentParser( + prog="garmy-mcp", + description="Garmin LocalDB MCP Server - Secure read-only access to health data", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + garmy-mcp server --database health.db + garmy-mcp info --database health.db + garmy-mcp config + +Use 'garmy-mcp --help' for command-specific help. + """ + ) + + # Subcommands + subparsers = parser.add_subparsers(dest='command', help='Available commands') + subparsers.required = True + + # Server command + server_parser = subparsers.add_parser( + 'server', + help='Start MCP server', + description='Start the MCP server with specified configuration' + ) + + server_parser.add_argument( + '--database', '-d', + type=str, + help="Path to Garmin LocalDB SQLite database file" + ) + + server_parser.add_argument( + '--max-rows', + type=int, + default=1000, + help="Maximum number of rows per query (default: 1000, max: 5000)" + ) + + server_parser.add_argument( + '--max-rows-absolute', + type=int, + default=5000, + help="Absolute maximum rows limit for security (default: 5000, max: 10000)" + ) + + server_parser.add_argument( + '--enable-query-logging', + action='store_true', + help="Enable SQL query logging for debugging" + ) + + server_parser.add_argument( + '--disable-strict-validation', + action='store_true', + help="Disable strict SQL validation (not recommended)" + ) + + server_parser.add_argument( + '--verbose', '-v', + action='store_true', + help="Enable verbose logging and configuration display" + ) + + server_parser.set_defaults(func=cmd_server) + + # Info command + info_parser = subparsers.add_parser( + 'info', + help='Show database and server information', + description='Display information about the database and available MCP tools' + ) + + info_parser.add_argument( + '--database', '-d', + type=str, + help="Path to Garmin LocalDB SQLite database file" + ) + + info_parser.set_defaults(func=cmd_info) + + # Config command + config_parser = subparsers.add_parser( + 'config', + help='Show configuration examples', + description='Display example configurations for different use cases' + ) + config_parser.set_defaults(func=cmd_config) + + return parser def main(): - """Entry point for CLI.""" - cli() + """Main entry point for garmy-mcp CLI.""" + parser = create_parser() + args = parser.parse_args() + + # Execute the selected command + args.func(args) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/garmy/mcp/config.py b/src/garmy/mcp/config.py index 16b7beb..23462c6 100644 --- a/src/garmy/mcp/config.py +++ b/src/garmy/mcp/config.py @@ -1,205 +1,40 @@ -"""Configuration for Garmy MCP server. +"""Configuration management for Garmin LocalDB MCP Server.""" -This module provides configuration classes and utilities for customizing -the behavior of the Garmy MCP server. -""" - -from dataclasses import dataclass, field -from typing import Any, Dict, Optional +from pathlib import Path +from typing import Optional +from dataclasses import dataclass @dataclass class MCPConfig: - """Configuration for Garmy MCP server. - - Attributes: - server_name: Name of the MCP server. - server_version: Version of the MCP server. - enable_auth_tools: Whether to enable authentication tools. - enable_metric_tools: Whether to enable metric access tools. - enable_analysis_tools: Whether to enable data analysis tools. - enable_resources: Whether to enable MCP resources. - enable_prompts: Whether to enable MCP prompts. - cache_enabled: Whether to enable data caching. - cache_size: Maximum number of cached items. - max_history_days: Maximum days for historical data requests. - default_analysis_period: Default period for analysis tools (days). - custom_tool_config: Custom configuration for tools. - debug_mode: Whether to enable debug logging. - """ - - # Server identification - server_name: str = "Garmy Health & Fitness Server" - server_version: str = "1.0.0" - - # Feature toggles - enable_auth_tools: bool = True - enable_metric_tools: bool = True - enable_analysis_tools: bool = True - enable_resources: bool = True - enable_prompts: bool = True - - # Performance settings - cache_enabled: bool = False - cache_size: int = 100 - - # Data limits - max_history_days: int = 365 - default_analysis_period: int = 30 - - # Custom configuration - custom_tool_config: Dict[str, Any] = field(default_factory=dict) - - # Development settings - debug_mode: bool = False - - def __post_init__(self): - """Validate configuration after initialization.""" - if self.max_history_days <= 0: - raise ValueError("max_history_days must be positive") - - if self.default_analysis_period <= 0: - raise ValueError("default_analysis_period must be positive") - - if self.cache_size <= 0: - raise ValueError("cache_size must be positive") - - @classmethod - def for_development(cls) -> "MCPConfig": - """Create a configuration optimized for development. - - Returns: - MCPConfig with development-friendly settings. - """ - return cls( - debug_mode=True, cache_enabled=True, cache_size=50, max_history_days=90 - ) - + """Configuration for MCP server behavior and security settings.""" + + # Database settings + db_path: Path + + # Query execution limits + max_rows: int = 1000 + max_rows_absolute: int = 5000 + + # Security settings + enable_query_logging: bool = False + strict_validation: bool = True + @classmethod - def for_production(cls) -> "MCPConfig": - """Create a configuration optimized for production. - - Returns: - MCPConfig with production-ready settings. - """ - return cls( - debug_mode=False, cache_enabled=True, cache_size=200, max_history_days=365 - ) - - @classmethod - def minimal(cls) -> "MCPConfig": - """Create a minimal configuration with only basic features. - - Returns: - MCPConfig with minimal feature set. - """ - return cls( - enable_analysis_tools=False, - enable_resources=False, - enable_prompts=False, - cache_enabled=False, - max_history_days=30, - ) - - -@dataclass -class ToolConfig: - """Configuration for individual MCP tools. - - Attributes: - enabled: Whether the tool is enabled. - rate_limit: Maximum calls per minute (0 = no limit). - timeout_seconds: Timeout for tool execution. - custom_params: Custom parameters for the tool. - """ - - enabled: bool = True - rate_limit: int = 0 # 0 = no limit - timeout_seconds: int = 30 - custom_params: Dict[str, Any] = field(default_factory=dict) - - -class ConfigManager: - """Manager for MCP server configuration. - - Provides utilities for loading, validating, and updating configuration. - """ - - @staticmethod - def load_from_env() -> MCPConfig: - """Load configuration from environment variables. - - Environment variables: - GARMY_MCP_DEBUG: Enable debug mode (true/false). - GARMY_MCP_CACHE_ENABLED: Enable caching (true/false). - GARMY_MCP_CACHE_SIZE: Cache size (integer). - GARMY_MCP_MAX_HISTORY_DAYS: Max history days (integer). - GARMIN_EMAIL: Garmin Connect email (for auto-authentication). - GARMIN_PASSWORD: Garmin Connect password (for auto-authentication). - - Returns: - MCPConfig loaded from environment. - """ - import os - - def get_bool(key: str, default: bool) -> bool: - value = os.getenv(key, "").lower() - if value in ("true", "1", "yes", "on"): - return True - elif value in ("false", "0", "no", "off"): - return False - return default - - def get_int(key: str, default: int) -> int: - try: - return int(os.getenv(key, str(default))) - except ValueError: - return default - - return MCPConfig( - debug_mode=get_bool("GARMY_MCP_DEBUG", False), - cache_enabled=get_bool("GARMY_MCP_CACHE_ENABLED", False), - cache_size=get_int("GARMY_MCP_CACHE_SIZE", 100), - max_history_days=get_int("GARMY_MCP_MAX_HISTORY_DAYS", 365), - default_analysis_period=get_int("GARMY_MCP_DEFAULT_ANALYSIS_PERIOD", 30), - ) - - @staticmethod - def validate_config(config: MCPConfig) -> None: - """Validate MCP configuration. - - Args: - config: Configuration to validate. - - Raises: - ValueError: If configuration is invalid. - """ - if not config.server_name.strip(): - raise ValueError("server_name cannot be empty") - - if not config.server_version.strip(): - raise ValueError("server_version cannot be empty") - - # Additional validation happens in MCPConfig.__post_init__ - - @staticmethod - def get_garmin_credentials() -> tuple[Optional[str], Optional[str]]: - """Get Garmin Connect credentials from environment variables. - - Returns: - Tuple of (email, password) from environment variables. - Both may be None if not set in environment. - """ - import os - - return os.getenv("GARMIN_EMAIL"), os.getenv("GARMIN_PASSWORD") - - @staticmethod - def has_garmin_credentials() -> bool: - """Check if Garmin Connect credentials are available in environment. - - Returns: - True if both email and password are set in environment. - """ - email, password = ConfigManager.get_garmin_credentials() - return bool(email and password) + def from_db_path(cls, db_path: Path, **kwargs) -> "MCPConfig": + """Create config with database path and optional overrides.""" + return cls(db_path=db_path, **kwargs) + + def validate(self) -> None: + """Validate configuration settings.""" + if not self.db_path.exists(): + raise FileNotFoundError(f"Database file not found: {self.db_path}") + + if not self.db_path.is_file(): + raise ValueError(f"Path is not a file: {self.db_path}") + + if self.max_rows > self.max_rows_absolute: + raise ValueError(f"max_rows cannot exceed {self.max_rows_absolute}") + + if self.max_rows <= 0: + raise ValueError("max_rows must be positive") \ No newline at end of file diff --git a/src/garmy/mcp/prompts/__init__.py b/src/garmy/mcp/prompts/__init__.py deleted file mode 100644 index 1098dbc..0000000 --- a/src/garmy/mcp/prompts/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -"""MCP Prompts for Garmy. - -This module provides prompt template implementations for the Garmy MCP server, -helping AI assistants with health data analysis. -""" - -from .templates import PromptTemplates - -__all__ = ["PromptTemplates"] diff --git a/src/garmy/mcp/prompts/templates.py b/src/garmy/mcp/prompts/templates.py deleted file mode 100644 index 7758c28..0000000 --- a/src/garmy/mcp/prompts/templates.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Prompt templates for Garmy MCP server. - -Provides structured prompt templates for health data analysis. -""" - -import logging -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from fastmcp import FastMCP - - from ..server import GarmyMCPServer - -logger = logging.getLogger(__name__) - - -class PromptTemplates: - """Prompt templates for MCP server.""" - - def __init__(self, server: "GarmyMCPServer"): - """Initialize prompt templates. - - Args: - server: The parent MCP server instance. - """ - self.server = server - - def register_prompts(self, mcp: "FastMCP"): - """Register prompts with the MCP server. - - Args: - mcp: FastMCP server instance to register prompts with. - """ - - @mcp.prompt() - def analyze_health_data(metric_type: str, time_period: str = "week") -> str: - """Generate a prompt for analyzing health data. - - Args: - metric_type: Type of metric (sleep, steps, heart_rate, etc.). - time_period: Analysis period (day, week, month). - """ - return f""" -Analyze the {metric_type} data for the past {time_period} and provide: - -1. **Key Metrics**: averages, minimums, maximums -2. **Trends**: improvement or decline patterns -3. **Patterns**: regularity, anomalies -4. **Recommendations**: specific advice for health improvement -5. **Goals**: suggested targets for next period - -Make the analysis clear and actionable, with specific numbers and recommendations. -""" - - @mcp.prompt() - def health_summary_request(period_days: int = 7) -> str: - """Prompt for creating a comprehensive health summary. - - Args: - period_days: Number of days for the summary. - """ - return f""" -Create a comprehensive health and fitness summary for the last {period_days} days: - -**Include these sections:** - -1. **๐Ÿ’ค Sleep**: quality, duration, patterns -2. **๐Ÿšถ Activity**: steps, distance, calories -3. **โค๏ธ Cardio**: resting heart rate, variability, zones -4. **๐Ÿ˜ฐ Stress**: levels, recovery, balance -5. **๐Ÿ”‹ Energy**: Body Battery, charging/draining trends -6. **๐Ÿƒ Readiness**: training readiness, contributing factors - -**For each section provide:** -- Key metrics and trends -- Comparison with previous period -- Improvement recommendations -- Risk warnings - -Make the summary personal and actionable. -""" - - @mcp.prompt() - def training_analysis_request(metric_focus: str = "training_readiness") -> str: - """Prompt for training-focused analysis. - - Args: - metric_focus: Primary metric to focus on. - """ - return f""" -Analyze training and recovery data with focus on {metric_focus}: - -**Training Analysis:** -1. **Readiness Trends**: daily scores and factors -2. **Recovery Patterns**: sleep, HRV, stress impact -3. **Training Load**: acute vs chronic workload -4. **Performance Indicators**: heart rate zones, recovery time - -**Recommendations:** -1. **Training Schedule**: when to push vs rest -2. **Recovery Strategies**: sleep, stress management -3. **Performance Optimization**: training intensity guidance -4. **Risk Management**: overtraining prevention - -Focus on actionable insights for athletic performance. -""" - - @mcp.prompt() - def wellness_trend_analysis(metrics: str = "sleep,stress,hrv") -> str: - """Prompt for wellness trend analysis. - - Args: - metrics: Comma-separated list of metrics to analyze. - """ - metric_list = [m.strip() for m in metrics.split(",")] - - return f""" -Analyze wellness trends across multiple metrics: {", ".join(metric_list)} - -**Cross-Metric Analysis:** -1. **Correlations**: how metrics influence each other -2. **Patterns**: weekly/monthly cycles -3. **Triggers**: factors affecting wellness -4. **Recovery**: bounce-back patterns - -**Holistic Recommendations:** -1. **Lifestyle Adjustments**: daily habits -2. **Stress Management**: techniques and timing -3. **Sleep Optimization**: quality improvement -4. **Overall Wellness**: balanced approach - -Provide insights that connect the metrics for comprehensive wellness understanding. -""" diff --git a/src/garmy/mcp/resources/__init__.py b/src/garmy/mcp/resources/__init__.py deleted file mode 100644 index 5a87426..0000000 --- a/src/garmy/mcp/resources/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -"""MCP Resources for Garmy. - -This module provides resource implementations for the Garmy MCP server, -allowing AI assistants to read structured data. -""" - -from .providers import ResourceProviders - -__all__ = ["ResourceProviders"] diff --git a/src/garmy/mcp/resources/providers.py b/src/garmy/mcp/resources/providers.py deleted file mode 100644 index 922273f..0000000 --- a/src/garmy/mcp/resources/providers.py +++ /dev/null @@ -1,95 +0,0 @@ -"""Resource providers for Garmy MCP server. - -Provides structured data resources for AI assistants. -""" - -import json -import logging -from datetime import datetime -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from fastmcp import FastMCP - - from ..server import GarmyMCPServer - -logger = logging.getLogger(__name__) - - -class ResourceProviders: - """Resource providers for MCP server.""" - - def __init__(self, server: "GarmyMCPServer"): - """Initialize resource providers. - - Args: - server: The parent MCP server instance. - """ - self.server = server - - def register_resources(self, mcp: "FastMCP"): - """Register resources with the MCP server. - - Args: - mcp: FastMCP server instance to register resources with. - """ - - @mcp.resource("garmy://metrics/available") - async def available_metrics_resource(): - """List of all available metrics.""" - metrics_info = [] - for name, config in self.server.discovered_metrics.items(): - metrics_info.append( - { - "name": name, - "description": config.description, - "endpoint": config.endpoint, - "deprecated": config.deprecated, - "class": config.metric_class.__name__, - } - ) - - return json.dumps(metrics_info, indent=2, ensure_ascii=False) - - @mcp.resource("garmy://status/auth") - async def auth_status_resource(): - """Authentication status information.""" - status = { - "authenticated": self.server.is_authenticated(), - "timestamp": datetime.now().isoformat(), - "available_metrics": len(self.server.discovered_metrics), - } - - if self.server.api_client: - try: - status["api_metrics_count"] = len( - list(self.server.api_client.metrics.keys()) - ) - except Exception: - status["api_metrics_count"] = 0 - - return json.dumps(status, indent=2, ensure_ascii=False) - - @mcp.resource("garmy://config/server") - async def server_config_resource(): - """Server configuration information.""" - config_info = { - "server_name": self.server.config.server_name, - "server_version": self.server.config.server_version, - "features": { - "auth_tools": self.server.config.enable_auth_tools, - "metric_tools": self.server.config.enable_metric_tools, - "analysis_tools": self.server.config.enable_analysis_tools, - "resources": self.server.config.enable_resources, - "prompts": self.server.config.enable_prompts, - }, - "limits": { - "max_history_days": self.server.config.max_history_days, - "default_analysis_period": self.server.config.default_analysis_period, - "cache_enabled": self.server.config.cache_enabled, - "cache_size": self.server.config.cache_size, - }, - "debug_mode": self.server.config.debug_mode, - } - - return json.dumps(config_info, indent=2, ensure_ascii=False) diff --git a/src/garmy/mcp/server.py b/src/garmy/mcp/server.py index 1df03d3..e14d0ba 100644 --- a/src/garmy/mcp/server.py +++ b/src/garmy/mcp/server.py @@ -1,353 +1,478 @@ -#!/usr/bin/env python3 -"""Garmy MCP Server implementation. +"""Garmin LocalDB MCP Server implementation. -This module provides the main MCP server class for Garmy, enabling AI assistants -to access Garmin Connect health and fitness data through the standardized MCP protocol. +Provides secure, read-only access to synchronized Garmin health data +through the Model Context Protocol with optimized tools for LLM understanding. """ +import os +import re +import sqlite3 import logging -from typing import Any, Dict, List, Optional, Union - -import anyio -from fastmcp import FastMCP +from pathlib import Path +from typing import Any, Dict, List, Optional try: - from builtins import BaseExceptionGroup # Python 3.11+ + from fastmcp import FastMCP except ImportError: - # Python < 3.11 compatibility - BaseExceptionGroup = Exception + raise ImportError( + "FastMCP is required for MCP server functionality. " + "Install with: pip install garmy[mcp] or pip install fastmcp" + ) -from .. import APIClient, AuthClient -from ..core.discovery import MetricDiscovery from .config import MCPConfig -from .prompts import PromptTemplates -from .resources import ResourceProviders -from .tools import AnalysisTools, AuthTools, MetricTools - -logger = logging.getLogger(__name__) - - -class GarmyMCPServer: - """MCP server for Garmy with authentication state management. - - Provides AI assistants access to Garmin Connect data through - the standardized MCP protocol. - - Attributes: - config: Server configuration. - mcp: FastMCP server instance. - auth_client: Garmin authentication client. - api_client: Garmin API client. - discovered_metrics: Discovered health metrics. - """ - - def __init__(self, config: Optional[MCPConfig] = None): - """Initialize MCP server. - +from ..localdb.models import MetricType + + +class SQLiteConnection: + """Secure SQLite connection context manager for read-only access.""" + + def __init__(self, db_path: Path): + self.db_path = db_path + self.conn = None + + def __enter__(self): + """Open read-only SQLite connection.""" + self.conn = sqlite3.connect(f"file:{self.db_path}?mode=ro", uri=True) + self.conn.row_factory = sqlite3.Row + return self.conn + + def __exit__(self, exc_type, exc_val, exc_tb): + """Close connection safely.""" + if self.conn: + self.conn.close() + + +class QueryValidator: + """SQL query validation and sanitization for read-only access.""" + + ALLOWED_STATEMENTS = ('select', 'with') + FORBIDDEN_KEYWORDS = { + 'insert', 'update', 'delete', 'drop', 'create', 'alter', + 'pragma', 'attach', 'detach', 'vacuum', 'analyze' + } + + @classmethod + def validate_query(cls, query: str) -> None: + """Validate SQL query for read-only access. + Args: - config: Server configuration. Uses defaults if not provided. + query: SQL query to validate + + Raises: + ValueError: If query is not safe for read-only access """ - self.config = config or MCPConfig() - self.mcp = FastMCP(self.config.server_name) - - # Authentication state - self.auth_client: Optional[AuthClient] = None - self.api_client: Optional[APIClient] = None - - # Discovered metrics - self.discovered_metrics: Dict[str, Any] = {} - - # Setup logging - if self.config.debug_mode: - logging.basicConfig(level=logging.DEBUG) - - # Initialize components - self._initialize_components() - - def _initialize_components(self): - """Initialize all server components.""" - # Discover metrics - self._discover_metrics() - - # Register components according to configuration - if self.config.enable_auth_tools: - self._register_auth_tools() - - if self.config.enable_metric_tools: - self._register_metric_tools() - - if self.config.enable_analysis_tools: - self._register_analysis_tools() - - if self.config.enable_resources: - self._register_resources() - - if self.config.enable_prompts: - self._register_prompts() - - def _discover_metrics(self): - """Discover all available metrics.""" + if not query or not query.strip(): + raise ValueError("Query cannot be empty") + + query_lower = query.lower().strip() + + # Check if query starts with allowed statement + if not any(query_lower.startswith(prefix) for prefix in cls.ALLOWED_STATEMENTS): + allowed = ', '.join(cls.ALLOWED_STATEMENTS).upper() + raise ValueError(f"Only {allowed} queries are allowed for security") + + # Check for forbidden keywords + query_words = set(re.findall(r'\\b\\w+\\b', query_lower)) + forbidden_found = query_words.intersection(cls.FORBIDDEN_KEYWORDS) + if forbidden_found: + raise ValueError(f"Forbidden keywords found: {', '.join(forbidden_found)}") + + # Check for multiple statements + if cls._contains_multiple_statements(query): + raise ValueError("Multiple statements not allowed") + + @staticmethod + def _contains_multiple_statements(sql: str) -> bool: + """Check if SQL contains multiple statements.""" + in_single_quote = False + in_double_quote = False + + for char in sql: + if char == "'" and not in_double_quote: + in_single_quote = not in_single_quote + elif char == '"' and not in_single_quote: + in_double_quote = not in_double_quote + elif char == ';' and not in_single_quote and not in_double_quote: + return True + + return False + + @staticmethod + def add_row_limit(query: str, limit: int = 1000) -> str: + """Add LIMIT clause if not present.""" + query_lower = query.lower() + if 'limit' not in query_lower: + return f"{query.rstrip(';')} LIMIT {limit}" + return query + + +class DatabaseManager: + """Manages database connections and basic operations.""" + + def __init__(self, config: MCPConfig): + self.config = config + self.validator = QueryValidator() + self.logger = logging.getLogger("garmy.mcp.database") + + # Configure logging if enabled + if config.enable_query_logging and not self.logger.handlers: + handler = logging.StreamHandler() + handler.setFormatter(logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s' + )) + self.logger.addHandler(handler) + self.logger.setLevel(logging.INFO) + + def get_connection(self): + """Get read-only database connection.""" + return SQLiteConnection(self.config.db_path) + + def execute_safe_query(self, query: str, params: Optional[List[Any]] = None) -> List[Dict[str, Any]]: + """Execute validated query with safety checks.""" + # Validate query + if self.config.strict_validation: + self.validator.validate_query(query) + + # Add row limit + original_query = query + query = self.validator.add_row_limit(query, self.config.max_rows) + + # Log query if enabled + if self.config.enable_query_logging: + self.logger.info(f"Executing query: {query}") + if params: + self.logger.info(f"Parameters: {params}") + try: - self.discovered_metrics = MetricDiscovery.discover_metrics() - MetricDiscovery.validate_metrics(self.discovered_metrics) - logger.info(f"Discovered {len(self.discovered_metrics)} metrics") - except Exception as e: - logger.error(f"Error discovering metrics: {e}") - self.discovered_metrics = {} - - def _register_auth_tools(self): - """Register authentication tools.""" - auth_tools = AuthTools(self) - auth_tools.register_tools(self.mcp) - - def _register_metric_tools(self): - """Register metric access tools.""" - metric_tools = MetricTools(self) - metric_tools.register_tools(self.mcp) - - def _register_analysis_tools(self): - """Register data analysis tools.""" - analysis_tools = AnalysisTools(self) - analysis_tools.register_tools(self.mcp) - - def _register_resources(self): - """Register data reading resources.""" - resource_providers = ResourceProviders(self) - resource_providers.register_resources(self.mcp) - - def _register_prompts(self): - """Register prompt templates.""" - prompt_templates = PromptTemplates(self) - prompt_templates.register_prompts(self.mcp) - - def authenticate(self, email: str, password: str) -> bool: - """Perform authentication with Garmin Connect. - - Args: - email: Email address. - password: Password. - + with self.get_connection() as conn: + cursor = conn.cursor() + cursor.execute(query, params or []) + results = [dict(row) for row in cursor.fetchall()] + + if self.config.enable_query_logging: + self.logger.info(f"Query returned {len(results)} rows") + + return results + except sqlite3.Error as e: + if self.config.enable_query_logging: + self.logger.error(f"Query failed: {str(e)}") + raise ValueError(f"Database error: {str(e)}") + + +# Initialize MCP server +def create_mcp_server(config: Optional[MCPConfig] = None) -> FastMCP: + """Create and configure the Garmin LocalDB MCP server. + + Args: + config: Optional MCP configuration. If None, loads from environment. + """ + if config is None: + # Fallback to environment variable for backwards compatibility + if 'GARMY_DB_PATH' not in os.environ: + raise ValueError("GARMY_DB_PATH environment variable must be set") + + db_path = Path(os.environ['GARMY_DB_PATH']) + config = MCPConfig.from_db_path(db_path) + + # Validate configuration + config.validate() + + # Initialize components + db_manager = DatabaseManager(config) + + # Initialize MCP server with clear, LLM-friendly name + mcp = FastMCP("Garmin Health Data Explorer") + + @mcp.tool() + def explore_database_structure() -> Dict[str, Any]: + """WHEN TO USE: When you need to understand what health data is available. + + This is your starting point for exploring Garmin health data. Use this tool first + to see what tables and data types are available before running specific queries. + Returns: - True if authentication successful, False otherwise. + Complete database structure with table descriptions and available data types """ try: - self.auth_client = AuthClient() - self.api_client = APIClient(auth_client=self.auth_client) - self.auth_client.login(email, password) - return True + # Get all tables + tables_query = """ + SELECT name FROM sqlite_master + WHERE type='table' + ORDER BY name + """ + tables = db_manager.execute_safe_query(tables_query) + table_names = [row['name'] for row in tables] + + # Get row counts for each table + table_info = {} + for table_name in table_names: + count_query = f"SELECT COUNT(*) as count FROM {table_name}" + count_result = db_manager.execute_safe_query(count_query) + + table_info[table_name] = { + "row_count": count_result[0]['count'], + "description": _get_table_description(table_name) + } + + return { + "available_tables": table_info, + "metric_types": [mt.value for mt in MetricType], + "usage_tip": "Use 'execute_sql_query' to get specific data from any table, or 'get_table_details' to see column structure" + } except Exception as e: - logger.error(f"Authentication failed: {e}") - self.auth_client = None - self.api_client = None - return False - - def logout(self): - """Perform logout from system.""" - self.auth_client = None - self.api_client = None - logger.info("Logged out from Garmin Connect") - - def is_authenticated(self) -> bool: - """Check if user is authenticated. - - Returns: - True if user is authenticated. - """ - return bool(self.auth_client and self.api_client) - - def _validate_authentication(self) -> None: - """Validate that user is authenticated. - - Raises: - ValueError: If user is not authenticated. - """ - if not self.is_authenticated(): - raise ValueError("Authentication required") - - def _validate_metric_name(self, metric_name: str) -> None: - """Validate that metric name exists. - - Args: - metric_name: Name of the metric to validate. - - Raises: - ValueError: If metric name is not found. - """ - if metric_name not in self.api_client.metrics: - available = list(self.api_client.metrics.keys()) - raise ValueError(f"Unknown metric '{metric_name}'. Available: {available}") - - def get_metric_data( - self, metric_name: str, date_input: Optional[Union[str]] = None - ) -> Any: - """Get metric data for specified date. - + raise ValueError(f"Failed to explore database: {str(e)}") + + @mcp.tool() + def get_table_details(table_name: str) -> Dict[str, Any]: + """WHEN TO USE: When you need to see the structure and sample data of a specific table. + + Use this after 'explore_database_structure' when you want to understand what columns + are available in a table and see examples of the actual data. + Args: - metric_name: Name of the metric. - date_input: Date (defaults to today). - + table_name: Name of the health data table (e.g., 'daily_health_metrics', 'activities') + Returns: - Metric data or None. - - Raises: - ValueError: If user not authenticated or metric not found. + Table structure with columns, data types, and sample records """ - self._validate_authentication() - self._validate_metric_name(metric_name) - return self.api_client.metrics[metric_name].get(date_input) - - def get_metric_history( - self, metric_name: str, days: int = 7, end_date: Optional[str] = None - ) -> List[Any]: - """Get historical metric data. - - Args: - metric_name: Name of the metric. - days: Number of days. - end_date: End date (defaults to today). - - Returns: - List of metric data. - - Raises: - ValueError: If user not authenticated or metric not found. - """ - self._validate_authentication() - self._validate_metric_name(metric_name) - - # Limit days according to configuration - days = min(days, self.config.max_history_days) - - return self.api_client.metrics[metric_name].list(end=end_date, days=days) - - def format_metric_data( - self, data: Any, metric_name: str, compact: bool = False - ) -> str: - """Format metric data for display using generic approach. - + if not table_name or not table_name.strip(): + raise ValueError("Table name cannot be empty") + + # Sanitize table name + if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', table_name): + raise ValueError("Invalid table name format") + + try: + # Verify table exists + check_query = """ + SELECT name FROM sqlite_master + WHERE type='table' AND name=? + """ + check_result = db_manager.execute_safe_query(check_query, [table_name]) + + if not check_result: + available_tables = db_manager.execute_safe_query( + "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" + ) + table_list = [row['name'] for row in available_tables] + raise ValueError(f"Table '{table_name}' does not exist. Available tables: {', '.join(table_list)}") + + # Get table schema using PRAGMA + schema_query = f"PRAGMA table_info({table_name})" + with db_manager.get_connection() as conn: + cursor = conn.cursor() + cursor.execute(schema_query) + columns = cursor.fetchall() + + column_info = [{ + 'name': col[1], + 'type': col[2], + 'required': bool(col[3]), + 'is_primary_key': bool(col[5]) + } for col in columns] + + # Get sample data (latest 3 records) + sample_query = f"SELECT * FROM {table_name} ORDER BY rowid DESC LIMIT 3" + sample_data = db_manager.execute_safe_query(sample_query) + + return { + "table_name": table_name, + "columns": column_info, + "sample_data": sample_data, + "description": _get_table_description(table_name), + "usage_tip": f"Use 'execute_sql_query' with SELECT statements to get specific data from {table_name}" + } + + except Exception as e: + raise ValueError(f"Failed to get table details: {str(e)}") + + @mcp.tool() + def execute_sql_query( + query: str, + params: Optional[List[Any]] = None + ) -> List[Dict[str, Any]]: + """WHEN TO USE: When you need to get specific data using SQL queries. + + This is the main tool for querying any data from the database. Use it to run SELECT queries + to analyze health metrics, activities, sync status, or find patterns across any tables. + + IMPORTANT: Only SELECT and WITH queries are allowed for security. + Args: - data: Metric data object. - metric_name: Name of the metric. - compact: Compact display mode (currently unused, kept for compatibility). - + query: SQL SELECT query (e.g., "SELECT metric_date, total_steps FROM daily_health_metrics WHERE user_id = 1") + params: Optional list of parameters for ? placeholders in query + + Example queries: + - Health metrics: "SELECT metric_date, sleep_duration_hours FROM daily_health_metrics WHERE user_id = 1 ORDER BY metric_date DESC LIMIT 10" + - Activities: "SELECT activity_date, activity_name, duration_seconds FROM activities WHERE user_id = 1" + - High step days: "SELECT metric_date, total_steps FROM daily_health_metrics WHERE total_steps > 10000" + - Timeseries data: "SELECT timestamp, value FROM timeseries WHERE metric_type = 'heart_rate'" + Returns: - Formatted string using object's __str__ method or fallback. + List of matching records as dictionaries """ - if not data: - return "No data" - + if not query or not query.strip(): + raise ValueError("Query cannot be empty") + try: - # Use object's __str__ method if available and meaningful - if hasattr(data, "__str__") and not isinstance( - data, (str, int, float, bool) - ): - formatted = str(data) - # Check if __str__ was overridden (not default object.__str__) - if formatted and formatted != object.__str__(data): - return formatted - - # Fallback for objects with useful attributes - return self._format_object_attributes(data, compact) - + return db_manager.execute_safe_query(query, params) except Exception as e: - logger.error(f"Error formatting {metric_name} data: {e}") - return f"Formatting error: {e!s}" - - def _format_object_attributes(self, data: Any, compact: bool) -> str: - """Generic object attribute formatting as fallback. - + raise ValueError(f"Query execution failed: {str(e)}") + + @mcp.tool() + def get_health_summary( + user_id: int = 1, + days: int = 30 + ) -> Dict[str, Any]: + """WHEN TO USE: When you want a quick overview of health metrics without writing SQL. + + This tool provides a ready-made summary of key health metrics over a specified period. + Use this for getting an overview before diving into specific analysis. + Args: - data: Object to format. - compact: Whether to use compact formatting. - + user_id: User ID to analyze (default: 1) + days: Number of recent days to analyze (max 365, default: 30) + Returns: - Formatted string showing object attributes. - """ - if hasattr(data, "__dict__"): - fields = data.__dict__ - result = [] - max_fields = 3 if compact else 8 - - for key, value in list(fields.items())[:max_fields]: - if value is not None and not key.startswith("_"): - # Format value appropriately - if isinstance(value, float): - formatted_value = f"{value:.1f}" - elif isinstance(value, int) and value > 1000: - formatted_value = f"{value:,}" - else: - formatted_value = str(value) - - result.append( - f"โ€ข {key.replace('_', ' ').title()}: {formatted_value}" - ) - - return "\n".join(result) if result else "Data available" - else: - return str(data) - - def run(self, transport: str = "stdio", **kwargs): - """Start MCP server. - - Args: - transport: Transport type ("stdio" or "streamable-http"). - **kwargs: Additional transport parameters. + Summary statistics including averages for steps, sleep, heart rate, stress, and activity count """ - # Only print debug info if explicitly in debug mode to avoid MCP protocol interference - if self.config.debug_mode: - self._log_debug("Starting Garmy MCP Server...") - self._log_debug(f"Transport: {transport}") - self._log_debug(f"Discovered metrics: {len(self.discovered_metrics)}") - self._log_debug("Server ready!") - + if days > 365: + raise ValueError("Days cannot exceed 365") + + if user_id < 1: + raise ValueError("User ID must be positive") + try: - self.mcp.run(transport=transport, **kwargs) - except BaseExceptionGroup as eg: - # Handle exception groups from anyio TaskGroup - has_broken_resource = any( - isinstance(exc, anyio.BrokenResourceError) for exc in eg.exceptions - ) - if has_broken_resource and self.config.debug_mode: - self._log_debug("MCP client disconnected (normal)") - if not has_broken_resource: - # Only re-raise if there are serious errors, not just disconnections - self._log_error( - f"Error in MCP server: {eg}", eg if self.config.debug_mode else None - ) - raise - except anyio.BrokenResourceError: - # Normal client disconnection - if self.config.debug_mode: - self._log_debug("MCP client disconnected (normal)") + # Get health metrics summary + summary_query = """ + SELECT + COUNT(*) as total_days_with_data, + ROUND(AVG(total_steps), 0) as avg_daily_steps, + ROUND(AVG(sleep_duration_hours), 1) as avg_sleep_hours, + ROUND(AVG(resting_heart_rate), 0) as avg_resting_hr, + ROUND(AVG(avg_stress_level), 0) as avg_stress_level, + MIN(metric_date) as earliest_data_date, + MAX(metric_date) as latest_data_date + FROM daily_health_metrics + WHERE user_id = ? + AND metric_date >= date('now', '-' || ? || ' days') + """ + + summary_result = db_manager.execute_safe_query(summary_query, [user_id, days]) + summary = summary_result[0] if summary_result else {} + + # Get activity count + activity_query = """ + SELECT COUNT(*) as activity_count + FROM activities + WHERE user_id = ? + AND activity_date >= date('now', '-' || ? || ' days') + """ + + activity_result = db_manager.execute_safe_query(activity_query, [user_id, days]) + if activity_result: + summary['total_activities'] = activity_result[0]['activity_count'] + + summary['analysis_period_days'] = days + summary['user_id'] = user_id + + return summary + except Exception as e: - self._log_error( - f"Error running MCP server: {e}", e if self.config.debug_mode else None - ) - raise - - def _log_error(self, message: str, exception: Optional[Exception] = None) -> None: - """Log error message with optional traceback. - - Args: - message: Error message to log. - exception: Optional exception for traceback. + raise ValueError(f"Failed to generate health summary: {str(e)}") + + @mcp.resource("file://health_data_guide") + def health_data_guide() -> str: + """Complete guide to understanding and querying Garmin health data. + + This resource provides all the information needed to understand the available + health data and how to query it effectively. """ - # Use stderr for logging to avoid MCP protocol interference - import sys - - sys.stderr.write(f"{message}\n") - sys.stderr.flush() - if exception and self.config.debug_mode: - import traceback - - traceback.print_exception( - type(exception), exception, exception.__traceback__, file=sys.stderr - ) - - def _log_debug(self, message: str) -> None: - """Log debug message to stderr if debug mode is enabled.""" - if self.config.debug_mode: - import sys - - sys.stderr.write(f"DEBUG: {message}\n") - sys.stderr.flush() + return _get_health_data_guide() + + return mcp + + +def _get_table_description(table_name: str) -> str: + """Get human-readable description for table.""" + descriptions = { + "daily_health_metrics": "Daily health summaries including steps, sleep, heart rate, stress, and other key metrics", + "timeseries": "High-frequency data like heart rate readings throughout the day, stress levels, body battery", + "activities": "Individual workouts and physical activities with performance metrics", + "sync_status": "System table tracking data synchronization status (usually not needed for health analysis)" + } + return descriptions.get(table_name, "Health data table") + + +def _get_health_data_guide() -> str: + """Get comprehensive guide for health data analysis.""" + return ''' +# Garmin Health Data Analysis Guide + +## Quick Start +1. Use `explore_database_structure` first to see what data is available +2. Use `get_table_details` to understand specific tables +3. Use `execute_sql_query` for custom analysis or `get_health_summary` for quick overviews + +## Main Data Tables + +### daily_health_metrics +**WHAT**: Daily summaries of all health metrics +**CONTAINS**: steps, sleep hours, heart rate averages, stress levels, body battery +**COMMON QUERIES**: +- Recent trends: `SELECT metric_date, total_steps, sleep_duration_hours FROM daily_health_metrics WHERE user_id = 1 ORDER BY metric_date DESC LIMIT 30` +- Sleep analysis: `SELECT metric_date, sleep_duration_hours, deep_sleep_hours FROM daily_health_metrics WHERE sleep_duration_hours IS NOT NULL` + +### activities +**WHAT**: Individual workouts and physical activities +**CONTAINS**: activity type, duration, heart rate, training load +**COMMON QUERIES**: +- Recent workouts: `SELECT activity_date, activity_name, duration_seconds/60 as minutes FROM activities ORDER BY activity_date DESC` +- Performance trends: `SELECT activity_name, AVG(avg_heart_rate), AVG(training_load) FROM activities GROUP BY activity_name` + +### timeseries +**WHAT**: High-frequency data throughout the day +**CONTAINS**: heart rate readings, stress measurements, body battery levels with timestamps +**USE CASE**: Detailed intraday analysis + +## Health Metrics Available +- **Steps & Movement**: total_steps, total_distance_meters +- **Sleep**: sleep_duration_hours, deep_sleep_hours, rem_sleep_hours +- **Heart Rate**: resting_heart_rate, max_heart_rate, average_heart_rate +- **Stress & Recovery**: avg_stress_level, body_battery_high/low +- **Training**: training_readiness_score, activities data + +## Tips for Analysis +- Always include `user_id = 1` in WHERE clauses +- Use `metric_date` for date filtering in daily_health_metrics +- Use `activity_date` for date filtering in activities +- NULL values are common - use `IS NOT NULL` to filter out missing data +- For recent data: `WHERE metric_date >= date('now', '-30 days')` + +## Common Analysis Patterns +1. **Trend Analysis**: Compare metrics over time periods +2. **Correlation Analysis**: Look for relationships between sleep, stress, and performance +3. **Goal Tracking**: Monitor progress toward targets (steps, sleep duration) +4. **Activity Analysis**: Understand workout patterns and performance + '''.strip() + + +# Legacy function for backwards compatibility +def create_mcp_server_from_env() -> FastMCP: + """Create MCP server from environment variables (backwards compatibility).""" + return create_mcp_server() + + +# Main entry point for MCP server +def main(): + """Main entry point for the Garmin LocalDB MCP server.""" + try: + mcp = create_mcp_server() + mcp.run() + except Exception as e: + print(f"Failed to start MCP server: {e}") + raise + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/garmy/mcp/tools/__init__.py b/src/garmy/mcp/tools/__init__.py deleted file mode 100644 index dcf284e..0000000 --- a/src/garmy/mcp/tools/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -""" -MCP Tools for Garmy. - -This module provides tool implementations for the Garmy MCP server, -organized by functionality. -""" - -from .analysis import AnalysisTools -from .auth import AuthTools -from .metrics import MetricTools - -__all__ = ["AnalysisTools", "AuthTools", "MetricTools"] diff --git a/src/garmy/mcp/tools/analysis.py b/src/garmy/mcp/tools/analysis.py deleted file mode 100644 index 06a0392..0000000 --- a/src/garmy/mcp/tools/analysis.py +++ /dev/null @@ -1,190 +0,0 @@ -"""Data analysis tools for Garmy MCP server. - -Provides tools for analyzing health and fitness trends. -""" - -import logging -from datetime import date, timedelta -from typing import TYPE_CHECKING - -from fastmcp import Context - -if TYPE_CHECKING: - from fastmcp import FastMCP - - from ..server import GarmyMCPServer - -logger = logging.getLogger(__name__) - - -class AnalysisTools: - """Data analysis tools for MCP server.""" - - def __init__(self, server: "GarmyMCPServer"): - """Initialize analysis tools. - - Args: - server: The parent MCP server instance. - """ - self.server = server - - def register_tools(self, mcp: "FastMCP"): - """Register analysis tools with the MCP server. - - Args: - mcp: FastMCP server instance to register tools with. - """ - - @mcp.tool() - async def analyze_sleep_trends(days: int = 30, ctx: Context = None) -> str: - """Analyze sleep trends over a specified period. - - Args: - days: Number of days to analyze (default 30). - """ - if not self.server.is_authenticated(): - return "Authentication required." - - try: - await ctx.info(f"Analyzing sleep trends for {days} days") - - sleep_data = self.server.get_metric_history("sleep", days) - - if not sleep_data: - return "No sleep data available for analysis" - - # Analyze data - durations = [] - deep_sleep_percentages = [] - - for sleep in sleep_data: - if ( - hasattr(sleep, "sleep_duration_hours") - and sleep.sleep_duration_hours - ): - durations.append(sleep.sleep_duration_hours) - if ( - hasattr(sleep, "deep_sleep_percentage") - and sleep.deep_sleep_percentage - ): - deep_sleep_percentages.append(sleep.deep_sleep_percentage) - - result = f"**Sleep Analysis for {days} days** ({len(sleep_data)} records):\n\n" - - if durations: - avg_duration = sum(durations) / len(durations) - min_duration = min(durations) - max_duration = max(durations) - - result += "**Sleep Duration:**\n" - result += f"โ€ข Average: {avg_duration:.1f} hours\n" - result += f"โ€ข Minimum: {min_duration:.1f} hours\n" - result += f"โ€ข Maximum: {max_duration:.1f} hours\n\n" - - if deep_sleep_percentages: - avg_deep = sum(deep_sleep_percentages) / len(deep_sleep_percentages) - result += "**Deep Sleep:**\n" - result += f"โ€ข Average percentage: {avg_deep:.1f}%\n\n" - - result += "**Recommendations:**\n" - if durations and avg_duration < 7: - result += "โ€ข Average sleep duration below recommended (7-9 hours)\n" - elif durations and avg_duration >= 7: - result += "โ€ข Good average sleep duration\n" - - await ctx.info("Sleep analysis completed") - return result - - except Exception as e: - await ctx.error(f"Analysis error: {e!s}") - return f"Analysis error: {e!s}" - - @mcp.tool() - async def compare_metrics( - metric_name: str, - period1_days: int = 7, - period2_days: int = 7, - ctx: Context = None, - ) -> str: - """Compare a metric between two time periods. - - Args: - metric_name: Name of the metric to compare. - period1_days: Days for first period (recent). - period2_days: Days for second period (previous). - """ - if not self.server.is_authenticated(): - return "Authentication required." - - try: - await ctx.info(f"Comparing {metric_name} between periods") - - # Get data for both periods - today = date.today() - - # Period 1 (recent days) - period1_data = self.server.get_metric_history( - metric_name, period1_days, today - ) - - # Period 2 (previous days) - period2_end = today - timedelta(days=period1_days) - period2_data = self.server.get_metric_history( - metric_name, period2_days, period2_end - ) - - result = f"**{metric_name.replace('_', ' ').title()} Comparison**:\n\n" - result += f"**Period 1** (last {period1_days} days): {len(period1_data)} records\n" - result += f"**Period 2** (previous {period2_days} days): {len(period2_data)} records\n\n" - - # Analyze key metrics based on metric type - if metric_name == "steps": - p1_steps = [ - getattr(d, "total_steps", 0) - for d in period1_data - if hasattr(d, "total_steps") - ] - p2_steps = [ - getattr(d, "total_steps", 0) - for d in period2_data - if hasattr(d, "total_steps") - ] - - if p1_steps and p2_steps: - avg1 = sum(p1_steps) / len(p1_steps) - avg2 = sum(p2_steps) / len(p2_steps) - change = ((avg1 - avg2) / avg2) * 100 if avg2 > 0 else 0 - - result += "**Average Steps:**\n" - result += f"โ€ข Period 1: {avg1:,.0f} steps\n" - result += f"โ€ข Period 2: {avg2:,.0f} steps\n" - result += f"โ€ข Change: {change:+.1f}%\n" - - elif metric_name == "sleep": - p1_duration = [ - getattr(d, "sleep_duration_hours", 0) - for d in period1_data - if hasattr(d, "sleep_duration_hours") and d.sleep_duration_hours - ] - p2_duration = [ - getattr(d, "sleep_duration_hours", 0) - for d in period2_data - if hasattr(d, "sleep_duration_hours") and d.sleep_duration_hours - ] - - if p1_duration and p2_duration: - avg1 = sum(p1_duration) / len(p1_duration) - avg2 = sum(p2_duration) / len(p2_duration) - change = avg1 - avg2 - - result += "**Average Sleep Duration:**\n" - result += f"โ€ข Period 1: {avg1:.1f} hours\n" - result += f"โ€ข Period 2: {avg2:.1f} hours\n" - result += f"โ€ข Change: {change:+.1f} hours\n" - - await ctx.info("Comparison completed") - return result - - except Exception as e: - await ctx.error(f"Comparison error: {e!s}") - return f"Comparison error: {e!s}" diff --git a/src/garmy/mcp/tools/auth.py b/src/garmy/mcp/tools/auth.py deleted file mode 100644 index 0769cf9..0000000 --- a/src/garmy/mcp/tools/auth.py +++ /dev/null @@ -1,165 +0,0 @@ -"""Authentication tools for Garmy MCP server. - -Provides tools for logging in/out of Garmin Connect and checking authentication status. -""" - -import logging -from typing import TYPE_CHECKING - -from fastmcp import Context - -if TYPE_CHECKING: - from fastmcp import FastMCP - - from ..server import GarmyMCPServer - -logger = logging.getLogger(__name__) - - -class AuthTools: - """Authentication tools for MCP server.""" - - def __init__(self, server: "GarmyMCPServer"): - """ - Initialize authentication tools. - - Args: - server: The parent MCP server instance - """ - self.server = server - - def _resolve_credentials(self, email: str, password: str) -> tuple[str, str, bool]: - """Resolve final credentials and determine source. - - Args: - email: Provided email (may be empty). - password: Provided password (may be empty). - - Returns: - Tuple of (final_email, final_password, is_manual). - - Raises: - ValueError: If no credentials are available. - """ - from ..config import ConfigManager - - # Use provided credentials or fall back to environment variables - final_email = email or None - final_password = password or None - - if not final_email or not final_password: - env_email, env_password = ConfigManager.get_garmin_credentials() - final_email = final_email or env_email - final_password = final_password or env_password - - if not final_email or not final_password: - raise ValueError( - "No credentials provided. Either provide email and password manually or set GARMIN_EMAIL and GARMIN_PASSWORD environment variables." - ) - - is_manual = bool(email and password) - return final_email, final_password, is_manual - - def _mask_email(self, email: str) -> str: - """Mask email for secure logging.""" - if "@" not in email: - return "***" - parts = email.split("@") - return f"{parts[0][:3]}***@{parts[1]}" - - def register_tools(self, mcp: "FastMCP"): - """ - Register authentication tools with the MCP server. - - Args: - mcp: FastMCP server instance to register tools with - """ - - @mcp.tool() - async def garmin_login( - ctx: Context, email: str = "", password: str = "" - ) -> str: - """Log into Garmin Connect with credentials. - - Will automatically use environment variables GARMIN_EMAIL and GARMIN_PASSWORD - if no credentials are provided manually. - - Args: - email: Garmin Connect email address (optional if set in environment). - password: Garmin Connect password (optional if set in environment). - """ - try: - final_email, final_password, is_manual = self._resolve_credentials( - email, password - ) - masked_email = self._mask_email(final_email) - - # Log attempt with security warning - await ctx.info(f"Attempting login for user: {masked_email}") - if is_manual: - await ctx.info( - "WARNING: Credentials passed manually may be visible to Anthropic servers" - ) - else: - await ctx.info( - "Using credentials from environment variables (secure)" - ) - - success = self.server.authenticate(final_email, final_password) - - if success: - await ctx.info("Successfully logged into Garmin Connect") - source = "manual input" if is_manual else "environment variables" - return f"Successfully logged into Garmin Connect using {source}. All health metrics are now available." - else: - await ctx.error("Login failed") - return "Login failed. Please check your credentials." - - except ValueError as e: - await ctx.error("No credentials provided") - return f"Authentication failed: {e!s}" - except Exception as e: - await ctx.error(f"Login error: {e!s}") - return f"Login error: {e!s}" - - @mcp.tool() - async def garmin_auto_login(ctx: Context) -> str: - """Automatically log into Garmin Connect using environment variables. - - Uses GARMIN_EMAIL and GARMIN_PASSWORD environment variables. - This is the most secure way to authenticate as credentials never - pass through Claude/Anthropic servers. - """ - # Delegate to garmin_login with empty credentials (will use env vars) - return await garmin_login(ctx, "", "") - - @mcp.tool() - async def garmin_logout(ctx: Context) -> str: - """Log out of Garmin Connect.""" - try: - if self.server.is_authenticated(): - self.server.logout() - await ctx.info("Logged out of Garmin Connect") - return "Successfully logged out." - else: - return "User was not authenticated." - except Exception as e: - await ctx.error(f"Logout error: {e!s}") - return f"Logout error: {e!s}" - - @mcp.tool() - async def check_auth_status(ctx: Context) -> str: - """Check authentication status.""" - if self.server.is_authenticated(): - try: - # Check API availability by getting metrics list - available_metrics = list(self.server.api_client.metrics.keys()) - await ctx.info( - f"Authenticated. Available metrics: {len(available_metrics)}" - ) - return f"Authenticated with Garmin Connect. {len(available_metrics)} metrics available." - except Exception as e: - await ctx.warning(f"Authentication issues: {e}") - return f"Possible authentication issues: {e}" - else: - return "Not authenticated. Use garmin_login to log in." diff --git a/src/garmy/mcp/tools/metrics.py b/src/garmy/mcp/tools/metrics.py deleted file mode 100644 index 37d52ad..0000000 --- a/src/garmy/mcp/tools/metrics.py +++ /dev/null @@ -1,716 +0,0 @@ -"""Metric access tools for Garmy MCP server. - -Provides tools for accessing and querying Garmin health metrics. -""" - -import csv -import io -import json -import logging -from dataclasses import dataclass -from datetime import date, datetime, timedelta -from typing import TYPE_CHECKING, Any, List, Optional - -from fastmcp import Context - -if TYPE_CHECKING: - from fastmcp import FastMCP - - from ..server import GarmyMCPServer - -logger = logging.getLogger(__name__) - - -@dataclass -class DatedMetricData: - """Wrapper for metric data with associated date.""" - - date: date - data: Any - - def __str__(self) -> str: - """Format dated metric data for display.""" - if self.data is None: - return f"**{self.date}**: No data" - - # Special formatting for activities - if isinstance(self.data, dict) and "activities" in self.data: - return self._format_activities_data() - - # Standard formatting for other metrics - data_str = str(self.data) - return f"**{self.date}**:\n{data_str}" - - def _format_activities_data(self) -> str: - """Format activities data for display.""" - activities = self.data["activities"] - count = self.data["count"] - - if count == 0: - return f"**{self.date}**: No activities" - - result = f"**{self.date}**: {count} activit{'y' if count == 1 else 'ies'}\n" - - for i, activity in enumerate(activities, 1): - # Get activity details - name = activity.activity_name or "Unnamed Activity" - activity_type = getattr(activity, "activity_type_name", "Unknown") - duration_min = getattr(activity, "duration_minutes", 0) - - result += f" {i}. {name} ({activity_type})" - if duration_min: - result += f" - {duration_min:.0f} min" - if hasattr(activity, "average_hr") and activity.average_hr: - result += f" - {activity.average_hr:.0f} bpm avg" - result += "\n" - - return result.rstrip() - - -class MetricTools: - """Metric access tools for MCP server.""" - - def __init__(self, server: "GarmyMCPServer"): - """Initialize metric tools. - - Args: - server: The parent MCP server instance. - """ - self.server = server - - def _export_to_csv(self, data_list: List[Any], metric_name: str) -> str: - """Export data list to CSV format. - - Args: - data_list: List of metric data objects. - metric_name: Name of the metric for context. - - Returns: - CSV formatted string. - """ - if not data_list: - return "No data to export" - - output = io.StringIO() - - # Get first object to determine fields - first_obj = data_list[0] - - if hasattr(first_obj, "__dict__"): - # Use object attributes as CSV columns - fieldnames = [] - for key in first_obj.__dict__: - if not key.startswith("_"): - fieldnames.append(key) - - writer = csv.DictWriter(output, fieldnames=fieldnames) - writer.writeheader() - - for obj in data_list: - row = {} - for field in fieldnames: - value = getattr(obj, field, None) - # Convert complex objects to string representation - if isinstance(value, (list, dict)): - row[field] = json.dumps(value) - elif value is None: - row[field] = "" - else: - row[field] = str(value) - writer.writerow(row) - - else: - # Fallback for simple objects - writer = csv.writer(output) - writer.writerow([metric_name.replace("_", " ").title()]) - for obj in data_list: - writer.writerow([str(obj)]) - - return output.getvalue() - - def _export_dated_to_csv( - self, dated_data_list: List[DatedMetricData], metric_name: str - ) -> str: - """Export dated data list to CSV format with date column. - - Args: - dated_data_list: List of DatedMetricData objects. - metric_name: Name of the metric for context. - - Returns: - CSV formatted string with date column. - """ - if not dated_data_list: - return "No data to export" - - output = io.StringIO() - - # Special handling for activities - if metric_name == "activities": - return self._export_activities_to_csv(dated_data_list) - - # Filter out entries with no data - valid_data = [d for d in dated_data_list if d.data is not None] - - if not valid_data: - writer = csv.writer(output) - writer.writerow(["date", metric_name.replace("_", " ").title()]) - writer.writerow(["No data available", ""]) - return output.getvalue() - - # Get first valid object to determine fields - first_obj = valid_data[0].data - - if hasattr(first_obj, "__dict__"): - # Use object attributes as CSV columns, with date as first column - fieldnames = ["date"] - for key in first_obj.__dict__: - if not key.startswith("_"): - fieldnames.append(key) - - writer = csv.DictWriter(output, fieldnames=fieldnames) - writer.writeheader() - - for dated_data in reversed(dated_data_list): # Chronological order - if dated_data.data is None: - # Write row with date but empty data - row = {"date": str(dated_data.date)} - for field in fieldnames[1:]: - row[field] = "" - writer.writerow(row) - else: - row = {"date": str(dated_data.date)} - for field in fieldnames[1:]: - value = getattr(dated_data.data, field, None) - # Convert complex objects to string representation - if isinstance(value, (list, dict)): - row[field] = json.dumps(value) - elif value is None: - row[field] = "" - else: - row[field] = str(value) - writer.writerow(row) - else: - # Fallback for simple objects - writer = csv.writer(output) - writer.writerow(["date", metric_name.replace("_", " ").title()]) - for dated_data in reversed(dated_data_list): # Chronological order - if dated_data.data is None: - writer.writerow([str(dated_data.date), "No data"]) - else: - writer.writerow([str(dated_data.date), str(dated_data.data)]) - - return output.getvalue() - - def _export_activities_to_csv(self, dated_data_list: List[DatedMetricData]) -> str: - """Export activities data to CSV format with one row per activity. - - Args: - dated_data_list: List of DatedMetricData objects containing activities. - - Returns: - CSV formatted string with one row per activity. - """ - output = io.StringIO() - - # Collect all activities - all_activities = [] - for dated_data in dated_data_list: - if dated_data.data and "activities" in dated_data.data: - for activity in dated_data.data["activities"]: - all_activities.append(activity) - - if not all_activities: - writer = csv.writer(output) - writer.writerow( - ["date", "activity_name", "activity_type", "duration_minutes"] - ) - writer.writerow(["No activities found", "", "", ""]) - return output.getvalue() - - # Determine fieldnames from first activity - first_activity = all_activities[0] - fieldnames = ["date"] # Always start with date - - if hasattr(first_activity, "__dict__"): - for key in first_activity.__dict__: - if not key.startswith("_"): - fieldnames.append(key) - - writer = csv.DictWriter(output, fieldnames=fieldnames) - writer.writeheader() - - # Write activities sorted by date (chronological order) - activities_with_dates = [] - for dated_data in dated_data_list: - if dated_data.data and "activities" in dated_data.data: - for activity in dated_data.data["activities"]: - activities_with_dates.append((dated_data.date, activity)) - - # Sort by date - activities_with_dates.sort(key=lambda x: x[0]) - - for activity_date, activity in activities_with_dates: - row = {"date": str(activity_date)} - - for field in fieldnames[1:]: - value = getattr(activity, field, None) - if isinstance(value, (list, dict)): - row[field] = json.dumps(value) - elif value is None: - row[field] = "" - else: - row[field] = str(value) - - writer.writerow(row) - - return output.getvalue() - - def _get_metric_history_with_dates( - self, metric_name: str, days: int, end_date: Optional[date] = None - ) -> List[DatedMetricData]: - """Get metric history with date labels. - - Args: - metric_name: Name of the metric. - days: Number of days to fetch. - end_date: End date (defaults to today). - - Returns: - List of DatedMetricData objects. - """ - if end_date is None: - end_date = date.today() - - # Special handling for activities - if metric_name == "activities": - return self._get_activities_history_with_dates(days, end_date) - - # Generate date list (most recent first) - dates = [end_date - timedelta(days=i) for i in range(days)] - - # Get metric data for each date - dated_data = [] - for target_date in dates: - try: - data = self.server.get_metric_data(metric_name, target_date) - dated_data.append(DatedMetricData(date=target_date, data=data)) - except Exception as e: - logger.warning( - f"Failed to get {metric_name} data for {target_date}: {e}" - ) - dated_data.append(DatedMetricData(date=target_date, data=None)) - - return dated_data - - def _get_activities_history_with_dates( - self, days: int, end_date: Optional[date] = None - ) -> List[DatedMetricData]: - """Get activities history grouped by dates. - - Args: - days: Number of days to fetch. - end_date: End date (defaults to today). - - Returns: - List of DatedMetricData objects with activities grouped by date. - """ - if end_date is None: - end_date = date.today() - - cutoff_date = end_date - timedelta(days=days - 1) - - try: - # Get activities from API (get more to ensure we capture all in the range) - activities_accessor = self.server.api_client.metrics.get("activities") - if not activities_accessor: - return [] - - all_activities = activities_accessor.list(limit=100) # Get more activities - - # Group activities by date - activities_by_date = {} - for activity in all_activities: - try: - # Parse activity date from start_date property - activity_date_str = activity.start_date # YYYY-MM-DD format - if activity_date_str: - activity_date = datetime.strptime( - activity_date_str, "%Y-%m-%d" - ).date() - - # Only include activities in our date range - if cutoff_date <= activity_date <= end_date: - if activity_date not in activities_by_date: - activities_by_date[activity_date] = [] - activities_by_date[activity_date].append(activity) - except Exception as e: - logger.warning(f"Failed to parse activity date: {e}") - continue - - # Create DatedMetricData for each day (most recent first) - dated_data = [] - for i in range(days): - target_date = end_date - timedelta(days=i) - activities_for_date = activities_by_date.get(target_date, []) - - if activities_for_date: - # Create a summary of activities for this date - activity_summary = { - "date": target_date, - "count": len(activities_for_date), - "activities": activities_for_date, - } - dated_data.append( - DatedMetricData(date=target_date, data=activity_summary) - ) - else: - dated_data.append(DatedMetricData(date=target_date, data=None)) - - return dated_data - - except Exception as e: - logger.error(f"Failed to get activities history: {e}") - return [ - DatedMetricData(date=end_date - timedelta(days=i), data=None) - for i in range(days) - ] - - def register_tools(self, mcp: "FastMCP"): - """Register metric tools with the MCP server. - - Args: - mcp: FastMCP server instance to register tools with. - """ - - @mcp.tool() - async def list_available_metrics(ctx: Context) -> str: - """Show all available health metrics.""" - if not self.server.discovered_metrics: - return "No metrics discovered. Check Garmy installation." - - result = "Available health and fitness metrics:\n\n" - - for i, (name, config) in enumerate( - self.server.discovered_metrics.items(), 1 - ): - title = name.replace("_", " ").title() - description = config.description or "No description available" - deprecated = " (DEPRECATED)" if config.deprecated else "" - - result += f"{i}. **{title}**{deprecated}\n" - result += f" โ€ข Key: `{name}`\n" - result += f" โ€ข {description}\n\n" - - await ctx.info(f"Discovered {len(self.server.discovered_metrics)} metrics") - return result - - @mcp.tool() - async def get_metric_data( - metric_name: str, date_str: Optional[str] = None, ctx: Context = None - ) -> str: - """Get metric data for a specific date. - - Args: - metric_name: Name of the metric (e.g. 'sleep', 'steps', 'heart_rate'). - date_str: Date in YYYY-MM-DD format (defaults to today). - """ - if not self.server.is_authenticated(): - return "Authentication required. Use garmin_login." - - try: - target_date = date.today() - if date_str: - target_date = datetime.strptime(date_str, "%Y-%m-%d").date() - - await ctx.info(f"Getting {metric_name} data for {target_date}") - - # Get data through API - data = self.server.get_metric_data(metric_name, target_date) - - if not data: - return f"No {metric_name} data for {target_date}" - - # Format result - result = f"**{metric_name.replace('_', ' ').title()}** for {target_date}:\n\n" - result += self.server.format_metric_data(data, metric_name) - - await ctx.info(f"Successfully retrieved {metric_name} data") - return result - - except ValueError as e: - if "Unknown metric" in str(e): - available = list(self.server.api_client.metrics.keys()) - return f"Unknown metric '{metric_name}'. Available: {', '.join(available)}" - else: - return f"Error: {e!s}" - except Exception as e: - await ctx.error(f"Error getting data: {e!s}") - return f"Error: {e!s}" - - @mcp.tool() - async def get_metric_history( - metric_name: str, - days: int = 7, - end_date: Optional[str] = None, - ctx: Context = None, - ) -> str: - """Get historical metric data for multiple days with date labels. - - Args: - metric_name: Name of the metric. - days: Number of days (default 7). - end_date: End date in YYYY-MM-DD format (defaults to today). - """ - if not self.server.is_authenticated(): - return "Authentication required. Use garmin_login." - - try: - target_end = date.today() - if end_date: - target_end = datetime.strptime(end_date, "%Y-%m-%d").date() - - await ctx.info( - f"Getting {metric_name} history for {days} days until {target_end}" - ) - - # Get historical data with dates - dated_history = self._get_metric_history_with_dates( - metric_name, days, target_end - ) - - if not dated_history or all(d.data is None for d in dated_history): - return f"No historical {metric_name} data" - - result = f"**{metric_name.replace('_', ' ').title()} History** ({days} days until {target_end}):\n\n" - - # Show records with dates (most recent first) - valid_records = [d for d in dated_history if d.data is not None] - result += f"Found records: {len(valid_records)} out of {len(dated_history)} days\n\n" - - # Show up to 10 recent records with dates - records_to_show = dated_history[:10] - for dated_data in records_to_show: - if dated_data.data: - result += str(dated_data) + "\n\n" - else: - result += f"**{dated_data.date}**: No data\n\n" - - if len(dated_history) > 10: - result += f"... and {len(dated_history) - 10} more days\n" - - await ctx.info( - f"Successfully retrieved {metric_name} history: {len(valid_records)} valid records" - ) - return result - - except ValueError as e: - return f"Error: {e!s}" - except Exception as e: - await ctx.error(f"Error getting history: {e!s}") - return f"Error: {e!s}" - - @mcp.tool() - async def get_metric_range( # noqa: PLR0911 - metric_name: str, - start_date: str, - end_date: Optional[str] = None, - ctx: Context = None, - ) -> str: - """Get metric data for a specific date range with date labels. - - Args: - metric_name: Name of the metric. - start_date: Start date in YYYY-MM-DD format. - end_date: End date in YYYY-MM-DD format (defaults to today). - """ - if not self.server.is_authenticated(): - return "Authentication required. Use garmin_login." - - try: - start = datetime.strptime(start_date, "%Y-%m-%d").date() - end = date.today() - if end_date: - end = datetime.strptime(end_date, "%Y-%m-%d").date() - - if start > end: - return "Error: Start date cannot be after end date" - - days = (end - start).days + 1 - if days > self.server.config.max_history_days: - return f"Error: Requested range ({days} days) exceeds maximum allowed ({self.server.config.max_history_days} days)" - - await ctx.info( - f"Getting {metric_name} data from {start} to {end} ({days} days)" - ) - - # Get range data with dates - dated_range = self._get_metric_history_with_dates( - metric_name, days, end - ) - - if not dated_range or all(d.data is None for d in dated_range): - return f"No {metric_name} data for range {start} to {end}" - - result = f"**{metric_name.replace('_', ' ').title()}** from {start} to {end}:\n\n" - - valid_records = [d for d in dated_range if d.data is not None] - result += f"Found records: {len(valid_records)} out of {days} days\n\n" - - # Show all records for smaller ranges, or summarize for larger ones - if len(dated_range) <= 15: - # Show all records with dates - for dated_data in reversed(dated_range): # Chronological order - if dated_data.data: - result += str(dated_data) + "\n\n" - else: - result += f"**{dated_data.date}**: No data\n\n" - else: - # Show first 5 and last 5 with dates - result += "**First 5 days:**\n\n" - for dated_data in reversed( - dated_range[-5:] - ): # First 5 chronologically - if dated_data.data: - result += str(dated_data) + "\n\n" - else: - result += f"**{dated_data.date}**: No data\n\n" - - result += f"... {len(dated_range) - 10} more days ...\n\n" - - result += "**Last 5 days:**\n\n" - for dated_data in dated_range[:5]: # Last 5 (most recent) - if dated_data.data: - result += str(dated_data) + "\n\n" - else: - result += f"**{dated_data.date}**: No data\n\n" - - await ctx.info( - f"Successfully retrieved {metric_name} range data: {len(valid_records)} valid records" - ) - return result - - except ValueError as e: - if "time data" in str(e): - return "Error: Invalid date format. Use YYYY-MM-DD format." - return f"Error: {e!s}" - except Exception as e: - await ctx.error(f"Error getting range data: {e!s}") - return f"Error: {e!s}" - - @mcp.tool() - async def export_metric_csv( - metric_name: str, - days: int = 30, - end_date: Optional[str] = None, - ctx: Context = None, - ) -> str: - """Export metric data as CSV for analysis with date columns. - - Args: - metric_name: Name of the metric. - days: Number of days to export (default 30). - end_date: End date in YYYY-MM-DD format (defaults to today). - """ - if not self.server.is_authenticated(): - return "Authentication required. Use garmin_login." - - try: - target_end = date.today() - if end_date: - target_end = datetime.strptime(end_date, "%Y-%m-%d").date() - - await ctx.info( - f"Exporting {metric_name} data as CSV for {days} days until {target_end}" - ) - - # Get historical data with dates - dated_history = self._get_metric_history_with_dates( - metric_name, days, target_end - ) - - if not dated_history or all(d.data is None for d in dated_history): - return f"No {metric_name} data to export" - - # Generate CSV with dates - csv_content = self._export_dated_to_csv(dated_history, metric_name) - - valid_records = [d for d in dated_history if d.data is not None] - result = f"**{metric_name.replace('_', ' ').title()} CSV Export** ({len(valid_records)} valid records out of {len(dated_history)} days):\n\n" - result += "```csv\n" - result += csv_content - result += "\n```\n\n" - result += f"Data exported successfully. Contains {len(valid_records)} valid records from {days} days with date labels." - - await ctx.info( - f"Successfully exported {metric_name} data: {len(valid_records)} valid records" - ) - return result - - except Exception as e: - await ctx.error(f"Error exporting CSV: {e!s}") - return f"Error: {e!s}" - - @mcp.tool() - async def get_metric_schema(metric_name: str, ctx: Context = None) -> str: - """Get detailed schema information for a metric. - - Args: - metric_name: Name of the metric to inspect. - """ - try: - # Check if metric exists in discovered metrics - if metric_name not in self.server.discovered_metrics: - available = list(self.server.discovered_metrics.keys()) - return f"Unknown metric '{metric_name}'. Available: {', '.join(available)}" - - config = self.server.discovered_metrics[metric_name] - - result = f"**{metric_name.replace('_', ' ').title()} Schema**:\n\n" - result += f"โ€ข **Class**: {config.metric_class.__name__}\n" - result += ( - f"โ€ข **Description**: {config.description or 'No description'}\n" - ) - result += f"โ€ข **Version**: {config.version}\n" - result += f"โ€ข **Requires User ID**: {config.requires_user_id}\n" - result += f"โ€ข **Deprecated**: {config.deprecated}\n\n" - - # Get class attributes if possible - try: - if hasattr(config.metric_class, "__dataclass_fields__"): - result += "**Available Fields**:\n" - for ( - field_name, - field_info, - ) in config.metric_class.__dataclass_fields__.items(): - field_type = ( - str(field_info.type) - .replace("", "") - ) - result += f"โ€ข `{field_name}`: {field_type}\n" - result += "\n" - - # Look for properties - properties = [] - for attr_name in dir(config.metric_class): - if not attr_name.startswith("_"): - attr = getattr(config.metric_class, attr_name, None) - if isinstance(attr, property): - properties.append(attr_name) - - if properties: - result += "**Computed Properties**:\n" - for prop in properties: - result += f"โ€ข `{prop}`\n" - result += "\n" - - except Exception as e: - result += f"Schema introspection error: {e!s}\n" - - await ctx.info(f"Retrieved schema for {metric_name}") - return result - - except Exception as e: - await ctx.error(f"Error getting schema: {e!s}") - return f"Error: {e!s}" diff --git a/tests/test_mcp_analysis.py b/tests/test_mcp_analysis.py deleted file mode 100644 index 17d921a..0000000 --- a/tests/test_mcp_analysis.py +++ /dev/null @@ -1,335 +0,0 @@ -""" -Comprehensive tests for MCP analysis tools module. - -Tests all analysis functionality to achieve 100% coverage. -""" - -from datetime import date -from unittest.mock import AsyncMock, Mock, patch - -import pytest -from fastmcp import Context - -from garmy.mcp.tools.analysis import AnalysisTools - - -class TestAnalysisTools: - """Test suite for AnalysisTools class.""" - - def setup_method(self): - """Set up test fixtures.""" - self.mock_server = Mock() - self.mock_server.config.default_analysis_period = 30 - self.analysis_tools = AnalysisTools(self.mock_server) - self.mock_ctx = AsyncMock(spec=Context) - - def test_init(self): - """Test AnalysisTools initialization.""" - server = Mock() - analysis_tools = AnalysisTools(server) - assert analysis_tools.server is server - - @pytest.mark.asyncio - async def test_analyze_metric_trends_not_authenticated(self): - """Test metric trend analysis when not authenticated.""" - self.mock_server.is_authenticated.return_value = False - - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.analysis_tools.register_tools(mock_mcp) - - result = await tool_functions["analyze_metric_trends"]( - "sleep", 7, None, self.mock_ctx - ) - - assert "Authentication required" in result - - @pytest.mark.asyncio - async def test_analyze_metric_trends_success(self): - """Test successful metric trend analysis.""" - self.mock_server.is_authenticated.return_value = True - - # Mock metric history data - mock_history = [ - Mock(value=8.5), # Sleep hours for different days - Mock(value=7.2), - Mock(value=8.8), - Mock(value=6.5), - Mock(value=9.1), - ] - self.mock_server.get_metric_history.return_value = mock_history - - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.analysis_tools.register_tools(mock_mcp) - - result = await tool_functions["analyze_metric_trends"]( - "sleep", 5, "2023-12-01", self.mock_ctx - ) - - assert "**Sleep Trend Analysis**" in result - assert "Period: 5 days until 2023-12-01" in result - assert "Data Points: 5" in result - assert "Average:" in result - assert "Min:" in result - assert "Max:" in result - assert "Trend:" in result - - self.mock_server.get_metric_history.assert_called_once_with( - "sleep", 5, "2023-12-01" - ) - self.mock_ctx.info.assert_any_call( - "Analyzing sleep trends for 5 days until 2023-12-01" - ) - - @pytest.mark.asyncio - async def test_analyze_metric_trends_no_data(self): - """Test metric trend analysis with no data.""" - self.mock_server.is_authenticated.return_value = True - self.mock_server.get_metric_history.return_value = [] - - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.analysis_tools.register_tools(mock_mcp) - - result = await tool_functions["analyze_metric_trends"]( - "sleep", 7, None, self.mock_ctx - ) - - assert "No sleep data available for trend analysis" in result - - @pytest.mark.asyncio - async def test_analyze_metric_trends_default_period(self): - """Test metric trend analysis with default period.""" - self.mock_server.is_authenticated.return_value = True - self.mock_server.get_metric_history.return_value = [Mock(value=123)] - - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.analysis_tools.register_tools(mock_mcp) - - await tool_functions["analyze_metric_trends"]( - "steps", None, None, self.mock_ctx - ) - - # Should use default analysis period from config - self.mock_server.get_metric_history.assert_called_once_with("steps", 30, None) - - @pytest.mark.asyncio - async def test_analyze_metric_trends_exception(self): - """Test metric trend analysis with exception.""" - self.mock_server.is_authenticated.return_value = True - self.mock_server.get_metric_history.side_effect = Exception("API Error") - - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.analysis_tools.register_tools(mock_mcp) - - result = await tool_functions["analyze_metric_trends"]( - "sleep", 7, None, self.mock_ctx - ) - - assert "Error analyzing trends: API Error" in result - self.mock_ctx.error.assert_called_once_with("Error analyzing trends: API Error") - - @pytest.mark.asyncio - async def test_compare_time_periods_not_authenticated(self): - """Test time period comparison when not authenticated.""" - self.mock_server.is_authenticated.return_value = False - - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.analysis_tools.register_tools(mock_mcp) - - result = await tool_functions["compare_time_periods"]( - "steps", 7, 7, self.mock_ctx - ) - - assert "Authentication required" in result - - @pytest.mark.asyncio - async def test_compare_time_periods_success(self): - """Test successful time period comparison.""" - self.mock_server.is_authenticated.return_value = True - - # Mock data for two periods - current_data = [Mock(value=8000), Mock(value=9000), Mock(value=7500)] - previous_data = [Mock(value=7000), Mock(value=8500), Mock(value=6500)] - - self.mock_server.get_metric_history.side_effect = [current_data, previous_data] - - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.analysis_tools.register_tools(mock_mcp) - - with patch("garmy.mcp.tools.analysis.date") as mock_date: - mock_date.today.return_value = date(2023, 12, 15) - mock_date.side_effect = lambda *args: date(*args) - - result = await tool_functions["compare_time_periods"]( - "steps", 3, 3, self.mock_ctx - ) - - assert "**Steps Period Comparison**" in result - assert "Current Period:" in result - assert "Previous Period:" in result - assert "Current Average:" in result - assert "Previous Average:" in result - assert "Change:" in result - assert "Current Data Points: 3" in result - assert "Previous Data Points: 3" in result - - @pytest.mark.asyncio - async def test_compare_time_periods_no_current_data(self): - """Test time period comparison with no current data.""" - self.mock_server.is_authenticated.return_value = True - self.mock_server.get_metric_history.side_effect = [[], [Mock(value=100)]] - - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.analysis_tools.register_tools(mock_mcp) - - result = await tool_functions["compare_time_periods"]( - "steps", 7, 7, self.mock_ctx - ) - - assert "No current period data available" in result - - @pytest.mark.asyncio - async def test_compare_time_periods_no_previous_data(self): - """Test time period comparison with no previous data.""" - self.mock_server.is_authenticated.return_value = True - self.mock_server.get_metric_history.side_effect = [[Mock(value=100)], []] - - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.analysis_tools.register_tools(mock_mcp) - - result = await tool_functions["compare_time_periods"]( - "steps", 7, 7, self.mock_ctx - ) - - assert "No previous period data available for comparison" in result - - @pytest.mark.asyncio - async def test_compare_time_periods_exception(self): - """Test time period comparison with exception.""" - self.mock_server.is_authenticated.return_value = True - self.mock_server.get_metric_history.side_effect = Exception("API Error") - - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.analysis_tools.register_tools(mock_mcp) - - result = await tool_functions["compare_time_periods"]( - "steps", 7, 7, self.mock_ctx - ) - - assert "Error comparing periods: API Error" in result - self.mock_ctx.error.assert_called_once_with( - "Error comparing periods: API Error" - ) - - -if __name__ == "__main__": - pytest.main([__file__]) diff --git a/tests/test_mcp_auth.py b/tests/test_mcp_auth.py deleted file mode 100644 index 2f79c06..0000000 --- a/tests/test_mcp_auth.py +++ /dev/null @@ -1,496 +0,0 @@ -""" -Comprehensive tests for MCP authentication tools module. - -Tests all functions and edge cases to achieve 100% coverage. -""" - -import os -from unittest.mock import AsyncMock, Mock, patch - -import pytest -from fastmcp import Context - -from garmy.mcp.tools.auth import AuthTools - - -class TestAuthTools: - """Test suite for AuthTools class.""" - - def setup_method(self): - """Set up test fixtures.""" - self.mock_server = Mock() - self.auth_tools = AuthTools(self.mock_server) - self.mock_ctx = AsyncMock(spec=Context) - - def test_init(self): - """Test AuthTools initialization.""" - server = Mock() - auth_tools = AuthTools(server) - assert auth_tools.server is server - - def test_resolve_credentials_with_provided_credentials(self): - """Test resolving credentials when both email and password are provided.""" - email = "test@example.com" - password = "test123" - - result_email, result_password, is_manual = self.auth_tools._resolve_credentials( - email, password - ) - - assert result_email == email - assert result_password == password - assert is_manual is True - - def test_resolve_credentials_with_partial_credentials(self): - """Test resolving credentials with only email provided.""" - with ( - patch.dict( - os.environ, - {"GARMIN_EMAIL": "env@example.com", "GARMIN_PASSWORD": "envpass"}, - ), - patch("garmy.mcp.tools.auth.ConfigManager") as mock_config, - ): - mock_config.get_garmin_credentials.return_value = ( - "env@example.com", - "envpass", - ) - - result_email, result_password, is_manual = ( - self.auth_tools._resolve_credentials("test@example.com", "") - ) - - assert result_email == "test@example.com" - assert result_password == "envpass" - assert is_manual is False - - def test_resolve_credentials_from_environment(self): - """Test resolving credentials from environment variables.""" - with patch("garmy.mcp.tools.auth.ConfigManager") as mock_config: - mock_config.get_garmin_credentials.return_value = ( - "env@example.com", - "envpass", - ) - - result_email, result_password, is_manual = ( - self.auth_tools._resolve_credentials("", "") - ) - - assert result_email == "env@example.com" - assert result_password == "envpass" - assert is_manual is False - - def test_resolve_credentials_no_credentials_available(self): - """Test resolving credentials when none are available.""" - with patch("garmy.mcp.tools.auth.ConfigManager") as mock_config: - mock_config.get_garmin_credentials.return_value = (None, None) - - with pytest.raises(ValueError, match="No credentials provided"): - self.auth_tools._resolve_credentials("", "") - - def test_resolve_credentials_missing_email(self): - """Test resolving credentials when email is missing.""" - with patch("garmy.mcp.tools.auth.ConfigManager") as mock_config: - mock_config.get_garmin_credentials.return_value = (None, "password") - - with pytest.raises(ValueError, match="No credentials provided"): - self.auth_tools._resolve_credentials("", "test123") - - def test_resolve_credentials_missing_password(self): - """Test resolving credentials when password is missing.""" - with patch("garmy.mcp.tools.auth.ConfigManager") as mock_config: - mock_config.get_garmin_credentials.return_value = ("email@test.com", None) - - with pytest.raises(ValueError, match="No credentials provided"): - self.auth_tools._resolve_credentials("test@example.com", "") - - def test_mask_email_valid_email(self): - """Test email masking for valid email addresses.""" - assert self.auth_tools._mask_email("test@example.com") == "tes***@example.com" - assert self.auth_tools._mask_email("a@b.com") == "a***@b.com" - assert ( - self.auth_tools._mask_email("verylongemail@domain.org") - == "ver***@domain.org" - ) - - def test_mask_email_invalid_email(self): - """Test email masking for invalid email format.""" - assert self.auth_tools._mask_email("notanemail") == "***" - assert self.auth_tools._mask_email("") == "***" - assert self.auth_tools._mask_email("no_at_symbol") == "***" - - @pytest.mark.asyncio - async def test_garmin_login_success_with_manual_credentials(self): - """Test successful login with manually provided credentials.""" - # Setup mocks - self.mock_server.authenticate.return_value = True - - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.auth_tools.register_tools(mock_mcp) - - # Call the registered function - result = await tool_functions["garmin_login"]( - self.mock_ctx, "test@example.com", "password123" - ) - - # Verify results - assert "Successfully logged into Garmin Connect using manual input" in result - self.mock_server.authenticate.assert_called_once_with( - "test@example.com", "password123" - ) - self.mock_ctx.info.assert_any_call( - "Attempting login for user: tes***@example.com" - ) - self.mock_ctx.info.assert_any_call( - "WARNING: Credentials passed manually may be visible to Anthropic servers" - ) - self.mock_ctx.info.assert_any_call("Successfully logged into Garmin Connect") - - @pytest.mark.asyncio - async def test_garmin_login_success_with_env_credentials(self): - """Test successful login with environment credentials.""" - # Setup mocks - self.mock_server.authenticate.return_value = True - - with patch("garmy.mcp.tools.auth.ConfigManager") as mock_config: - mock_config.get_garmin_credentials.return_value = ( - "env@example.com", - "envpass", - ) - - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.auth_tools.register_tools(mock_mcp) - - # Call the registered function with empty credentials - result = await tool_functions["garmin_login"](self.mock_ctx, "", "") - - # Verify results - assert ( - "Successfully logged into Garmin Connect using environment variables" - in result - ) - self.mock_server.authenticate.assert_called_once_with( - "env@example.com", "envpass" - ) - self.mock_ctx.info.assert_any_call( - "Using credentials from environment variables (secure)" - ) - - @pytest.mark.asyncio - async def test_garmin_login_failure(self): - """Test failed login attempt.""" - # Setup mocks - self.mock_server.authenticate.return_value = False - - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.auth_tools.register_tools(mock_mcp) - - # Call the registered function - result = await tool_functions["garmin_login"]( - self.mock_ctx, "test@example.com", "wrongpass" - ) - - # Verify results - assert "Login failed. Please check your credentials." in result - self.mock_ctx.error.assert_called_once_with("Login failed") - - @pytest.mark.asyncio - async def test_garmin_login_no_credentials_error(self): - """Test login with no credentials available.""" - with patch("garmy.mcp.tools.auth.ConfigManager") as mock_config: - mock_config.get_garmin_credentials.return_value = (None, None) - - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.auth_tools.register_tools(mock_mcp) - - # Call the registered function - result = await tool_functions["garmin_login"](self.mock_ctx, "", "") - - # Verify results - assert "Authentication failed: No credentials provided" in result - self.mock_ctx.error.assert_called_once_with("No credentials provided") - - @pytest.mark.asyncio - async def test_garmin_login_exception(self): - """Test login with unexpected exception.""" - # Setup mocks - self.mock_server.authenticate.side_effect = Exception("Connection error") - - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.auth_tools.register_tools(mock_mcp) - - # Call the registered function - result = await tool_functions["garmin_login"]( - self.mock_ctx, "test@example.com", "password123" - ) - - # Verify results - assert "Login error: Connection error" in result - self.mock_ctx.error.assert_called_once_with("Login error: Connection error") - - @pytest.mark.asyncio - async def test_garmin_auto_login(self): - """Test automatic login using environment variables.""" - with patch("garmy.mcp.tools.auth.ConfigManager") as mock_config: - mock_config.get_garmin_credentials.return_value = ( - "auto@example.com", - "autopass", - ) - self.mock_server.authenticate.return_value = True - - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.auth_tools.register_tools(mock_mcp) - - # Call the registered function - result = await tool_functions["garmin_auto_login"](self.mock_ctx) - - # Verify results - assert ( - "Successfully logged into Garmin Connect using environment variables" - in result - ) - self.mock_server.authenticate.assert_called_once_with( - "auto@example.com", "autopass" - ) - - @pytest.mark.asyncio - async def test_garmin_logout_when_authenticated(self): - """Test logout when user is authenticated.""" - # Setup mocks - self.mock_server.is_authenticated.return_value = True - - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.auth_tools.register_tools(mock_mcp) - - # Call the registered function - result = await tool_functions["garmin_logout"](self.mock_ctx) - - # Verify results - assert "Successfully logged out." in result - self.mock_server.logout.assert_called_once() - self.mock_ctx.info.assert_called_once_with("Logged out of Garmin Connect") - - @pytest.mark.asyncio - async def test_garmin_logout_when_not_authenticated(self): - """Test logout when user is not authenticated.""" - # Setup mocks - self.mock_server.is_authenticated.return_value = False - - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.auth_tools.register_tools(mock_mcp) - - # Call the registered function - result = await tool_functions["garmin_logout"](self.mock_ctx) - - # Verify results - assert "User was not authenticated." in result - self.mock_server.logout.assert_not_called() - - @pytest.mark.asyncio - async def test_garmin_logout_exception(self): - """Test logout with exception.""" - # Setup mocks - self.mock_server.is_authenticated.return_value = True - self.mock_server.logout.side_effect = Exception("Logout error") - - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.auth_tools.register_tools(mock_mcp) - - # Call the registered function - result = await tool_functions["garmin_logout"](self.mock_ctx) - - # Verify results - assert "Logout error: Logout error" in result - self.mock_ctx.error.assert_called_once_with("Logout error: Logout error") - - @pytest.mark.asyncio - async def test_check_auth_status_authenticated(self): - """Test auth status check when authenticated.""" - # Setup mocks - self.mock_server.is_authenticated.return_value = True - self.mock_server.api_client.metrics.keys.return_value = [ - "sleep", - "steps", - "heart_rate", - ] - - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.auth_tools.register_tools(mock_mcp) - - # Call the registered function - result = await tool_functions["check_auth_status"](self.mock_ctx) - - # Verify results - assert "Authenticated with Garmin Connect. 3 metrics available." in result - self.mock_ctx.info.assert_called_once_with( - "Authenticated. Available metrics: 3" - ) - - @pytest.mark.asyncio - async def test_check_auth_status_authenticated_with_api_error(self): - """Test auth status check when authenticated but API has issues.""" - # Setup mocks - self.mock_server.is_authenticated.return_value = True - self.mock_server.api_client.metrics.keys.side_effect = Exception("API Error") - - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.auth_tools.register_tools(mock_mcp) - - # Call the registered function - result = await tool_functions["check_auth_status"](self.mock_ctx) - - # Verify results - assert "Possible authentication issues: API Error" in result - self.mock_ctx.warning.assert_called_once_with( - "Authentication issues: API Error" - ) - - @pytest.mark.asyncio - async def test_check_auth_status_not_authenticated(self): - """Test auth status check when not authenticated.""" - # Setup mocks - self.mock_server.is_authenticated.return_value = False - - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.auth_tools.register_tools(mock_mcp) - - # Call the registered function - result = await tool_functions["check_auth_status"](self.mock_ctx) - - # Verify results - assert "Not authenticated. Use garmin_login to log in." in result - - -if __name__ == "__main__": - pytest.main([__file__]) diff --git a/tests/test_mcp_config.py b/tests/test_mcp_config.py deleted file mode 100644 index af96288..0000000 --- a/tests/test_mcp_config.py +++ /dev/null @@ -1,381 +0,0 @@ -""" -Comprehensive tests for MCP configuration module. - -Tests all configuration classes and utilities to achieve 100% coverage. -""" - -import os -from unittest.mock import patch - -import pytest - -from garmy.mcp.config import ConfigManager, MCPConfig, ToolConfig - - -class TestMCPConfig: - """Test suite for MCPConfig class.""" - - def test_default_initialization(self): - """Test MCPConfig with default values.""" - config = MCPConfig() - - assert config.server_name == "Garmy Health & Fitness Server" - assert config.server_version == "1.0.0" - assert config.enable_auth_tools is True - assert config.enable_metric_tools is True - assert config.enable_analysis_tools is True - assert config.enable_resources is True - assert config.enable_prompts is True - assert config.cache_enabled is False - assert config.cache_size == 100 - assert config.max_history_days == 365 - assert config.default_analysis_period == 30 - assert config.custom_tool_config == {} - assert config.debug_mode is False - - def test_custom_initialization(self): - """Test MCPConfig with custom values.""" - custom_config = {"tool1": {"param1": "value1"}} - - config = MCPConfig( - server_name="Custom Server", - server_version="2.0.0", - enable_auth_tools=False, - cache_enabled=True, - cache_size=50, - max_history_days=180, - default_analysis_period=14, - custom_tool_config=custom_config, - debug_mode=True, - ) - - assert config.server_name == "Custom Server" - assert config.server_version == "2.0.0" - assert config.enable_auth_tools is False - assert config.cache_enabled is True - assert config.cache_size == 50 - assert config.max_history_days == 180 - assert config.default_analysis_period == 14 - assert config.custom_tool_config == custom_config - assert config.debug_mode is True - - def test_post_init_validation_valid(self): - """Test successful post-init validation.""" - # Should not raise any exception - config = MCPConfig( - max_history_days=30, default_analysis_period=7, cache_size=10 - ) - assert config is not None - - def test_post_init_validation_invalid_max_history_days(self): - """Test post-init validation with invalid max_history_days.""" - with pytest.raises(ValueError, match="max_history_days must be positive"): - MCPConfig(max_history_days=0) - - with pytest.raises(ValueError, match="max_history_days must be positive"): - MCPConfig(max_history_days=-10) - - def test_post_init_validation_invalid_default_analysis_period(self): - """Test post-init validation with invalid default_analysis_period.""" - with pytest.raises( - ValueError, match="default_analysis_period must be positive" - ): - MCPConfig(default_analysis_period=0) - - with pytest.raises( - ValueError, match="default_analysis_period must be positive" - ): - MCPConfig(default_analysis_period=-5) - - def test_post_init_validation_invalid_cache_size(self): - """Test post-init validation with invalid cache_size.""" - with pytest.raises(ValueError, match="cache_size must be positive"): - MCPConfig(cache_size=0) - - with pytest.raises(ValueError, match="cache_size must be positive"): - MCPConfig(cache_size=-20) - - def test_for_development(self): - """Test development configuration factory method.""" - config = MCPConfig.for_development() - - assert config.debug_mode is True - assert config.cache_enabled is True - assert config.cache_size == 50 - assert config.max_history_days == 90 - # Other values should be defaults - assert config.server_name == "Garmy Health & Fitness Server" - assert config.enable_auth_tools is True - - def test_for_production(self): - """Test production configuration factory method.""" - config = MCPConfig.for_production() - - assert config.debug_mode is False - assert config.cache_enabled is True - assert config.cache_size == 200 - assert config.max_history_days == 365 - # Other values should be defaults - assert config.server_name == "Garmy Health & Fitness Server" - assert config.enable_auth_tools is True - - def test_minimal(self): - """Test minimal configuration factory method.""" - config = MCPConfig.minimal() - - assert config.enable_analysis_tools is False - assert config.enable_resources is False - assert config.enable_prompts is False - assert config.cache_enabled is False - assert config.max_history_days == 30 - # Other values should be defaults - assert config.server_name == "Garmy Health & Fitness Server" - assert config.enable_auth_tools is True - assert config.enable_metric_tools is True - - -class TestToolConfig: - """Test suite for ToolConfig class.""" - - def test_default_initialization(self): - """Test ToolConfig with default values.""" - config = ToolConfig() - - assert config.enabled is True - assert config.rate_limit == 0 - assert config.timeout_seconds == 30 - assert config.custom_params == {} - - def test_custom_initialization(self): - """Test ToolConfig with custom values.""" - custom_params = {"param1": "value1", "param2": 42} - - config = ToolConfig( - enabled=False, - rate_limit=60, - timeout_seconds=120, - custom_params=custom_params, - ) - - assert config.enabled is False - assert config.rate_limit == 60 - assert config.timeout_seconds == 120 - assert config.custom_params == custom_params - - -class TestConfigManager: - """Test suite for ConfigManager class.""" - - def test_load_from_env_defaults(self): - """Test loading configuration from environment with no env vars set.""" - with patch.dict(os.environ, {}, clear=True): - config = ConfigManager.load_from_env() - - assert config.debug_mode is False - assert config.cache_enabled is False - assert config.cache_size == 100 - assert config.max_history_days == 365 - assert config.default_analysis_period == 30 - - def test_load_from_env_debug_true_variations(self): - """Test loading debug mode with various true values.""" - true_values = ["true", "1", "yes", "on", "TRUE", "True", "YES", "ON"] - - for true_val in true_values: - with patch.dict(os.environ, {"GARMY_MCP_DEBUG": true_val}): - config = ConfigManager.load_from_env() - assert config.debug_mode is True, f"Failed for value: {true_val}" - - def test_load_from_env_debug_false_variations(self): - """Test loading debug mode with various false values.""" - false_values = ["false", "0", "no", "off", "FALSE", "False", "NO", "OFF"] - - for false_val in false_values: - with patch.dict(os.environ, {"GARMY_MCP_DEBUG": false_val}): - config = ConfigManager.load_from_env() - assert config.debug_mode is False, f"Failed for value: {false_val}" - - def test_load_from_env_debug_invalid_value(self): - """Test loading debug mode with invalid value falls back to default.""" - with patch.dict(os.environ, {"GARMY_MCP_DEBUG": "maybe"}): - config = ConfigManager.load_from_env() - assert config.debug_mode is False # Should use default - - def test_load_from_env_cache_enabled_true(self): - """Test loading cache enabled with true value.""" - with patch.dict(os.environ, {"GARMY_MCP_CACHE_ENABLED": "true"}): - config = ConfigManager.load_from_env() - assert config.cache_enabled is True - - def test_load_from_env_cache_enabled_false(self): - """Test loading cache enabled with false value.""" - with patch.dict(os.environ, {"GARMY_MCP_CACHE_ENABLED": "false"}): - config = ConfigManager.load_from_env() - assert config.cache_enabled is False - - def test_load_from_env_cache_size_valid(self): - """Test loading cache size with valid integer.""" - with patch.dict(os.environ, {"GARMY_MCP_CACHE_SIZE": "250"}): - config = ConfigManager.load_from_env() - assert config.cache_size == 250 - - def test_load_from_env_cache_size_invalid(self): - """Test loading cache size with invalid value falls back to default.""" - with patch.dict(os.environ, {"GARMY_MCP_CACHE_SIZE": "not_a_number"}): - config = ConfigManager.load_from_env() - assert config.cache_size == 100 # Should use default - - def test_load_from_env_max_history_days_valid(self): - """Test loading max history days with valid integer.""" - with patch.dict(os.environ, {"GARMY_MCP_MAX_HISTORY_DAYS": "180"}): - config = ConfigManager.load_from_env() - assert config.max_history_days == 180 - - def test_load_from_env_max_history_days_invalid(self): - """Test loading max history days with invalid value falls back to default.""" - with patch.dict(os.environ, {"GARMY_MCP_MAX_HISTORY_DAYS": "invalid"}): - config = ConfigManager.load_from_env() - assert config.max_history_days == 365 # Should use default - - def test_load_from_env_default_analysis_period_valid(self): - """Test loading default analysis period with valid integer.""" - with patch.dict(os.environ, {"GARMY_MCP_DEFAULT_ANALYSIS_PERIOD": "14"}): - config = ConfigManager.load_from_env() - assert config.default_analysis_period == 14 - - def test_load_from_env_default_analysis_period_invalid(self): - """Test loading default analysis period with invalid value falls back to default.""" - with patch.dict(os.environ, {"GARMY_MCP_DEFAULT_ANALYSIS_PERIOD": "not_valid"}): - config = ConfigManager.load_from_env() - assert config.default_analysis_period == 30 # Should use default - - def test_load_from_env_all_values_set(self): - """Test loading configuration with all environment variables set.""" - env_vars = { - "GARMY_MCP_DEBUG": "true", - "GARMY_MCP_CACHE_ENABLED": "true", - "GARMY_MCP_CACHE_SIZE": "150", - "GARMY_MCP_MAX_HISTORY_DAYS": "200", - "GARMY_MCP_DEFAULT_ANALYSIS_PERIOD": "21", - } - - with patch.dict(os.environ, env_vars): - config = ConfigManager.load_from_env() - - assert config.debug_mode is True - assert config.cache_enabled is True - assert config.cache_size == 150 - assert config.max_history_days == 200 - assert config.default_analysis_period == 21 - - def test_validate_config_valid(self): - """Test validating a valid configuration.""" - config = MCPConfig(server_name="Valid Server", server_version="1.0.0") - - # Should not raise any exception - ConfigManager.validate_config(config) - - def test_validate_config_empty_server_name(self): - """Test validating configuration with empty server name.""" - config = MCPConfig(server_name="") - - with pytest.raises(ValueError, match="server_name cannot be empty"): - ConfigManager.validate_config(config) - - def test_validate_config_whitespace_server_name(self): - """Test validating configuration with whitespace-only server name.""" - config = MCPConfig(server_name=" ") - - with pytest.raises(ValueError, match="server_name cannot be empty"): - ConfigManager.validate_config(config) - - def test_validate_config_empty_server_version(self): - """Test validating configuration with empty server version.""" - config = MCPConfig(server_version="") - - with pytest.raises(ValueError, match="server_version cannot be empty"): - ConfigManager.validate_config(config) - - def test_validate_config_whitespace_server_version(self): - """Test validating configuration with whitespace-only server version.""" - config = MCPConfig(server_version=" ") - - with pytest.raises(ValueError, match="server_version cannot be empty"): - ConfigManager.validate_config(config) - - def test_get_garmin_credentials_both_set(self): - """Test getting Garmin credentials when both are set.""" - with patch.dict( - os.environ, - {"GARMIN_EMAIL": "test@example.com", "GARMIN_PASSWORD": "password123"}, - ): - email, password = ConfigManager.get_garmin_credentials() - - assert email == "test@example.com" - assert password == "password123" - - def test_get_garmin_credentials_email_only(self): - """Test getting Garmin credentials when only email is set.""" - with patch.dict(os.environ, {"GARMIN_EMAIL": "test@example.com"}, clear=True): - email, password = ConfigManager.get_garmin_credentials() - - assert email == "test@example.com" - assert password is None - - def test_get_garmin_credentials_password_only(self): - """Test getting Garmin credentials when only password is set.""" - with patch.dict(os.environ, {"GARMIN_PASSWORD": "password123"}, clear=True): - email, password = ConfigManager.get_garmin_credentials() - - assert email is None - assert password == "password123" - - def test_get_garmin_credentials_none_set(self): - """Test getting Garmin credentials when none are set.""" - with patch.dict(os.environ, {}, clear=True): - email, password = ConfigManager.get_garmin_credentials() - - assert email is None - assert password is None - - def test_has_garmin_credentials_both_set(self): - """Test checking for Garmin credentials when both are set.""" - with patch.dict( - os.environ, - {"GARMIN_EMAIL": "test@example.com", "GARMIN_PASSWORD": "password123"}, - ): - result = ConfigManager.has_garmin_credentials() - - assert result is True - - def test_has_garmin_credentials_email_only(self): - """Test checking for Garmin credentials when only email is set.""" - with patch.dict(os.environ, {"GARMIN_EMAIL": "test@example.com"}, clear=True): - result = ConfigManager.has_garmin_credentials() - - assert result is False - - def test_has_garmin_credentials_password_only(self): - """Test checking for Garmin credentials when only password is set.""" - with patch.dict(os.environ, {"GARMIN_PASSWORD": "password123"}, clear=True): - result = ConfigManager.has_garmin_credentials() - - assert result is False - - def test_has_garmin_credentials_none_set(self): - """Test checking for Garmin credentials when none are set.""" - with patch.dict(os.environ, {}, clear=True): - result = ConfigManager.has_garmin_credentials() - - assert result is False - - def test_has_garmin_credentials_empty_values(self): - """Test checking for Garmin credentials with empty string values.""" - with patch.dict(os.environ, {"GARMIN_EMAIL": "", "GARMIN_PASSWORD": ""}): - result = ConfigManager.has_garmin_credentials() - - assert result is False - - -if __name__ == "__main__": - pytest.main([__file__]) diff --git a/tests/test_mcp_metrics.py b/tests/test_mcp_metrics.py deleted file mode 100644 index b00da87..0000000 --- a/tests/test_mcp_metrics.py +++ /dev/null @@ -1,1320 +0,0 @@ -""" -Comprehensive tests for MCP metrics tools module. - -Tests all functions, classes, and edge cases to achieve 100% coverage. -""" - -from dataclasses import dataclass -from datetime import date -from typing import List, Optional -from unittest.mock import AsyncMock, Mock, patch - -import pytest -from fastmcp import Context - -from garmy.mcp.tools.metrics import DatedMetricData, MetricTools - - -# Test data classes for mocking -@dataclass -class MockMetricData: - """Mock metric data class for testing.""" - - value: float = 0.0 - quality: str = "good" - timestamp: Optional[str] = None - - -@dataclass -class MockActivityData: - """Mock activity data class for testing.""" - - activity_name: str = "Running" - activity_type_name: str = "running" - duration_minutes: float = 30.0 - average_hr: Optional[float] = 150.0 - start_date: str = "2023-12-01" - - -class TestDatedMetricData: - """Test suite for DatedMetricData class.""" - - def test_init(self): - """Test DatedMetricData initialization.""" - test_date = date(2023, 12, 1) - test_data = {"value": 123} - - dated_data = DatedMetricData(date=test_date, data=test_data) - - assert dated_data.date == test_date - assert dated_data.data == test_data - - def test_str_with_none_data(self): - """Test string representation with None data.""" - test_date = date(2023, 12, 1) - dated_data = DatedMetricData(date=test_date, data=None) - - result = str(dated_data) - - assert result == "**2023-12-01**: No data" - - def test_str_with_activities_data(self): - """Test string representation with activities data.""" - test_date = date(2023, 12, 1) - activities_data = {"activities": [MockActivityData()], "count": 1} - dated_data = DatedMetricData(date=test_date, data=activities_data) - - with patch.object( - dated_data, "_format_activities_data", return_value="formatted activities" - ): - result = str(dated_data) - - assert result == "formatted activities" - - def test_str_with_standard_data(self): - """Test string representation with standard metric data.""" - test_date = date(2023, 12, 1) - test_data = MockMetricData(value=123.45, quality="excellent") - dated_data = DatedMetricData(date=test_date, data=test_data) - - result = str(dated_data) - - expected = f"**{test_date}**:\n{test_data!s}" - assert result == expected - - def test_format_activities_data_no_activities(self): - """Test activities formatting with no activities.""" - test_date = date(2023, 12, 1) - activities_data = {"activities": [], "count": 0} - dated_data = DatedMetricData(date=test_date, data=activities_data) - - result = dated_data._format_activities_data() - - assert result == "**2023-12-01**: No activities" - - def test_format_activities_data_single_activity(self): - """Test activities formatting with single activity.""" - test_date = date(2023, 12, 1) - activity = MockActivityData( - activity_name="Morning Run", - activity_type_name="running", - duration_minutes=45.0, - average_hr=160.0, - ) - activities_data = {"activities": [activity], "count": 1} - dated_data = DatedMetricData(date=test_date, data=activities_data) - - result = dated_data._format_activities_data() - - expected_parts = [ - "**2023-12-01**: 1 activity", - "1. Morning Run (running) - 45 min - 160 bpm avg", - ] - for part in expected_parts: - assert part in result - - def test_format_activities_data_multiple_activities(self): - """Test activities formatting with multiple activities.""" - test_date = date(2023, 12, 1) - activities = [ - MockActivityData(activity_name="Morning Run", duration_minutes=30.0), - MockActivityData( - activity_name="Evening Walk", duration_minutes=15.0, average_hr=None - ), - ] - activities_data = {"activities": activities, "count": 2} - dated_data = DatedMetricData(date=test_date, data=activities_data) - - result = dated_data._format_activities_data() - - assert "**2023-12-01**: 2 activities" in result - assert "1. Morning Run" in result - assert "2. Evening Walk" in result - - def test_format_activities_data_unnamed_activity(self): - """Test activities formatting with unnamed activity.""" - test_date = date(2023, 12, 1) - activity = MockActivityData(activity_name=None) - activities_data = {"activities": [activity], "count": 1} - dated_data = DatedMetricData(date=test_date, data=activities_data) - - result = dated_data._format_activities_data() - - assert "Unnamed Activity" in result - - def test_format_activities_data_missing_attributes(self): - """Test activities formatting with missing attributes.""" - test_date = date(2023, 12, 1) - # Create activity with minimal attributes - activity = Mock() - activity.activity_name = "Test" - # Missing duration_minutes and average_hr attributes - activities_data = {"activities": [activity], "count": 1} - dated_data = DatedMetricData(date=test_date, data=activities_data) - - result = dated_data._format_activities_data() - - assert "Test (Unknown)" in result - - -class TestMetricTools: - """Test suite for MetricTools class.""" - - def setup_method(self): - """Set up test fixtures.""" - self.mock_server = Mock() - self.mock_server.config.max_history_days = 90 - self.metric_tools = MetricTools(self.mock_server) - self.mock_ctx = AsyncMock(spec=Context) - - def test_init(self): - """Test MetricTools initialization.""" - server = Mock() - metric_tools = MetricTools(server) - assert metric_tools.server is server - - def test_export_to_csv_empty_data(self): - """Test CSV export with empty data.""" - result = self.metric_tools._export_to_csv([], "test_metric") - assert result == "No data to export" - - def test_export_to_csv_with_dataclass_objects(self): - """Test CSV export with dataclass objects.""" - data_list = [ - MockMetricData(value=123.45, quality="good"), - MockMetricData(value=234.56, quality="excellent"), - ] - - result = self.metric_tools._export_to_csv(data_list, "test_metric") - - assert "value,quality,timestamp" in result - assert "123.45,good," in result - assert "234.56,excellent," in result - - def test_export_to_csv_with_complex_objects(self): - """Test CSV export with complex nested objects.""" - - @dataclass - class ComplexData: - simple_value: str - list_value: List[str] - dict_value: dict - none_value: Optional[str] - - data_list = [ - ComplexData( - simple_value="test", - list_value=["a", "b"], - dict_value={"key": "value"}, - none_value=None, - ) - ] - - result = self.metric_tools._export_to_csv(data_list, "complex_metric") - - assert '"[""a"", ""b""]"' in result or '["a", "b"]' in result - assert '{"key": "value"}' in result or '"{\\"key\\": \\"value\\"}"' in result - assert "test" in result - - def test_export_to_csv_fallback_simple_objects(self): - """Test CSV export fallback for simple objects.""" - data_list = ["simple_string", 123, {"not": "dataclass"}] - - result = self.metric_tools._export_to_csv(data_list, "simple_metric") - - assert "Simple Metric" in result - assert "simple_string" in result - assert "123" in result - - def test_export_dated_to_csv_empty_data(self): - """Test dated CSV export with empty data.""" - result = self.metric_tools._export_dated_to_csv([], "test_metric") - assert result == "No data to export" - - def test_export_dated_to_csv_activities_special_case(self): - """Test dated CSV export with activities (special case).""" - dated_data = [DatedMetricData(date=date.today(), data={"activities": []})] - - with patch.object( - self.metric_tools, - "_export_activities_to_csv", - return_value="activities csv", - ): - result = self.metric_tools._export_dated_to_csv(dated_data, "activities") - - assert result == "activities csv" - - def test_export_dated_to_csv_no_valid_data(self): - """Test dated CSV export with no valid data.""" - dated_data = [ - DatedMetricData(date=date(2023, 12, 1), data=None), - DatedMetricData(date=date(2023, 12, 2), data=None), - ] - - result = self.metric_tools._export_dated_to_csv(dated_data, "test_metric") - - assert "date,Test Metric" in result - assert "No data available" in result - - def test_export_dated_to_csv_with_valid_data(self): - """Test dated CSV export with valid dataclass data.""" - dated_data = [ - DatedMetricData(date=date(2023, 12, 1), data=MockMetricData(value=100.0)), - DatedMetricData(date=date(2023, 12, 2), data=None), - DatedMetricData(date=date(2023, 12, 3), data=MockMetricData(value=200.0)), - ] - - result = self.metric_tools._export_dated_to_csv(dated_data, "test_metric") - - assert "date,value,quality,timestamp" in result - assert "2023-12-03,100.0,good," in result # Most recent first (reversed) - assert "2023-12-02,," in result # Empty data row - assert "2023-12-01,200.0,good," in result - - def test_export_dated_to_csv_fallback_simple_objects(self): - """Test dated CSV export fallback for simple objects.""" - dated_data = [ - DatedMetricData(date=date(2023, 12, 1), data="simple_data"), - DatedMetricData(date=date(2023, 12, 2), data=None), - ] - - result = self.metric_tools._export_dated_to_csv(dated_data, "simple_metric") - - assert "date,Simple Metric" in result - assert "2023-12-02,No data" in result # Most recent first (reversed) - assert "2023-12-01,simple_data" in result - - def test_export_activities_to_csv_no_activities(self): - """Test activities CSV export with no activities.""" - dated_data = [] - - result = self.metric_tools._export_activities_to_csv(dated_data) - - assert "date,activity_name,activity_type,duration_minutes" in result - assert "No activities found" in result - - def test_export_activities_to_csv_with_activities(self): - """Test activities CSV export with activities.""" - activities = [ - MockActivityData(activity_name="Run", duration_minutes=30.0), - MockActivityData(activity_name="Walk", duration_minutes=15.0), - ] - dated_data = [ - DatedMetricData( - date=date(2023, 12, 1), data={"activities": activities, "count": 2} - ) - ] - - result = self.metric_tools._export_activities_to_csv(dated_data) - - assert ( - "date,activity_name,activity_type_name,duration_minutes,average_hr,start_date" - in result - ) - assert "2023-12-01,Run,running,30.0,150.0,2023-12-01" in result - assert "2023-12-01,Walk,running,15.0,150.0,2023-12-01" in result - - def test_export_activities_to_csv_sorted_by_date(self): - """Test activities CSV export sorting by date.""" - activity1 = MockActivityData(activity_name="Activity1") - activity2 = MockActivityData(activity_name="Activity2") - - dated_data = [ - DatedMetricData( - date=date(2023, 12, 2), data={"activities": [activity2], "count": 1} - ), - DatedMetricData( - date=date(2023, 12, 1), data={"activities": [activity1], "count": 1} - ), - ] - - result = self.metric_tools._export_activities_to_csv(dated_data) - - lines = result.strip().split("\n") - # Should be sorted chronologically (date ascending) - assert "2023-12-01,Activity1" in lines[1] # After header - assert "2023-12-02,Activity2" in lines[2] - - def test_export_activities_to_csv_complex_data(self): - """Test activities CSV export with complex data types.""" - - @dataclass - class ComplexActivity: - activity_name: str - list_data: List[str] - dict_data: dict - none_data: Optional[str] = None - - activity = ComplexActivity( - activity_name="Complex", - list_data=["a", "b"], - dict_data={"key": "value"}, - none_data=None, - ) - - dated_data = [ - DatedMetricData( - date=date(2023, 12, 1), data={"activities": [activity], "count": 1} - ) - ] - - result = self.metric_tools._export_activities_to_csv(dated_data) - - assert "Complex" in result - assert '["a", "b"]' in result or '"[""a"", ""b""]"' in result - - def test_get_metric_history_with_dates_standard_metric(self): - """Test getting metric history with dates for standard metrics.""" - self.mock_server.get_metric_data.side_effect = [ - MockMetricData(value=100.0), - MockMetricData(value=200.0), - None, # No data for one day - ] - - result = self.metric_tools._get_metric_history_with_dates( - "sleep", 3, date(2023, 12, 3) - ) - - assert len(result) == 3 - assert result[0].date == date(2023, 12, 3) # Most recent first - assert result[0].data.value == 100.0 - assert result[1].date == date(2023, 12, 2) - assert result[1].data.value == 200.0 - assert result[2].date == date(2023, 12, 1) - assert result[2].data is None - - def test_get_metric_history_with_dates_activities_special_case(self): - """Test getting metric history with dates for activities (special case).""" - with patch.object( - self.metric_tools, - "_get_activities_history_with_dates", - return_value="activities_result", - ): - result = self.metric_tools._get_metric_history_with_dates( - "activities", 7, date.today() - ) - - assert result == "activities_result" - - def test_get_metric_history_with_dates_default_end_date(self): - """Test getting metric history with default end date.""" - self.mock_server.get_metric_data.return_value = MockMetricData(value=123.0) - - with patch("garmy.mcp.tools.metrics.date") as mock_date: - mock_date.today.return_value = date(2023, 12, 10) - mock_date.side_effect = lambda *args: date( - *args - ) # Allow date() constructor - - result = self.metric_tools._get_metric_history_with_dates("steps", 2) - - assert len(result) == 2 - assert result[0].date == date(2023, 12, 10) - assert result[1].date == date(2023, 12, 9) - - def test_get_metric_history_with_dates_api_exception(self): - """Test getting metric history with API exceptions.""" - self.mock_server.get_metric_data.side_effect = [ - MockMetricData(value=100.0), - Exception("API Error"), - MockMetricData(value=300.0), - ] - - with patch("garmy.mcp.tools.metrics.logger") as mock_logger: - result = self.metric_tools._get_metric_history_with_dates( - "heart_rate", 3, date(2023, 12, 3) - ) - - assert len(result) == 3 - assert result[0].data.value == 100.0 - assert result[1].data is None # Exception handled - assert result[2].data.value == 300.0 - mock_logger.warning.assert_called_once() - - def test_get_activities_history_with_dates_success(self): - """Test getting activities history with successful API call.""" - mock_activity1 = Mock() - mock_activity1.start_date = "2023-12-01" - mock_activity2 = Mock() - mock_activity2.start_date = "2023-12-02" - mock_activity3 = Mock() - mock_activity3.start_date = "2023-11-30" # Outside range - - mock_activities_accessor = Mock() - mock_activities_accessor.list.return_value = [ - mock_activity1, - mock_activity2, - mock_activity3, - ] - - self.mock_server.api_client.metrics.get.return_value = mock_activities_accessor - - result = self.metric_tools._get_activities_history_with_dates( - 3, date(2023, 12, 2) - ) - - assert len(result) == 3 - # Check that activities are grouped by date - assert result[0].date == date(2023, 12, 2) # Most recent first - assert result[0].data["count"] == 1 - assert result[0].data["activities"] == [mock_activity2] - - assert result[1].date == date(2023, 12, 1) - assert result[1].data["count"] == 1 - assert result[1].data["activities"] == [mock_activity1] - - assert result[2].date == date(2023, 11, 30) - assert result[2].data is None # No activities this day (outside range) - - def test_get_activities_history_with_dates_no_accessor(self): - """Test getting activities history when accessor is not available.""" - self.mock_server.api_client.metrics.get.return_value = None - - result = self.metric_tools._get_activities_history_with_dates( - 3, date(2023, 12, 2) - ) - - assert result == [] - - def test_get_activities_history_with_dates_invalid_date_format(self): - """Test getting activities history with invalid activity date format.""" - mock_activity = Mock() - mock_activity.start_date = "invalid-date" - - mock_activities_accessor = Mock() - mock_activities_accessor.list.return_value = [mock_activity] - - self.mock_server.api_client.metrics.get.return_value = mock_activities_accessor - - with patch("garmy.mcp.tools.metrics.logger") as mock_logger: - result = self.metric_tools._get_activities_history_with_dates( - 1, date(2023, 12, 1) - ) - - assert len(result) == 1 - assert result[0].data is None # No valid activities - mock_logger.warning.assert_called() - - def test_get_activities_history_with_dates_api_exception(self): - """Test getting activities history with API exception.""" - self.mock_server.api_client.metrics.get.side_effect = Exception("API Error") - - with patch("garmy.mcp.tools.metrics.logger") as mock_logger: - result = self.metric_tools._get_activities_history_with_dates( - 2, date(2023, 12, 1) - ) - - assert len(result) == 2 - assert all(item.data is None for item in result) - mock_logger.error.assert_called_once() - - def test_get_activities_history_with_dates_default_end_date(self): - """Test getting activities history with default end date.""" - mock_activities_accessor = Mock() - mock_activities_accessor.list.return_value = [] - self.mock_server.api_client.metrics.get.return_value = mock_activities_accessor - - with patch("garmy.mcp.tools.metrics.date") as mock_date: - mock_date.today.return_value = date(2023, 12, 10) - mock_date.side_effect = lambda *args: date(*args) - - result = self.metric_tools._get_activities_history_with_dates(1) - - assert len(result) == 1 - assert result[0].date == date(2023, 12, 10) - - @pytest.mark.asyncio - async def test_list_available_metrics_no_metrics(self): - """Test listing available metrics when none are discovered.""" - self.mock_server.discovered_metrics = {} - - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.metric_tools.register_tools(mock_mcp) - - result = await tool_functions["list_available_metrics"](self.mock_ctx) - - assert "No metrics discovered" in result - - @pytest.mark.asyncio - async def test_list_available_metrics_with_metrics(self): - """Test listing available metrics with discovered metrics.""" - mock_config1 = Mock() - mock_config1.description = "Sleep data" - mock_config1.deprecated = False - - mock_config2 = Mock() - mock_config2.description = None - mock_config2.deprecated = True - - self.mock_server.discovered_metrics = { - "sleep": mock_config1, - "old_metric": mock_config2, - } - - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.metric_tools.register_tools(mock_mcp) - - result = await tool_functions["list_available_metrics"](self.mock_ctx) - - assert "**Sleep** (DEPRECATED)" not in result # Sleep is not deprecated - assert "**Old Metric** (DEPRECATED)" in result - assert "Sleep data" in result - assert "No description available" in result - self.mock_ctx.info.assert_called_once_with("Discovered 2 metrics") - - @pytest.mark.asyncio - async def test_get_metric_data_not_authenticated(self): - """Test getting metric data when not authenticated.""" - self.mock_server.is_authenticated.return_value = False - - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.metric_tools.register_tools(mock_mcp) - - result = await tool_functions["get_metric_data"]( - "sleep", "2023-12-01", self.mock_ctx - ) - - assert "Authentication required" in result - - @pytest.mark.asyncio - async def test_get_metric_data_success(self): - """Test successful metric data retrieval.""" - self.mock_server.is_authenticated.return_value = True - self.mock_server.get_metric_data.return_value = MockMetricData(value=123.45) - self.mock_server.format_metric_data.return_value = "Formatted data" - - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.metric_tools.register_tools(mock_mcp) - - result = await tool_functions["get_metric_data"]( - "sleep", "2023-12-01", self.mock_ctx - ) - - assert "**Sleep** for 2023-12-01" in result - assert "Formatted data" in result - self.mock_ctx.info.assert_any_call("Getting sleep data for 2023-12-01") - self.mock_ctx.info.assert_any_call("Successfully retrieved sleep data") - - @pytest.mark.asyncio - async def test_get_metric_data_no_data(self): - """Test metric data retrieval with no data.""" - self.mock_server.is_authenticated.return_value = True - self.mock_server.get_metric_data.return_value = None - - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.metric_tools.register_tools(mock_mcp) - - result = await tool_functions["get_metric_data"]( - "sleep", "2023-12-01", self.mock_ctx - ) - - assert "No sleep data for 2023-12-01" in result - - @pytest.mark.asyncio - async def test_get_metric_data_default_date(self): - """Test metric data retrieval with default date.""" - self.mock_server.is_authenticated.return_value = True - self.mock_server.get_metric_data.return_value = MockMetricData() - self.mock_server.format_metric_data.return_value = "Data" - - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.metric_tools.register_tools(mock_mcp) - - with patch("garmy.mcp.tools.metrics.date") as mock_date: - mock_date.today.return_value = date(2023, 12, 15) - mock_date.side_effect = lambda *args: date(*args) - - result = await tool_functions["get_metric_data"]( - "steps", None, self.mock_ctx - ) - - assert "2023-12-15" in result - - @pytest.mark.asyncio - async def test_get_metric_data_unknown_metric(self): - """Test metric data retrieval with unknown metric.""" - self.mock_server.is_authenticated.return_value = True - self.mock_server.get_metric_data.side_effect = ValueError( - "Unknown metric 'unknown'" - ) - self.mock_server.api_client.metrics.keys.return_value = ["sleep", "steps"] - - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.metric_tools.register_tools(mock_mcp) - - result = await tool_functions["get_metric_data"]( - "unknown", "2023-12-01", self.mock_ctx - ) - - assert "Unknown metric 'unknown'" in result - assert "Available: sleep, steps" in result - - @pytest.mark.asyncio - async def test_get_metric_data_other_value_error(self): - """Test metric data retrieval with other ValueError.""" - self.mock_server.is_authenticated.return_value = True - self.mock_server.get_metric_data.side_effect = ValueError("Invalid date format") - - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.metric_tools.register_tools(mock_mcp) - - result = await tool_functions["get_metric_data"]( - "sleep", "invalid-date", self.mock_ctx - ) - - assert "Error: Invalid date format" in result - - @pytest.mark.asyncio - async def test_get_metric_data_general_exception(self): - """Test metric data retrieval with general exception.""" - self.mock_server.is_authenticated.return_value = True - self.mock_server.get_metric_data.side_effect = Exception("API Error") - - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.metric_tools.register_tools(mock_mcp) - - result = await tool_functions["get_metric_data"]( - "sleep", "2023-12-01", self.mock_ctx - ) - - assert "Error: API Error" in result - self.mock_ctx.error.assert_called_once_with("Error getting data: API Error") - - @pytest.mark.asyncio - async def test_get_metric_history_success(self): - """Test successful metric history retrieval.""" - self.mock_server.is_authenticated.return_value = True - - mock_dated_data = [ - DatedMetricData(date=date(2023, 12, 3), data=MockMetricData(value=100.0)), - DatedMetricData(date=date(2023, 12, 2), data=MockMetricData(value=200.0)), - DatedMetricData(date=date(2023, 12, 1), data=None), - ] - - with patch.object( - self.metric_tools, - "_get_metric_history_with_dates", - return_value=mock_dated_data, - ): - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.metric_tools.register_tools(mock_mcp) - - result = await tool_functions["get_metric_history"]( - "sleep", 3, "2023-12-03", self.mock_ctx - ) - - assert "**Sleep History** (3 days until 2023-12-03)" in result - assert "Found records: 2 out of 3 days" in result - assert "**2023-12-03**" in result - assert "**2023-12-02**" in result - assert "**2023-12-01**: No data" in result - - @pytest.mark.asyncio - async def test_get_metric_history_no_data(self): - """Test metric history retrieval with no data.""" - self.mock_server.is_authenticated.return_value = True - - mock_dated_data = [DatedMetricData(date=date(2023, 12, 1), data=None)] - - with patch.object( - self.metric_tools, - "_get_metric_history_with_dates", - return_value=mock_dated_data, - ): - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.metric_tools.register_tools(mock_mcp) - - result = await tool_functions["get_metric_history"]( - "sleep", 1, None, self.mock_ctx - ) - - assert "No historical sleep data" in result - - @pytest.mark.asyncio - async def test_get_metric_history_large_dataset(self): - """Test metric history retrieval with large dataset (>10 records).""" - self.mock_server.is_authenticated.return_value = True - - # Create 15 records - mock_dated_data = [ - DatedMetricData(date=date(2023, 12, i), data=MockMetricData(value=i * 10.0)) - for i in range(1, 16) - ] - - with patch.object( - self.metric_tools, - "_get_metric_history_with_dates", - return_value=mock_dated_data, - ): - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.metric_tools.register_tools(mock_mcp) - - result = await tool_functions["get_metric_history"]( - "sleep", 15, None, self.mock_ctx - ) - - assert "Found records: 15 out of 15 days" in result - assert "... and 5 more days" in result # Should truncate to first 10 - - @pytest.mark.asyncio - async def test_get_metric_range_success(self): - """Test successful metric range retrieval.""" - self.mock_server.is_authenticated.return_value = True - - mock_dated_data = [ - DatedMetricData(date=date(2023, 12, 1), data=MockMetricData(value=100.0)), - DatedMetricData(date=date(2023, 12, 2), data=MockMetricData(value=200.0)), - ] - - with patch.object( - self.metric_tools, - "_get_metric_history_with_dates", - return_value=mock_dated_data, - ): - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.metric_tools.register_tools(mock_mcp) - - result = await tool_functions["get_metric_range"]( - "sleep", "2023-12-01", "2023-12-02", self.mock_ctx - ) - - assert "**Sleep** from 2023-12-01 to 2023-12-02" in result - assert "Found records: 2 out of 2 days" in result - - @pytest.mark.asyncio - async def test_get_metric_range_invalid_date_order(self): - """Test metric range retrieval with invalid date order.""" - self.mock_server.is_authenticated.return_value = True - - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.metric_tools.register_tools(mock_mcp) - - result = await tool_functions["get_metric_range"]( - "sleep", "2023-12-02", "2023-12-01", self.mock_ctx - ) - - assert "Error: Start date cannot be after end date" in result - - @pytest.mark.asyncio - async def test_get_metric_range_exceeds_max_days(self): - """Test metric range retrieval exceeding maximum days.""" - self.mock_server.is_authenticated.return_value = True - self.mock_server.config.max_history_days = 30 - - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.metric_tools.register_tools(mock_mcp) - - result = await tool_functions["get_metric_range"]( - "sleep", "2023-01-01", "2023-12-31", self.mock_ctx - ) - - assert "exceeds maximum allowed (30 days)" in result - - @pytest.mark.asyncio - async def test_get_metric_range_large_dataset(self): - """Test metric range retrieval with large dataset (>15 records).""" - self.mock_server.is_authenticated.return_value = True - - # Create 20 records - mock_dated_data = [ - DatedMetricData(date=date(2023, 12, i), data=MockMetricData(value=i * 10.0)) - for i in range(1, 21) - ] - - with patch.object( - self.metric_tools, - "_get_metric_history_with_dates", - return_value=mock_dated_data, - ): - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.metric_tools.register_tools(mock_mcp) - - result = await tool_functions["get_metric_range"]( - "sleep", "2023-12-01", "2023-12-20", self.mock_ctx - ) - - assert "**First 5 days:**" in result - assert "**Last 5 days:**" in result - assert "10 more days" in result - - @pytest.mark.asyncio - async def test_get_metric_range_invalid_date_format(self): - """Test metric range retrieval with invalid date format.""" - self.mock_server.is_authenticated.return_value = True - - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.metric_tools.register_tools(mock_mcp) - - result = await tool_functions["get_metric_range"]( - "sleep", "invalid-date", None, self.mock_ctx - ) - - assert "Error: Invalid date format. Use YYYY-MM-DD format." in result - - @pytest.mark.asyncio - async def test_export_metric_csv_success(self): - """Test successful metric CSV export.""" - self.mock_server.is_authenticated.return_value = True - - mock_dated_data = [ - DatedMetricData(date=date(2023, 12, 1), data=MockMetricData(value=100.0)), - DatedMetricData(date=date(2023, 12, 2), data=None), - ] - - with patch.object( - self.metric_tools, - "_get_metric_history_with_dates", - return_value=mock_dated_data, - ): - with patch.object( - self.metric_tools, "_export_dated_to_csv", return_value="CSV content" - ): - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.metric_tools.register_tools(mock_mcp) - - result = await tool_functions["export_metric_csv"]( - "sleep", 2, "2023-12-02", self.mock_ctx - ) - - assert "**Sleep CSV Export**" in result - assert "1 valid records out of 2 days" in result - assert "```csv" in result - assert "CSV content" in result - - @pytest.mark.asyncio - async def test_export_metric_csv_no_data(self): - """Test metric CSV export with no data.""" - self.mock_server.is_authenticated.return_value = True - - mock_dated_data = [DatedMetricData(date=date(2023, 12, 1), data=None)] - - with patch.object( - self.metric_tools, - "_get_metric_history_with_dates", - return_value=mock_dated_data, - ): - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.metric_tools.register_tools(mock_mcp) - - result = await tool_functions["export_metric_csv"]( - "sleep", 1, None, self.mock_ctx - ) - - assert "No sleep data to export" in result - - @pytest.mark.asyncio - async def test_export_metric_csv_exception(self): - """Test metric CSV export with exception.""" - self.mock_server.is_authenticated.return_value = True - - with patch.object( - self.metric_tools, - "_get_metric_history_with_dates", - side_effect=Exception("Export error"), - ): - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.metric_tools.register_tools(mock_mcp) - - result = await tool_functions["export_metric_csv"]( - "sleep", 1, None, self.mock_ctx - ) - - assert "Error: Export error" in result - self.mock_ctx.error.assert_called_once_with("Error exporting CSV: Export error") - - @pytest.mark.asyncio - async def test_get_metric_schema_success(self): - """Test successful metric schema retrieval.""" - mock_config = Mock() - mock_config.metric_class = MockMetricData - mock_config.description = "Test metric description" - mock_config.version = "2.0" - mock_config.requires_user_id = True - mock_config.deprecated = False - - self.mock_server.discovered_metrics = {"test_metric": mock_config} - - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.metric_tools.register_tools(mock_mcp) - - result = await tool_functions["get_metric_schema"]("test_metric", self.mock_ctx) - - assert "**Test Metric Schema**" in result - assert "**Class**: MockMetricData" in result - assert "**Description**: Test metric description" in result - assert "**Version**: 2.0" in result - assert "**Requires User ID**: True" in result - assert "**Deprecated**: False" in result - assert "**Available Fields**:" in result - assert "`value`:" in result - assert "`quality`:" in result - - @pytest.mark.asyncio - async def test_get_metric_schema_unknown_metric(self): - """Test metric schema retrieval for unknown metric.""" - self.mock_server.discovered_metrics = {"known_metric": Mock()} - - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.metric_tools.register_tools(mock_mcp) - - result = await tool_functions["get_metric_schema"]( - "unknown_metric", self.mock_ctx - ) - - assert "Unknown metric 'unknown_metric'" in result - assert "Available: known_metric" in result - - @pytest.mark.asyncio - async def test_get_metric_schema_with_properties(self): - """Test metric schema retrieval with computed properties.""" - - @dataclass - class MetricWithProperties: - value: float - - @property - def computed_value(self) -> float: - return self.value * 2 - - mock_config = Mock() - mock_config.metric_class = MetricWithProperties - mock_config.description = "Metric with properties" - mock_config.version = "1.0" - mock_config.requires_user_id = False - mock_config.deprecated = False - - self.mock_server.discovered_metrics = {"prop_metric": mock_config} - - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.metric_tools.register_tools(mock_mcp) - - result = await tool_functions["get_metric_schema"]("prop_metric", self.mock_ctx) - - assert "**Computed Properties**:" in result - assert "`computed_value`" in result - - @pytest.mark.asyncio - async def test_get_metric_schema_introspection_error(self): - """Test metric schema retrieval with introspection error.""" - mock_config = Mock() - # Create a class that will cause introspection to fail - mock_config.metric_class = Mock() - mock_config.metric_class.__name__ = "BadClass" - mock_config.metric_class.__dataclass_fields__ = ( - None # This will cause AttributeError - ) - mock_config.description = "Bad class" - mock_config.version = "1.0" - mock_config.requires_user_id = False - mock_config.deprecated = False - - self.mock_server.discovered_metrics = {"bad_metric": mock_config} - - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.metric_tools.register_tools(mock_mcp) - - result = await tool_functions["get_metric_schema"]("bad_metric", self.mock_ctx) - - assert "Schema introspection error:" in result - - @pytest.mark.asyncio - async def test_get_metric_schema_general_exception(self): - """Test metric schema retrieval with general exception.""" - self.mock_server.discovered_metrics = {"test_metric": Mock()} - - # Create mock MCP and register tools - mock_mcp = Mock() - tool_functions = {} - - def mock_tool(func=None): - def decorator(f): - tool_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.tool = mock_tool - self.metric_tools.register_tools(mock_mcp) - - # Make discovered_metrics access fail - with patch.object( - self.mock_server, - "discovered_metrics", - side_effect=Exception("Schema error"), - ): - result = await tool_functions["get_metric_schema"]( - "test_metric", self.mock_ctx - ) - - assert "Error: Schema error" in result - self.mock_ctx.error.assert_called_once_with( - "Error getting schema: Schema error" - ) - - -if __name__ == "__main__": - pytest.main([__file__]) diff --git a/tests/test_mcp_prompts.py b/tests/test_mcp_prompts.py deleted file mode 100644 index 8c4faf7..0000000 --- a/tests/test_mcp_prompts.py +++ /dev/null @@ -1,257 +0,0 @@ -""" -Comprehensive tests for MCP prompt templates module. - -Tests all prompt template functionality to achieve 100% coverage. -""" - -from unittest.mock import Mock - -import pytest - -from garmy.mcp.prompts.templates import PromptTemplates - - -class TestPromptTemplates: - """Test suite for PromptTemplates class.""" - - def setup_method(self): - """Set up test fixtures.""" - self.mock_server = Mock() - self.prompt_templates = PromptTemplates(self.mock_server) - - def test_init(self): - """Test PromptTemplates initialization.""" - server = Mock() - templates = PromptTemplates(server) - assert templates.server is server - - def test_register_prompts(self): - """Test prompt registration with MCP server.""" - mock_mcp = Mock() - prompt_functions = {} - - def mock_prompt(func=None): - def decorator(f): - prompt_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.prompt = mock_prompt - - self.prompt_templates.register_prompts(mock_mcp) - - # Verify that prompts were registered - expected_prompts = [ - "health_data_analysis", - "fitness_goal_planning", - "sleep_optimization", - "activity_summary", - ] - - for prompt_name in expected_prompts: - assert prompt_name in prompt_functions - - @pytest.mark.asyncio - async def test_health_data_analysis_prompt(self): - """Test health data analysis prompt template.""" - mock_mcp = Mock() - prompt_functions = {} - - def mock_prompt(func=None): - def decorator(f): - prompt_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.prompt = mock_prompt - - self.prompt_templates.register_prompts(mock_mcp) - - result = await prompt_functions["health_data_analysis"]( - "sleep", "7", "2023-12-01" - ) - - assert isinstance(result, str) - assert "analyze" in result.lower() - assert "sleep" in result.lower() - assert "7" in result - assert "2023-12-01" in result - assert "patterns" in result.lower() or "trends" in result.lower() - - @pytest.mark.asyncio - async def test_health_data_analysis_prompt_default_params(self): - """Test health data analysis prompt with default parameters.""" - mock_mcp = Mock() - prompt_functions = {} - - def mock_prompt(func=None): - def decorator(f): - prompt_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.prompt = mock_prompt - - self.prompt_templates.register_prompts(mock_mcp) - - result = await prompt_functions["health_data_analysis"]() - - assert isinstance(result, str) - assert "analyze" in result.lower() - # Should have default values - assert "health" in result.lower() - - @pytest.mark.asyncio - async def test_fitness_goal_planning_prompt(self): - """Test fitness goal planning prompt template.""" - mock_mcp = Mock() - prompt_functions = {} - - def mock_prompt(func=None): - def decorator(f): - prompt_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.prompt = mock_prompt - - self.prompt_templates.register_prompts(mock_mcp) - - result = await prompt_functions["fitness_goal_planning"]( - "weight loss", "10000 steps daily" - ) - - assert isinstance(result, str) - assert "goal" in result.lower() - assert "weight loss" in result.lower() - assert "10000 steps daily" in result.lower() - assert "plan" in result.lower() or "strategy" in result.lower() - - @pytest.mark.asyncio - async def test_fitness_goal_planning_prompt_default_params(self): - """Test fitness goal planning prompt with default parameters.""" - mock_mcp = Mock() - prompt_functions = {} - - def mock_prompt(func=None): - def decorator(f): - prompt_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.prompt = mock_prompt - - self.prompt_templates.register_prompts(mock_mcp) - - result = await prompt_functions["fitness_goal_planning"]() - - assert isinstance(result, str) - assert "fitness" in result.lower() - assert "goal" in result.lower() - - @pytest.mark.asyncio - async def test_sleep_optimization_prompt(self): - """Test sleep optimization prompt template.""" - mock_mcp = Mock() - prompt_functions = {} - - def mock_prompt(func=None): - def decorator(f): - prompt_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.prompt = mock_prompt - - self.prompt_templates.register_prompts(mock_mcp) - - result = await prompt_functions["sleep_optimization"]( - "6.5 hours", "frequent wakings" - ) - - assert isinstance(result, str) - assert "sleep" in result.lower() - assert "6.5 hours" in result.lower() - assert "frequent wakings" in result.lower() - assert "optimize" in result.lower() or "improve" in result.lower() - - @pytest.mark.asyncio - async def test_sleep_optimization_prompt_default_params(self): - """Test sleep optimization prompt with default parameters.""" - mock_mcp = Mock() - prompt_functions = {} - - def mock_prompt(func=None): - def decorator(f): - prompt_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.prompt = mock_prompt - - self.prompt_templates.register_prompts(mock_mcp) - - result = await prompt_functions["sleep_optimization"]() - - assert isinstance(result, str) - assert "sleep" in result.lower() - assert "optimize" in result.lower() or "improve" in result.lower() - - @pytest.mark.asyncio - async def test_activity_summary_prompt(self): - """Test activity summary prompt template.""" - mock_mcp = Mock() - prompt_functions = {} - - def mock_prompt(func=None): - def decorator(f): - prompt_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.prompt = mock_prompt - - self.prompt_templates.register_prompts(mock_mcp) - - result = await prompt_functions["activity_summary"]("2023-12-01", "2023-12-07") - - assert isinstance(result, str) - assert "activity" in result.lower() or "activities" in result.lower() - assert "summary" in result.lower() - assert "2023-12-01" in result - assert "2023-12-07" in result - - @pytest.mark.asyncio - async def test_activity_summary_prompt_default_params(self): - """Test activity summary prompt with default parameters.""" - mock_mcp = Mock() - prompt_functions = {} - - def mock_prompt(func=None): - def decorator(f): - prompt_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.prompt = mock_prompt - - self.prompt_templates.register_prompts(mock_mcp) - - result = await prompt_functions["activity_summary"]() - - assert isinstance(result, str) - assert "activity" in result.lower() or "activities" in result.lower() - assert "summary" in result.lower() - - -if __name__ == "__main__": - pytest.main([__file__]) diff --git a/tests/test_mcp_resources.py b/tests/test_mcp_resources.py deleted file mode 100644 index 435b6e1..0000000 --- a/tests/test_mcp_resources.py +++ /dev/null @@ -1,177 +0,0 @@ -""" -Comprehensive tests for MCP resource providers module. - -Tests all resource provider functionality to achieve 100% coverage. -""" - -from unittest.mock import Mock - -import pytest - -from garmy.mcp.resources.providers import ResourceProviders - - -class TestResourceProviders: - """Test suite for ResourceProviders class.""" - - def setup_method(self): - """Set up test fixtures.""" - self.mock_server = Mock() - self.resource_providers = ResourceProviders(self.mock_server) - - def test_init(self): - """Test ResourceProviders initialization.""" - server = Mock() - providers = ResourceProviders(server) - assert providers.server is server - - def test_register_resources(self): - """Test resource registration with MCP server.""" - mock_mcp = Mock() - resource_functions = {} - - def mock_resource(func=None): - def decorator(f): - resource_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.resource = mock_resource - - self.resource_providers.register_resources(mock_mcp) - - # Verify that resources were registered - expected_resources = [ - "garmin_health_data_schema", - "garmin_metric_definitions", - "garmin_api_endpoints", - ] - - for resource_name in expected_resources: - assert resource_name in resource_functions - - @pytest.mark.asyncio - async def test_garmin_health_data_schema(self): - """Test Garmin health data schema resource.""" - mock_mcp = Mock() - resource_functions = {} - - def mock_resource(func=None): - def decorator(f): - resource_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.resource = mock_resource - - self.resource_providers.register_resources(mock_mcp) - - result = await resource_functions["garmin_health_data_schema"]() - - assert isinstance(result, str) - assert "Garmin Connect Health Data Schema" in result - assert "sleep" in result.lower() - assert "steps" in result.lower() - assert "heart_rate" in result.lower() - - @pytest.mark.asyncio - async def test_garmin_metric_definitions(self): - """Test Garmin metric definitions resource.""" - # Mock discovered metrics - mock_config1 = Mock() - mock_config1.description = "Sleep data tracking" - mock_config1.version = "1.0" - mock_config1.deprecated = False - - mock_config2 = Mock() - mock_config2.description = "Daily step count" - mock_config2.version = "2.0" - mock_config2.deprecated = True - - self.mock_server.discovered_metrics = { - "sleep": mock_config1, - "steps": mock_config2, - } - - mock_mcp = Mock() - resource_functions = {} - - def mock_resource(func=None): - def decorator(f): - resource_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.resource = mock_resource - - self.resource_providers.register_resources(mock_mcp) - - result = await resource_functions["garmin_metric_definitions"]() - - assert isinstance(result, str) - assert "Available Garmin Health Metrics" in result - assert "Sleep" in result - assert "Steps" in result - assert "Sleep data tracking" in result - assert "Daily step count" in result - assert "Version: 1.0" in result - assert "Version: 2.0" in result - assert "DEPRECATED" in result # Should show deprecated status - - @pytest.mark.asyncio - async def test_garmin_metric_definitions_no_metrics(self): - """Test Garmin metric definitions resource with no discovered metrics.""" - self.mock_server.discovered_metrics = {} - - mock_mcp = Mock() - resource_functions = {} - - def mock_resource(func=None): - def decorator(f): - resource_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.resource = mock_resource - - self.resource_providers.register_resources(mock_mcp) - - result = await resource_functions["garmin_metric_definitions"]() - - assert isinstance(result, str) - assert "No metrics discovered" in result - - @pytest.mark.asyncio - async def test_garmin_api_endpoints(self): - """Test Garmin API endpoints resource.""" - mock_mcp = Mock() - resource_functions = {} - - def mock_resource(func=None): - def decorator(f): - resource_functions[f.__name__] = f - return f - - return decorator(func) if func else decorator - - mock_mcp.resource = mock_resource - - self.resource_providers.register_resources(mock_mcp) - - result = await resource_functions["garmin_api_endpoints"]() - - assert isinstance(result, str) - assert "Garmin Connect API Endpoints" in result - assert "Base URL:" in result - assert "connect.garmin.com" in result - assert "Authentication:" in result - assert "OAuth" in result - assert "Rate Limiting:" in result - - -if __name__ == "__main__": - pytest.main([__file__]) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py deleted file mode 100644 index 70db565..0000000 --- a/tests/test_mcp_server.py +++ /dev/null @@ -1,848 +0,0 @@ -""" -Comprehensive tests for MCP server module. - -Tests the main GarmyMCPServer class and all its functionality to achieve 100% coverage. -""" - -import logging -from dataclasses import dataclass -from io import StringIO -from unittest.mock import Mock, patch - -import anyio -import pytest - -try: - from builtins import BaseExceptionGroup # Python 3.11+ -except ImportError: - # Python < 3.11 compatibility - BaseExceptionGroup = Exception - -from garmy.mcp.config import MCPConfig -from garmy.mcp.server import GarmyMCPServer - - -@dataclass -class MockMetricData: - """Mock metric data for testing.""" - - value: float = 123.45 - quality: str = "good" - - def __str__(self): - return f"MockMetricData(value={self.value}, quality='{self.quality}')" - - -class TestGarmyMCPServer: - """Test suite for GarmyMCPServer class.""" - - def setup_method(self): - """Set up test fixtures.""" - self.mock_config = MCPConfig() - self.mock_config.debug_mode = False # Disable debug output for clean tests - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - def test_init_with_default_config(self, mock_fastmcp, mock_discovery): - """Test server initialization with default configuration.""" - mock_discovery.discover_metrics.return_value = {} - mock_discovery.validate_metrics.return_value = None - - server = GarmyMCPServer() - - assert server.config is not None - assert server.mcp is not None - assert server.auth_client is None - assert server.api_client is None - assert server.discovered_metrics == {} - mock_fastmcp.assert_called_once() - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - def test_init_with_custom_config(self, mock_fastmcp, mock_discovery): - """Test server initialization with custom configuration.""" - mock_discovery.discover_metrics.return_value = {} - mock_discovery.validate_metrics.return_value = None - - config = MCPConfig(server_name="Test Server", debug_mode=True) - server = GarmyMCPServer(config) - - assert server.config == config - mock_fastmcp.assert_called_once_with("Test Server") - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - @patch("garmy.mcp.server.logging.basicConfig") - def test_init_with_debug_mode( - self, mock_logging_config, mock_fastmcp, mock_discovery - ): - """Test server initialization with debug mode enabled.""" - mock_discovery.discover_metrics.return_value = {} - mock_discovery.validate_metrics.return_value = None - - config = MCPConfig(debug_mode=True) - GarmyMCPServer(config) - - mock_logging_config.assert_called_once_with(level=logging.DEBUG) - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - def test_discover_metrics_success(self, mock_fastmcp, mock_discovery): - """Test successful metric discovery.""" - mock_metrics = {"sleep": Mock(), "steps": Mock()} - mock_discovery.discover_metrics.return_value = mock_metrics - mock_discovery.validate_metrics.return_value = None - - with patch("garmy.mcp.server.logger") as mock_logger: - server = GarmyMCPServer(self.mock_config) - - assert server.discovered_metrics == mock_metrics - mock_discovery.discover_metrics.assert_called_once() - mock_discovery.validate_metrics.assert_called_once_with(mock_metrics) - mock_logger.info.assert_called_once_with("Discovered 2 metrics") - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - def test_discover_metrics_failure(self, mock_fastmcp, mock_discovery): - """Test metric discovery failure.""" - mock_discovery.discover_metrics.side_effect = Exception("Discovery failed") - - with patch("garmy.mcp.server.logger") as mock_logger: - server = GarmyMCPServer(self.mock_config) - - assert server.discovered_metrics == {} - mock_logger.error.assert_called_once_with( - "Error discovering metrics: Discovery failed" - ) - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - @patch("garmy.mcp.server.AuthTools") - def test_register_auth_tools( - self, mock_auth_tools_class, mock_fastmcp, mock_discovery - ): - """Test authentication tools registration.""" - mock_discovery.discover_metrics.return_value = {} - mock_discovery.validate_metrics.return_value = None - mock_auth_tools = Mock() - mock_auth_tools_class.return_value = mock_auth_tools - - config = MCPConfig(enable_auth_tools=True) - server = GarmyMCPServer(config) - - mock_auth_tools_class.assert_called_once_with(server) - mock_auth_tools.register_tools.assert_called_once_with(server.mcp) - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - @patch("garmy.mcp.server.MetricTools") - def test_register_metric_tools( - self, mock_metric_tools_class, mock_fastmcp, mock_discovery - ): - """Test metric tools registration.""" - mock_discovery.discover_metrics.return_value = {} - mock_discovery.validate_metrics.return_value = None - mock_metric_tools = Mock() - mock_metric_tools_class.return_value = mock_metric_tools - - config = MCPConfig(enable_metric_tools=True) - server = GarmyMCPServer(config) - - mock_metric_tools_class.assert_called_once_with(server) - mock_metric_tools.register_tools.assert_called_once_with(server.mcp) - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - @patch("garmy.mcp.server.AnalysisTools") - def test_register_analysis_tools( - self, mock_analysis_tools_class, mock_fastmcp, mock_discovery - ): - """Test analysis tools registration.""" - mock_discovery.discover_metrics.return_value = {} - mock_discovery.validate_metrics.return_value = None - mock_analysis_tools = Mock() - mock_analysis_tools_class.return_value = mock_analysis_tools - - config = MCPConfig(enable_analysis_tools=True) - server = GarmyMCPServer(config) - - mock_analysis_tools_class.assert_called_once_with(server) - mock_analysis_tools.register_tools.assert_called_once_with(server.mcp) - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - @patch("garmy.mcp.server.ResourceProviders") - def test_register_resources( - self, mock_resource_providers_class, mock_fastmcp, mock_discovery - ): - """Test resource providers registration.""" - mock_discovery.discover_metrics.return_value = {} - mock_discovery.validate_metrics.return_value = None - mock_resource_providers = Mock() - mock_resource_providers_class.return_value = mock_resource_providers - - config = MCPConfig(enable_resources=True) - server = GarmyMCPServer(config) - - mock_resource_providers_class.assert_called_once_with(server) - mock_resource_providers.register_resources.assert_called_once_with(server.mcp) - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - @patch("garmy.mcp.server.PromptTemplates") - def test_register_prompts( - self, mock_prompt_templates_class, mock_fastmcp, mock_discovery - ): - """Test prompt templates registration.""" - mock_discovery.discover_metrics.return_value = {} - mock_discovery.validate_metrics.return_value = None - mock_prompt_templates = Mock() - mock_prompt_templates_class.return_value = mock_prompt_templates - - config = MCPConfig(enable_prompts=True) - server = GarmyMCPServer(config) - - mock_prompt_templates_class.assert_called_once_with(server) - mock_prompt_templates.register_prompts.assert_called_once_with(server.mcp) - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - def test_initialize_components_all_disabled(self, mock_fastmcp, mock_discovery): - """Test component initialization with all features disabled.""" - mock_discovery.discover_metrics.return_value = {} - mock_discovery.validate_metrics.return_value = None - - config = MCPConfig( - enable_auth_tools=False, - enable_metric_tools=False, - enable_analysis_tools=False, - enable_resources=False, - enable_prompts=False, - ) - - # Should not raise any errors - server = GarmyMCPServer(config) - assert server is not None - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - @patch("garmy.mcp.server.AuthClient") - @patch("garmy.mcp.server.APIClient") - def test_authenticate_success( - self, - mock_api_client_class, - mock_auth_client_class, - mock_fastmcp, - mock_discovery, - ): - """Test successful authentication.""" - mock_discovery.discover_metrics.return_value = {} - mock_discovery.validate_metrics.return_value = None - - mock_auth_client = Mock() - mock_api_client = Mock() - mock_auth_client_class.return_value = mock_auth_client - mock_api_client_class.return_value = mock_api_client - - server = GarmyMCPServer(self.mock_config) - result = server.authenticate("test@example.com", "password123") - - assert result is True - assert server.auth_client == mock_auth_client - assert server.api_client == mock_api_client - mock_auth_client.login.assert_called_once_with( - "test@example.com", "password123" - ) - mock_api_client_class.assert_called_once_with(auth_client=mock_auth_client) - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - @patch("garmy.mcp.server.AuthClient") - def test_authenticate_failure( - self, mock_auth_client_class, mock_fastmcp, mock_discovery - ): - """Test authentication failure.""" - mock_discovery.discover_metrics.return_value = {} - mock_discovery.validate_metrics.return_value = None - - mock_auth_client = Mock() - mock_auth_client.login.side_effect = Exception("Login failed") - mock_auth_client_class.return_value = mock_auth_client - - with patch("garmy.mcp.server.logger") as mock_logger: - server = GarmyMCPServer(self.mock_config) - result = server.authenticate("test@example.com", "wrongpass") - - assert result is False - assert server.auth_client is None - assert server.api_client is None - mock_logger.error.assert_called_once_with("Authentication failed: Login failed") - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - def test_logout(self, mock_fastmcp, mock_discovery): - """Test logout functionality.""" - mock_discovery.discover_metrics.return_value = {} - mock_discovery.validate_metrics.return_value = None - - server = GarmyMCPServer(self.mock_config) - server.auth_client = Mock() - server.api_client = Mock() - - with patch("garmy.mcp.server.logger") as mock_logger: - server.logout() - - assert server.auth_client is None - assert server.api_client is None - mock_logger.info.assert_called_once_with("Logged out from Garmin Connect") - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - def test_is_authenticated_true(self, mock_fastmcp, mock_discovery): - """Test authentication status when authenticated.""" - mock_discovery.discover_metrics.return_value = {} - mock_discovery.validate_metrics.return_value = None - - server = GarmyMCPServer(self.mock_config) - server.auth_client = Mock() - server.api_client = Mock() - - assert server.is_authenticated() is True - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - def test_is_authenticated_false_no_auth_client(self, mock_fastmcp, mock_discovery): - """Test authentication status with no auth client.""" - mock_discovery.discover_metrics.return_value = {} - mock_discovery.validate_metrics.return_value = None - - server = GarmyMCPServer(self.mock_config) - server.auth_client = None - server.api_client = Mock() - - assert server.is_authenticated() is False - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - def test_is_authenticated_false_no_api_client(self, mock_fastmcp, mock_discovery): - """Test authentication status with no API client.""" - mock_discovery.discover_metrics.return_value = {} - mock_discovery.validate_metrics.return_value = None - - server = GarmyMCPServer(self.mock_config) - server.auth_client = Mock() - server.api_client = None - - assert server.is_authenticated() is False - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - def test_validate_authentication_success(self, mock_fastmcp, mock_discovery): - """Test authentication validation when authenticated.""" - mock_discovery.discover_metrics.return_value = {} - mock_discovery.validate_metrics.return_value = None - - server = GarmyMCPServer(self.mock_config) - server.auth_client = Mock() - server.api_client = Mock() - - # Should not raise an exception - server._validate_authentication() - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - def test_validate_authentication_failure(self, mock_fastmcp, mock_discovery): - """Test authentication validation when not authenticated.""" - mock_discovery.discover_metrics.return_value = {} - mock_discovery.validate_metrics.return_value = None - - server = GarmyMCPServer(self.mock_config) - server.auth_client = None - server.api_client = None - - with pytest.raises(ValueError, match="Authentication required"): - server._validate_authentication() - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - def test_validate_metric_name_success(self, mock_fastmcp, mock_discovery): - """Test metric name validation with valid metric.""" - mock_discovery.discover_metrics.return_value = {} - mock_discovery.validate_metrics.return_value = None - - server = GarmyMCPServer(self.mock_config) - server.api_client = Mock() - server.api_client.metrics.keys.return_value = ["sleep", "steps", "heart_rate"] - - # Should not raise an exception - server._validate_metric_name("sleep") - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - def test_validate_metric_name_failure(self, mock_fastmcp, mock_discovery): - """Test metric name validation with invalid metric.""" - mock_discovery.discover_metrics.return_value = {} - mock_discovery.validate_metrics.return_value = None - - server = GarmyMCPServer(self.mock_config) - server.api_client = Mock() - server.api_client.metrics.keys.return_value = ["sleep", "steps"] - - with pytest.raises(ValueError, match="Unknown metric 'invalid'"): - server._validate_metric_name("invalid") - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - def test_get_metric_data_success(self, mock_fastmcp, mock_discovery): - """Test successful metric data retrieval.""" - mock_discovery.discover_metrics.return_value = {} - mock_discovery.validate_metrics.return_value = None - - server = GarmyMCPServer(self.mock_config) - server.auth_client = Mock() - server.api_client = Mock() - - mock_metric_accessor = Mock() - mock_metric_data = MockMetricData(value=123.45) - mock_metric_accessor.get.return_value = mock_metric_data - server.api_client.metrics = {"sleep": mock_metric_accessor} - server.api_client.metrics.keys.return_value = ["sleep"] - - result = server.get_metric_data("sleep", "2023-12-01") - - assert result == mock_metric_data - mock_metric_accessor.get.assert_called_once_with("2023-12-01") - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - def test_get_metric_data_not_authenticated(self, mock_fastmcp, mock_discovery): - """Test metric data retrieval when not authenticated.""" - mock_discovery.discover_metrics.return_value = {} - mock_discovery.validate_metrics.return_value = None - - server = GarmyMCPServer(self.mock_config) - server.auth_client = None - server.api_client = None - - with pytest.raises(ValueError, match="Authentication required"): - server.get_metric_data("sleep", "2023-12-01") - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - def test_get_metric_data_invalid_metric(self, mock_fastmcp, mock_discovery): - """Test metric data retrieval with invalid metric name.""" - mock_discovery.discover_metrics.return_value = {} - mock_discovery.validate_metrics.return_value = None - - server = GarmyMCPServer(self.mock_config) - server.auth_client = Mock() - server.api_client = Mock() - server.api_client.metrics.keys.return_value = ["sleep"] - - with pytest.raises(ValueError, match="Unknown metric 'invalid'"): - server.get_metric_data("invalid", "2023-12-01") - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - def test_get_metric_history_success(self, mock_fastmcp, mock_discovery): - """Test successful metric history retrieval.""" - mock_discovery.discover_metrics.return_value = {} - mock_discovery.validate_metrics.return_value = None - - server = GarmyMCPServer(self.mock_config) - server.auth_client = Mock() - server.api_client = Mock() - - mock_metric_accessor = Mock() - mock_history_data = [MockMetricData(value=100.0), MockMetricData(value=200.0)] - mock_metric_accessor.list.return_value = mock_history_data - server.api_client.metrics = {"sleep": mock_metric_accessor} - server.api_client.metrics.keys.return_value = ["sleep"] - - result = server.get_metric_history("sleep", 7, "2023-12-01") - - assert result == mock_history_data - mock_metric_accessor.list.assert_called_once_with(end="2023-12-01", days=7) - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - def test_get_metric_history_limit_days(self, mock_fastmcp, mock_discovery): - """Test metric history retrieval with days limit.""" - mock_discovery.discover_metrics.return_value = {} - mock_discovery.validate_metrics.return_value = None - - config = MCPConfig(max_history_days=30) - server = GarmyMCPServer(config) - server.auth_client = Mock() - server.api_client = Mock() - - mock_metric_accessor = Mock() - mock_metric_accessor.list.return_value = [] - server.api_client.metrics = {"sleep": mock_metric_accessor} - server.api_client.metrics.keys.return_value = ["sleep"] - - server.get_metric_history( - "sleep", 100 - ) # Request 100 days, should be limited to 30 - - mock_metric_accessor.list.assert_called_once_with(end=None, days=30) - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - def test_format_metric_data_none(self, mock_fastmcp, mock_discovery): - """Test formatting None metric data.""" - mock_discovery.discover_metrics.return_value = {} - mock_discovery.validate_metrics.return_value = None - - server = GarmyMCPServer(self.mock_config) - - result = server.format_metric_data(None, "sleep") - assert result == "No data" - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - def test_format_metric_data_with_str_method(self, mock_fastmcp, mock_discovery): - """Test formatting metric data with custom __str__ method.""" - mock_discovery.discover_metrics.return_value = {} - mock_discovery.validate_metrics.return_value = None - - server = GarmyMCPServer(self.mock_config) - data = MockMetricData(value=123.45, quality="excellent") - - result = server.format_metric_data(data, "sleep") - assert "MockMetricData(value=123.45, quality='excellent')" in result - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - def test_format_metric_data_fallback_to_attributes( - self, mock_fastmcp, mock_discovery - ): - """Test formatting metric data falling back to attributes.""" - mock_discovery.discover_metrics.return_value = {} - mock_discovery.validate_metrics.return_value = None - - server = GarmyMCPServer(self.mock_config) - - # Create object without meaningful __str__ method - class SimpleData: - def __init__(self): - self.temperature = 98.6 - self.humidity = 65 - self.pressure = 1013.25 - - data = SimpleData() - - result = server.format_metric_data(data, "weather") - assert "Temperature: 98.6" in result - assert "Humidity: 65" in result - assert "Pressure: 1013.3" in result # Float formatting - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - def test_format_metric_data_string_type(self, mock_fastmcp, mock_discovery): - """Test formatting string metric data.""" - mock_discovery.discover_metrics.return_value = {} - mock_discovery.validate_metrics.return_value = None - - server = GarmyMCPServer(self.mock_config) - - # String data should use object attributes fallback - result = server.format_metric_data("simple string", "test") - assert result == "simple string" - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - def test_format_metric_data_exception(self, mock_fastmcp, mock_discovery): - """Test formatting metric data with exception.""" - mock_discovery.discover_metrics.return_value = {} - mock_discovery.validate_metrics.return_value = None - - server = GarmyMCPServer(self.mock_config) - - # Create object that will cause exception during formatting - class BadData: - def __str__(self): - raise ValueError("Formatting error") - - def __dict__(self): - raise ValueError("Dict access error") - - data = BadData() - - with patch("garmy.mcp.server.logger") as mock_logger: - result = server.format_metric_data(data, "bad_metric") - - assert "Formatting error:" in result - mock_logger.error.assert_called_once() - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - def test_format_object_attributes_compact(self, mock_fastmcp, mock_discovery): - """Test formatting object attributes in compact mode.""" - mock_discovery.discover_metrics.return_value = {} - mock_discovery.validate_metrics.return_value = None - - server = GarmyMCPServer(self.mock_config) - - class TestData: - def __init__(self): - self.field1 = "value1" - self.field2 = "value2" - self.field3 = "value3" - self.field4 = "value4" # Should be excluded in compact mode - - data = TestData() - - result = server._format_object_attributes(data, compact=True) - lines = result.split("\n") - assert len(lines) <= 3 # Compact mode should limit fields - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - def test_format_object_attributes_large_numbers(self, mock_fastmcp, mock_discovery): - """Test formatting object attributes with large numbers.""" - mock_discovery.discover_metrics.return_value = {} - mock_discovery.validate_metrics.return_value = None - - server = GarmyMCPServer(self.mock_config) - - class TestData: - def __init__(self): - self.large_int = 1234567 - self.float_val = 123.456789 - self.small_int = 42 - - data = TestData() - - result = server._format_object_attributes(data, compact=False) - assert "1,234,567" in result # Large integer formatting - assert "123.5" in result # Float formatting - assert "42" in result # Small integer - no formatting change - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - def test_format_object_attributes_none_values(self, mock_fastmcp, mock_discovery): - """Test formatting object attributes with None values.""" - mock_discovery.discover_metrics.return_value = {} - mock_discovery.validate_metrics.return_value = None - - server = GarmyMCPServer(self.mock_config) - - class TestData: - def __init__(self): - self.valid_field = "value" - self.none_field = None - self._private_field = "private" - - data = TestData() - - result = server._format_object_attributes(data, compact=False) - assert "Valid Field: value" in result - assert "none_field" not in result # None values should be excluded - assert "_private_field" not in result # Private fields should be excluded - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - def test_format_object_attributes_no_dict(self, mock_fastmcp, mock_discovery): - """Test formatting object without __dict__ attribute.""" - mock_discovery.discover_metrics.return_value = {} - mock_discovery.validate_metrics.return_value = None - - server = GarmyMCPServer(self.mock_config) - - # Use a simple type without __dict__ - data = 42 - - result = server._format_object_attributes(data, compact=False) - assert result == "42" - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - def test_format_object_attributes_empty_dict(self, mock_fastmcp, mock_discovery): - """Test formatting object with empty attributes.""" - mock_discovery.discover_metrics.return_value = {} - mock_discovery.validate_metrics.return_value = None - - server = GarmyMCPServer(self.mock_config) - - class EmptyData: - pass - - data = EmptyData() - - result = server._format_object_attributes(data, compact=False) - assert result == "Data available" # Fallback for empty attributes - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - def test_run_stdio_transport(self, mock_fastmcp, mock_discovery): - """Test running server with stdio transport.""" - mock_discovery.discover_metrics.return_value = {} - mock_discovery.validate_metrics.return_value = None - - server = GarmyMCPServer(self.mock_config) - - server.run(transport="stdio") - server.mcp.run.assert_called_once_with(transport="stdio") - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - def test_run_debug_mode(self, mock_fastmcp, mock_discovery): - """Test running server in debug mode.""" - mock_discovery.discover_metrics.return_value = {"sleep": Mock()} - mock_discovery.validate_metrics.return_value = None - - config = MCPConfig(debug_mode=True) - server = GarmyMCPServer(config) - - # Capture stderr output - with patch("sys.stderr", new_callable=StringIO) as mock_stderr: - server.run(transport="stdio") - - stderr_output = mock_stderr.getvalue() - assert "Starting Garmy MCP Server..." in stderr_output - assert "Transport: stdio" in stderr_output - assert "Discovered metrics: 1" in stderr_output - assert "Server ready!" in stderr_output - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - def test_run_broken_resource_error(self, mock_fastmcp, mock_discovery): - """Test running server with BrokenResourceError (normal disconnection).""" - mock_discovery.discover_metrics.return_value = {} - mock_discovery.validate_metrics.return_value = None - - config = MCPConfig(debug_mode=True) - server = GarmyMCPServer(config) - server.mcp.run.side_effect = anyio.BrokenResourceError("Client disconnected") - - with patch("sys.stderr", new_callable=StringIO) as mock_stderr: - server.run(transport="stdio") # Should not raise - - stderr_output = mock_stderr.getvalue() - assert "MCP client disconnected (normal)" in stderr_output - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - def test_run_exception_group_with_broken_resource( - self, mock_fastmcp, mock_discovery - ): - """Test running server with BaseExceptionGroup containing BrokenResourceError.""" - mock_discovery.discover_metrics.return_value = {} - mock_discovery.validate_metrics.return_value = None - - config = MCPConfig(debug_mode=True) - server = GarmyMCPServer(config) - - # Create exception group with BrokenResourceError - broken_error = anyio.BrokenResourceError("Connection broken") - exception_group = BaseExceptionGroup("Multiple errors", [broken_error]) - server.mcp.run.side_effect = exception_group - - with patch("sys.stderr", new_callable=StringIO) as mock_stderr: - server.run(transport="stdio") # Should not raise - - stderr_output = mock_stderr.getvalue() - assert "MCP client disconnected (normal)" in stderr_output - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - def test_run_exception_group_without_broken_resource( - self, mock_fastmcp, mock_discovery - ): - """Test running server with BaseExceptionGroup without BrokenResourceError.""" - mock_discovery.discover_metrics.return_value = {} - mock_discovery.validate_metrics.return_value = None - - server = GarmyMCPServer(self.mock_config) - - # Create exception group with other errors - other_error = ValueError("Some other error") - exception_group = BaseExceptionGroup("Multiple errors", [other_error]) - server.mcp.run.side_effect = exception_group - - with patch.object(server, "_log_error") as mock_log_error: - with pytest.raises(BaseExceptionGroup): - server.run(transport="stdio") - - mock_log_error.assert_called_once() - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - def test_run_general_exception(self, mock_fastmcp, mock_discovery): - """Test running server with general exception.""" - mock_discovery.discover_metrics.return_value = {} - mock_discovery.validate_metrics.return_value = None - - server = GarmyMCPServer(self.mock_config) - server.mcp.run.side_effect = RuntimeError("Server error") - - with patch.object(server, "_log_error") as mock_log_error: - with pytest.raises(RuntimeError): - server.run(transport="stdio") - - mock_log_error.assert_called_once_with( - "Error running MCP server: Server error", mock_log_error.call_args[0][1] - ) - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - def test_log_error_without_exception(self, mock_fastmcp, mock_discovery): - """Test error logging without exception.""" - mock_discovery.discover_metrics.return_value = {} - mock_discovery.validate_metrics.return_value = None - - server = GarmyMCPServer(self.mock_config) - - with patch("sys.stderr", new_callable=StringIO) as mock_stderr: - server._log_error("Test error message") - - stderr_output = mock_stderr.getvalue() - assert "Test error message" in stderr_output - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - def test_log_error_with_exception_debug_mode(self, mock_fastmcp, mock_discovery): - """Test error logging with exception in debug mode.""" - mock_discovery.discover_metrics.return_value = {} - mock_discovery.validate_metrics.return_value = None - - config = MCPConfig(debug_mode=True) - server = GarmyMCPServer(config) - - exception = ValueError("Test exception") - - with patch("sys.stderr", new_callable=StringIO) as mock_stderr: - with patch( - "garmy.mcp.server.traceback.print_exception" - ) as mock_print_exception: - server._log_error("Test error message", exception) - - stderr_output = mock_stderr.getvalue() - assert "Test error message" in stderr_output - mock_print_exception.assert_called_once() - - @patch("garmy.mcp.server.MetricDiscovery") - @patch("garmy.mcp.server.FastMCP") - def test_log_error_with_exception_no_debug(self, mock_fastmcp, mock_discovery): - """Test error logging with exception not in debug mode.""" - mock_discovery.discover_metrics.return_value = {} - mock_discovery.validate_metrics.return_value = None - - server = GarmyMCPServer(self.mock_config) # debug_mode=False - - exception = ValueError("Test exception") - - with patch("sys.stderr", new_callable=StringIO) as mock_stderr: - with patch( - "garmy.mcp.server.traceback.print_exception" - ) as mock_print_exception: - server._log_error("Test error message", exception) - - stderr_output = mock_stderr.getvalue() - assert "Test error message" in stderr_output - mock_print_exception.assert_not_called() # Should not print traceback - - -if __name__ == "__main__": - pytest.main([__file__])