0.3.11 with curl_cffi as optional tls extra (FreeBSD support)

This commit is contained in:
2026-09-11 18:41:55 -04:00
commit 2720927aa7
12 changed files with 11315 additions and 0 deletions

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020-2026 Ron Klinkien
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

558
PKG-INFO Normal file
View File

@ -0,0 +1,558 @@
Metadata-Version: 2.1
Name: garminconnect
Version: 0.3.11
Summary: Python 3 API wrapper for Garmin Connect
Keywords: garmin connect,api,garmin
Author-Email: Ron Klinkien <ron@cyberjunky.nl>
License: MIT
Classifier: Development Status :: 5 - Production/Stable
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: MacOS :: MacOS X
Classifier: Operating System :: Microsoft :: Windows
Classifier: Operating System :: POSIX :: Linux
Classifier: Operating System :: OS Independent
Project-URL: Homepage, https://github.com/cyberjunky/python-garminconnect
Project-URL: Issues, https://github.com/cyberjunky/python-garminconnect/issues
Project-URL: Changelog, https://github.com/cyberjunky/python-garminconnect/releases
Requires-Python: >=3.12
Requires-Dist: curl_cffi>=0.15.0
Requires-Dist: requests>=2.33.0
Requires-Dist: ua-generator>=1.0
Provides-Extra: dev
Requires-Dist: ipython; extra == "dev"
Requires-Dist: ipdb; extra == "dev"
Requires-Dist: ipykernel; extra == "dev"
Requires-Dist: pandas; extra == "dev"
Requires-Dist: matplotlib; extra == "dev"
Provides-Extra: workout
Requires-Dist: pydantic>=2.4.0; extra == "workout"
Provides-Extra: typed
Requires-Dist: pydantic>=2.4.0; extra == "typed"
Provides-Extra: security
Requires-Dist: bandit[toml]; extra == "security"
Requires-Dist: pip-audit; extra == "security"
Provides-Extra: linting
Requires-Dist: black[jupyter]; extra == "linting"
Requires-Dist: ruff; extra == "linting"
Requires-Dist: mypy; extra == "linting"
Requires-Dist: isort; extra == "linting"
Requires-Dist: types-requests; extra == "linting"
Requires-Dist: pre-commit; extra == "linting"
Requires-Dist: codespell; extra == "linting"
Provides-Extra: testing
Requires-Dist: coverage; extra == "testing"
Requires-Dist: pytest; extra == "testing"
Requires-Dist: pytest-vcr>=1.0.2; extra == "testing"
Requires-Dist: vcrpy>=7.0.0; extra == "testing"
Provides-Extra: example
Requires-Dist: readchar; extra == "example"
Description-Content-Type: text/markdown
[![GitHub Release][releases-shield]][releases]
[![GitHub Activity][commits-shield]][commits]
[![License][license-shield]](LICENSE)
![Project Maintenance][maintenance-shield]
[![Donate via PayPal](https://img.shields.io/badge/Donate-PayPal-blue.svg?style=for-the-badge&logo=paypal)](https://www.paypal.me/cyberjunkynl/)
[![Sponsor on GitHub](https://img.shields.io/badge/Sponsor-GitHub-red.svg?style=for-the-badge&logo=github)](https://github.com/sponsors/cyberjunky)
# Python: Garmin Connect
The Garmin Connect API library comes with two examples:
- **`example.py`** - Simple getting-started example showing authentication, token storage, and basic API calls
- **`demo.py`** - Comprehensive demo providing access to **130+ API methods** organized into **13 categories** for easy navigation
```bash
$ ./demo.py
📁 Exported data will be saved to the directory: 'your_data'
📄 All API responses are written to: 'response.json'
Attempting to login using stored tokens from: ~/.garminconnect
Successfully logged in using stored tokens!
📊 Your Stats Today: 4,045 steps | 1445.0 kcal
🐌 Time to get those legs moving!
==================================================
🚴 Full-blown Garmin Connect API Demo - Main Menu
==================================================
Select a category:
[1] 👤 User & Profile
[2] 📊 Daily Health & Activity
[3] 🔬 Advanced Health Metrics
[4] 📈 Historical Data & Trends
[5] 🏃 Activities & Workouts
[6] ⚖️ Body Composition & Weight
[7] 🏆 Goals & Achievements
[8] ⌚ Device & Technical
[9] 🎽 Gear & Equipment
[0] 💧 Hydration & Wellness
[a] 🔧 System & Export
[b] 📅 Training Plans
[c] ⛳ Golf
[d] ✏️ Activity Editing
[q] Exit program
Make your selection:
```
## API Coverage Statistics
- **Total API Methods**: 144+ unique endpoints (snapshot)
- **Categories**: 13 organized sections
- **User & Profile**: 4 methods (basic user info, settings)
- **Daily Health & Activity**: 10 methods (today's health data plus daily calories, resting HR and sleep ranges)
- **Advanced Health Metrics**: 16 methods (fitness metrics, HRV, VO2, FTP range, training readiness, training zones, running tolerance)
- **Historical Data & Trends**: 9 methods (date range queries, weekly aggregates)
- **Activities & Workouts**: 41 methods (comprehensive activity, workout management, typed workout uploads including strength, in-place edit, scheduling, push to device, import, edit description / exercise sets)
- **Body Composition & Weight**: 8 methods (weight tracking, body composition)
- **Goals & Achievements**: 15 methods (challenges, badges, goals)
- **Device & Technical**: 7 methods (device info, settings)
- **Gear & Equipment**: 7 methods (gear management, tracking)
- **Hydration & Wellness**: 12 methods (hydration, nutrition, blood pressure, menstrual)
- **System & Export**: 5 methods (reporting, logout, GraphQL, health snapshot download)
- **Training Plans**: 3 methods (plans, plan by ID, adaptive plan by ID)
- **Golf**: 5 methods (scorecard summary, scorecard detail, shot data, club stats, user stats)
### Interactive Features
- **Enhanced User Experience**: Categorized navigation with emoji indicators
- **Smart Data Management**: Interactive weigh-in deletion with search capabilities
- **Comprehensive Coverage**: All major Garmin Connect features are accessible
- **Error Handling**: Robust error handling with user-friendly prompts
- **Data Export**: JSON export functionality for all data types
[![Donate via PayPal](https://img.shields.io/badge/Donate-PayPal-blue.svg?style=for-the-badge&logo=paypal)](https://www.paypal.me/cyberjunkynl/)
[![Sponsor on GitHub](https://img.shields.io/badge/Sponsor-GitHub-red.svg?style=for-the-badge&logo=github)](https://github.com/sponsors/cyberjunky)
A comprehensive Python3 API wrapper for Garmin Connect, providing access to health, fitness, and device data.
## 📖 About
This library enables developers to programmatically access Garmin Connect data including:
- **Health Metrics**: Heart rate, sleep, stress, body composition, SpO2, HRV
- **Activity Data**: Workouts, typed workout uploads (running, cycling, swimming, walking, hiking, strength), workout scheduling, exercises, training status, performance metrics, import-style uploads (no Strava re-export)
- **Nutrition**: Daily food logs, meals, and nutrition settings
- **Golf**: Scorecard summaries, scorecard details, shot-by-shot data
- **Device Information**: Connected devices, settings, alarms, solar data
- **Goals & Achievements**: Personal records, badges, challenges, race predictions
- **Historical Data**: Trends, progress tracking, date range queries
Compatible with all Garmin Connect accounts. See <https://connect.garmin.com/>
## 📦 Installation
Requires Python 3.12 or later.
Install from PyPI:
```bash
pip install --upgrade garminconnect curl_cffi
```
## Run demo software (recommended)
Clone the repo, then:
```bash
python3 -m venv .venv --copies
source .venv/bin/activate # On Windows: .venv\Scripts\activate
pip install -e ".[example]"
python3 ./example.py # simple getting-started example
python3 ./demo.py # comprehensive demo (130+ API methods)
```
## 🛠️ Development
This project uses [PDM](https://pdm.fming.dev/) for dependency management and task automation.
> **⚠️ Important**: Create a virtual environment first on externally-managed Python installs (Debian/Ubuntu) to avoid system package conflicts.
```bash
python3 -m venv .venv --copies
source .venv/bin/activate # On Windows: .venv\Scripts\activate
pip install pdm
python3 -m pdm install --group :all
pre-commit install --install-hooks # optional but recommended
```
> **Note**: Using `python -m pdm` instead of `pdm` avoids PATH issues on some
> Windows setups where `pip install pdm` places the `pdm` executable outside
> the directories on `PATH`. Once `pdm install` has run, subsequent `pdm run ...`
> commands work normally because the venv's `Scripts/` directory is on `PATH`
> while the venv is active.
**Development commands:**
```bash
pdm run format # Auto-format code (isort, black, ruff --fix)
pdm run lint # Check code quality (isort, ruff, black, mypy)
pdm run codespell # Check spelling
pdm run test # Run test suite
pdm run testcov # Run tests with coverage report
pdm run all # Run all checks (lint + codespell + pre-commit + test)
pdm run clean # Clean build artifacts and cache files
pdm run build # Build package for distribution
pdm run publish # Build and publish to PyPI
pdm run --list # Show all available commands
```
Run `pdm run format && pdm run lint && pdm run test` before submitting PRs.
## 🔐 Authentication
Authentication uses the same mobile SSO flow as the official Garmin Connect Android app.
No browser is needed.
**How it works:**
1. **First login**: Authenticates via `sso.garmin.com/mobile/api/login` using the Android
app's client ID. If MFA is required, a callback (`prompt_mfa`) prompts for the one-time code.
2. **Token exchange**: The service ticket is exchanged for DI OAuth Bearer tokens
(`access_token` + `refresh_token`) via `diauth.garmin.com`. Tokens are stored at
`~/.garminconnect/garmin_tokens.json`.
3. **Auto-refresh**: Before each API request the library checks whether the DI token is about
to expire and refreshes it automatically — no user interaction required.
**Session lifetime:**
- DI tokens auto-refresh indefinitely as long as the refresh token remains valid.
- A full re-login with credentials (and possibly MFA) is only needed if the refresh token
itself expires or is revoked.
**Token storage:**
```bash
~/.garminconnect/garmin_tokens.json # saved automatically, mode 0600
```
The containing directory is restricted to mode `0700`. Treat the token file
like a password: the refresh token can provide persistent account access. Avoid
putting a Garmin password in shell history or a long-lived environment variable;
prefer `getpass()` or another interactive secret prompt.
**Resilient login (multi-strategy + token validation):**
`login()` tries several authentication strategies in order (mobile, SSO widget,
web portal — each with and without TLS impersonation) and only declares success
when the resulting token is actually accepted by the API. If a strategy obtains
a token the API later rejects (a region/account-specific condition — see
[#369](https://github.com/cyberjunky/python-garminconnect/issues/369)), the
library transparently falls through to the next strategy. Set
`Garmin(..., verify_login=False)` to restore the legacy "first token wins"
behavior.
**Cached-token gotcha & self-healing:** when a `tokenstore` is supplied,
`login()` loads those tokens *before* the strategy chain and short-circuits if
they load — so stale/poisoned cached tokens used to fail every run. The library
now detects this: if cached tokens are rejected by the API, it discards them and
performs a fresh credential login automatically. To force a clean slate yourself
(e.g. between a failed resume and a retry), call:
```python
g.logout() # clears in-memory auth + cached tokens (uses GARMINTOKENS)
g.logout(tokenstore) # or pass an explicit path
```
`logout()` removes only the local `garmin_tokens.json` file and preserves its
directory and unrelated files. It does **not** revoke a token that has already
been issued by Garmin. Revoke account access from Garmin's account/security
settings if a token may have been copied or exposed.
### What running it locally does
This is an unofficial client for Garmin's web services; it does not pair with
the Garmin Connect phone app or connect directly to a watch. When you call
`login()`, your credentials and MFA code are sent over HTTPS to Garmin's login
service. The library receives an access/refresh token pair and, when a token
store is supplied, caches it locally for later sessions. Subsequent API methods
send that token to Garmin and can read or change the same account data that the
selected method targets.
The library does not automatically download an entire account. The demo writes
responses, activity downloads, and health reports only when you select those
actions. Demo exports are stored under `your_data/` with owner-only directory
and file permissions. Run the project in a dedicated virtual environment, read
the method you plan to call, and start with read-only methods. Upload, edit,
delete, schedule, hydration, and weigh-in methods can change Garmin account data.
## 🧪 Testing
The default suite is credential-free and excludes live-account integration tests:
```bash
pdm run test # Run all tests
pdm run testcov # Run tests with coverage report
```
To explicitly run live integration tests, use a test account if possible. This
can create local VCR recordings and includes methods that may mutate the account:
```bash
export GARMIN_EMAIL="you@example.com"
read -s GARMIN_PASSWORD && export GARMIN_PASSWORD
pdm run pytest -m integration --vcr-record=once
unset GARMIN_PASSWORD
```
VCR recordings are ignored by Git because Garmin responses contain sensitive
health, activity, location, and account data. Do not commit them.
## 📦 Publishing
For package maintainers:
**Setup PyPI credentials:**
```bash
pip install twine
# Edit with your preferred editor, or create via here-doc:
# cat > ~/.pypirc <<'EOF'
# [pypi]
# username = __token__
# password = <PyPI_API_TOKEN>
# EOF
```
```ini
[pypi]
username = __token__
password = <PyPI_API_TOKEN>
```
Recommended: use environment variables and restrict file perms
```bash
chmod 600 ~/.pypirc
export TWINE_USERNAME="__token__"
export TWINE_PASSWORD="<PyPI_API_TOKEN>"
```
**Publish new version:**
```bash
pdm run publish # Build and publish to PyPI
```
**Alternative publishing steps:**
```bash
pdm run build # Build package only
pdm publish # Publish pre-built package
```
## 🤝 Contributing
We welcome contributions! Here's how you can help:
- **Report Issues**: Bug reports and feature requests via GitHub issues
- **Submit PRs**: Code improvements, new features, documentation updates
- **Testing**: Help test new features and report compatibility issues
- **Documentation**: Improve examples, add use cases, fix typos
**Before contributing:**
1. Set up your dev environment (see [Development](#️-development) above)
2. Format and lint: `pdm run format && pdm run lint`
3. Run tests: `pdm run test`
4. Follow existing code style and patterns
### Jupyter Notebook
Explore the API interactively with our [reference notebook](https://github.com/cyberjunky/python-garminconnect/blob/master/docs/reference.ipynb).
### Python Code Examples
```python
import os
from getpass import getpass
from datetime import date
from garminconnect import Garmin
# First run: logs in and saves tokens to ~/.garminconnect
# Subsequent runs: loads saved tokens and auto-refreshes
client = Garmin(
os.getenv("GARMIN_EMAIL"),
getpass("Garmin password: "),
prompt_mfa=lambda: input("MFA code: "),
)
client.login("~/.garminconnect")
# Get today's stats
today = date.today().isoformat()
stats = client.get_stats(today)
# Get heart rate data
hr_data = client.get_heart_rates(today)
print(f"Resting HR: {hr_data.get('restingHeartRate', 'n/a')}")
```
### Typed Workouts (Pydantic Models)
The library includes optional typed workout models for creating type-safe workout definitions:
```bash
pip install garminconnect[workout]
```
```python
from garminconnect.workout import (
RunningWorkout,
WorkoutSegment,
create_warmup_step,
create_interval_step,
create_distance_interval_step,
create_cooldown_step,
create_repeat_group,
)
# Create a structured running workout
workout = RunningWorkout(
workoutName="Easy Run",
estimatedDurationInSecs=1800,
workoutSegments=[
WorkoutSegment(
segmentOrder=1,
sportType={"sportTypeId": 1, "sportTypeKey": "running"},
workoutSteps=[create_warmup_step(300.0)],
)
],
)
# Upload and optionally schedule it
result = client.upload_running_workout(workout)
client.schedule_workout(result["workoutId"], "2026-03-20")
# Edit it in place - keeps its id, so any schedules pointing at it stay valid
workout_data = client.get_workout_by_id(result["workoutId"])
workout_data["workoutName"] = "Easy Run (revised)"
client.update_workout(result["workoutId"], workout_data)
# Delete a workout or remove it from the calendar
client.delete_workout(workout_id)
client.unschedule_workout(scheduled_workout_id)
# Push a workout to a device - defaults to the last workout / last used device
client.push_workout_to_device(result["workoutId"], device_id)
```
**Available workout classes:** `RunningWorkout`, `CyclingWorkout`, `SwimmingWorkout`, `WalkingWorkout`, `HikingWorkout`, `StrengthWorkout`, `MultiSportWorkout`, `FitnessEquipmentWorkout`
**Strength workouts** are rep-based. Build each exercise with `create_strength_set(category, step_order, sets, reps, rest_seconds, exercise_name="", weight_kg=None)` and identify exercises with a `category` / `exercise` pair from the bundled catalog in `garminconnect.exercises` (1,527 exercises across 47 categories, with `resolve(name)` and `find(term)` helpers):
```python
from garminconnect import exercises
from garminconnect.workout import StrengthWorkout, WorkoutSegment, create_strength_set
lat = exercises.resolve(
"Lat Pull-down"
) # {'category': 'PULL_UP', 'exercise': 'LAT_PULLDOWN'}
workout = StrengthWorkout(
workoutName="Upper Body",
estimatedDurationInSecs=0,
workoutSegments=[
WorkoutSegment(
segmentOrder=1,
sportType={"sportTypeId": 5, "sportTypeKey": "strength_training"},
workoutSteps=[
create_strength_set(
"BENCH_PRESS", step_order=1, sets=4, reps=10, rest_seconds=120
),
create_strength_set(
lat["category"],
step_order=4,
sets=3,
reps=12,
rest_seconds=90,
exercise_name=lat["exercise"],
),
],
)
],
)
client.upload_strength_workout(workout)
```
**Helper functions:** `create_warmup_step`, `create_interval_step`, `create_distance_interval_step`, `create_recovery_step`, `create_cooldown_step`, `create_repeat_group`, `create_strength_exercise_step`, `create_strength_rest_step`, `create_strength_set`
Use `create_distance_interval_step(600.0, step_order=1)` for interval steps that should end after a distance in meters instead of after a duration.
### Golf
The library supports reading Garmin Golf scorecards, shot-level data, club statistics, and golf user statistics.
```python
# List recent golf scorecards
summary = client.get_golf_summary(limit=10)
scorecard_id = summary["scorecardSummaries"][0]["id"]
# Get the full scorecard details
scorecard = client.get_golf_scorecard(scorecard_id)
# Get shot-by-shot data
# - Omit hole_numbers to receive every hole (recommended for holes 10-18).
# - Single-digit holes work as a comma- or dash-separated list, e.g. "1,2,3".
# Commas are normalized to dashes because Garmin only accepts '-' as the
# separator.
# - Double-digit holes (10-18) cannot be fetched with a filter at all; the
# API either drops them or returns an empty response. If you request holes
# 10-18, the library falls back to returning all 18 holes.
shots = client.get_golf_shot_data(scorecard_id)
shots = client.get_golf_shot_data(scorecard_id, hole_numbers="1,2,3")
# Club and player statistics
club_stats = client.get_golf_club_stats()
user_stats = client.get_golf_user_stats()
```
### Additional Resources
- **Simple Example**: [example.py](https://raw.githubusercontent.com/cyberjunky/python-garminconnect/master/example.py) - Getting started guide
- **Comprehensive Demo**: [demo.py](https://raw.githubusercontent.com/cyberjunky/python-garminconnect/master/demo.py) - All 130+ API methods
- **API Documentation**: Comprehensive method documentation in source code
- **Test Cases**: Real-world usage examples in `tests/` directory
## 🙏 Acknowledgments
Special thanks to all contributors who have helped improve this project:
- **Community Contributors**: Bug reports, feature requests, and code improvements
- **Issue Reporters**: Helping identify and resolve compatibility issues
- **Feature Developers**: Adding new API endpoints and functionality
- **Documentation Authors**: Improving examples and user guides
This project thrives thanks to community involvement and feedback.
## 💖 Support This Project
If you find this library useful for your projects, please consider supporting its continued development and maintenance:
### 🌟 Ways to Support
- **⭐ Star this repository** - Help others discover the project
- **💰 Financial Support** - Contribute to development and hosting costs
- **🐛 Report Issues** - Help improve stability and compatibility
- **📖 Spread the Word** - Share with other developers
### 💳 Financial Support Options
[![Donate via PayPal](https://img.shields.io/badge/Donate-PayPal-blue.svg?style=for-the-badge&logo=paypal)](https://www.paypal.me/cyberjunkynl/)
[![Sponsor on GitHub](https://img.shields.io/badge/Sponsor-GitHub-red.svg?style=for-the-badge&logo=github)](https://github.com/sponsors/cyberjunky)
**Why Support?**
- Keeps the project actively maintained
- Enables faster bug fixes and new features
- Supports infrastructure costs (testing, AI, CI/CD)
- Shows appreciation for hundreds of hours of development
Every contribution, no matter the size, makes a difference and is greatly appreciated! 🙏
[releases-shield]: https://img.shields.io/github/release/cyberjunky/python-garminconnect.svg?style=for-the-badge
[releases]: https://github.com/cyberjunky/python-garminconnect/releases
[commits-shield]: https://img.shields.io/github/commit-activity/y/cyberjunky/python-garminconnect.svg?style=for-the-badge
[commits]: https://github.com/cyberjunky/python-garminconnect/commits/master
[license-shield]: https://img.shields.io/github/license/cyberjunky/python-garminconnect.svg?style=for-the-badge
[maintenance-shield]: https://img.shields.io/badge/maintainer-cyberjunky-blue.svg?style=for-the-badge

506
README.md Normal file
View File

@ -0,0 +1,506 @@
[![GitHub Release][releases-shield]][releases]
[![GitHub Activity][commits-shield]][commits]
[![License][license-shield]](LICENSE)
![Project Maintenance][maintenance-shield]
[![Donate via PayPal](https://img.shields.io/badge/Donate-PayPal-blue.svg?style=for-the-badge&logo=paypal)](https://www.paypal.me/cyberjunkynl/)
[![Sponsor on GitHub](https://img.shields.io/badge/Sponsor-GitHub-red.svg?style=for-the-badge&logo=github)](https://github.com/sponsors/cyberjunky)
# Python: Garmin Connect
The Garmin Connect API library comes with two examples:
- **`example.py`** - Simple getting-started example showing authentication, token storage, and basic API calls
- **`demo.py`** - Comprehensive demo providing access to **130+ API methods** organized into **13 categories** for easy navigation
```bash
$ ./demo.py
📁 Exported data will be saved to the directory: 'your_data'
📄 All API responses are written to: 'response.json'
Attempting to login using stored tokens from: ~/.garminconnect
Successfully logged in using stored tokens!
📊 Your Stats Today: 4,045 steps | 1445.0 kcal
🐌 Time to get those legs moving!
==================================================
🚴 Full-blown Garmin Connect API Demo - Main Menu
==================================================
Select a category:
[1] 👤 User & Profile
[2] 📊 Daily Health & Activity
[3] 🔬 Advanced Health Metrics
[4] 📈 Historical Data & Trends
[5] 🏃 Activities & Workouts
[6] ⚖️ Body Composition & Weight
[7] 🏆 Goals & Achievements
[8] ⌚ Device & Technical
[9] 🎽 Gear & Equipment
[0] 💧 Hydration & Wellness
[a] 🔧 System & Export
[b] 📅 Training Plans
[c] ⛳ Golf
[d] ✏️ Activity Editing
[q] Exit program
Make your selection:
```
## API Coverage Statistics
- **Total API Methods**: 144+ unique endpoints (snapshot)
- **Categories**: 13 organized sections
- **User & Profile**: 4 methods (basic user info, settings)
- **Daily Health & Activity**: 10 methods (today's health data plus daily calories, resting HR and sleep ranges)
- **Advanced Health Metrics**: 16 methods (fitness metrics, HRV, VO2, FTP range, training readiness, training zones, running tolerance)
- **Historical Data & Trends**: 9 methods (date range queries, weekly aggregates)
- **Activities & Workouts**: 41 methods (comprehensive activity, workout management, typed workout uploads including strength, in-place edit, scheduling, push to device, import, edit description / exercise sets)
- **Body Composition & Weight**: 8 methods (weight tracking, body composition)
- **Goals & Achievements**: 15 methods (challenges, badges, goals)
- **Device & Technical**: 7 methods (device info, settings)
- **Gear & Equipment**: 7 methods (gear management, tracking)
- **Hydration & Wellness**: 12 methods (hydration, nutrition, blood pressure, menstrual)
- **System & Export**: 5 methods (reporting, logout, GraphQL, health snapshot download)
- **Training Plans**: 3 methods (plans, plan by ID, adaptive plan by ID)
- **Golf**: 5 methods (scorecard summary, scorecard detail, shot data, club stats, user stats)
### Interactive Features
- **Enhanced User Experience**: Categorized navigation with emoji indicators
- **Smart Data Management**: Interactive weigh-in deletion with search capabilities
- **Comprehensive Coverage**: All major Garmin Connect features are accessible
- **Error Handling**: Robust error handling with user-friendly prompts
- **Data Export**: JSON export functionality for all data types
[![Donate via PayPal](https://img.shields.io/badge/Donate-PayPal-blue.svg?style=for-the-badge&logo=paypal)](https://www.paypal.me/cyberjunkynl/)
[![Sponsor on GitHub](https://img.shields.io/badge/Sponsor-GitHub-red.svg?style=for-the-badge&logo=github)](https://github.com/sponsors/cyberjunky)
A comprehensive Python3 API wrapper for Garmin Connect, providing access to health, fitness, and device data.
## 📖 About
This library enables developers to programmatically access Garmin Connect data including:
- **Health Metrics**: Heart rate, sleep, stress, body composition, SpO2, HRV
- **Activity Data**: Workouts, typed workout uploads (running, cycling, swimming, walking, hiking, strength), workout scheduling, exercises, training status, performance metrics, import-style uploads (no Strava re-export)
- **Nutrition**: Daily food logs, meals, and nutrition settings
- **Golf**: Scorecard summaries, scorecard details, shot-by-shot data
- **Device Information**: Connected devices, settings, alarms, solar data
- **Goals & Achievements**: Personal records, badges, challenges, race predictions
- **Historical Data**: Trends, progress tracking, date range queries
Compatible with all Garmin Connect accounts. See <https://connect.garmin.com/>
## 📦 Installation
Requires Python 3.12 or later.
Install from PyPI:
```bash
pip install --upgrade garminconnect curl_cffi
```
## Run demo software (recommended)
Clone the repo, then:
```bash
python3 -m venv .venv --copies
source .venv/bin/activate # On Windows: .venv\Scripts\activate
pip install -e ".[example]"
python3 ./example.py # simple getting-started example
python3 ./demo.py # comprehensive demo (130+ API methods)
```
## 🛠️ Development
This project uses [PDM](https://pdm.fming.dev/) for dependency management and task automation.
> **⚠️ Important**: Create a virtual environment first on externally-managed Python installs (Debian/Ubuntu) to avoid system package conflicts.
```bash
python3 -m venv .venv --copies
source .venv/bin/activate # On Windows: .venv\Scripts\activate
pip install pdm
python3 -m pdm install --group :all
pre-commit install --install-hooks # optional but recommended
```
> **Note**: Using `python -m pdm` instead of `pdm` avoids PATH issues on some
> Windows setups where `pip install pdm` places the `pdm` executable outside
> the directories on `PATH`. Once `pdm install` has run, subsequent `pdm run ...`
> commands work normally because the venv's `Scripts/` directory is on `PATH`
> while the venv is active.
**Development commands:**
```bash
pdm run format # Auto-format code (isort, black, ruff --fix)
pdm run lint # Check code quality (isort, ruff, black, mypy)
pdm run codespell # Check spelling
pdm run test # Run test suite
pdm run testcov # Run tests with coverage report
pdm run all # Run all checks (lint + codespell + pre-commit + test)
pdm run clean # Clean build artifacts and cache files
pdm run build # Build package for distribution
pdm run publish # Build and publish to PyPI
pdm run --list # Show all available commands
```
Run `pdm run format && pdm run lint && pdm run test` before submitting PRs.
## 🔐 Authentication
Authentication uses the same mobile SSO flow as the official Garmin Connect Android app.
No browser is needed.
**How it works:**
1. **First login**: Authenticates via `sso.garmin.com/mobile/api/login` using the Android
app's client ID. If MFA is required, a callback (`prompt_mfa`) prompts for the one-time code.
2. **Token exchange**: The service ticket is exchanged for DI OAuth Bearer tokens
(`access_token` + `refresh_token`) via `diauth.garmin.com`. Tokens are stored at
`~/.garminconnect/garmin_tokens.json`.
3. **Auto-refresh**: Before each API request the library checks whether the DI token is about
to expire and refreshes it automatically — no user interaction required.
**Session lifetime:**
- DI tokens auto-refresh indefinitely as long as the refresh token remains valid.
- A full re-login with credentials (and possibly MFA) is only needed if the refresh token
itself expires or is revoked.
**Token storage:**
```bash
~/.garminconnect/garmin_tokens.json # saved automatically, mode 0600
```
The containing directory is restricted to mode `0700`. Treat the token file
like a password: the refresh token can provide persistent account access. Avoid
putting a Garmin password in shell history or a long-lived environment variable;
prefer `getpass()` or another interactive secret prompt.
**Resilient login (multi-strategy + token validation):**
`login()` tries several authentication strategies in order (mobile, SSO widget,
web portal — each with and without TLS impersonation) and only declares success
when the resulting token is actually accepted by the API. If a strategy obtains
a token the API later rejects (a region/account-specific condition — see
[#369](https://github.com/cyberjunky/python-garminconnect/issues/369)), the
library transparently falls through to the next strategy. Set
`Garmin(..., verify_login=False)` to restore the legacy "first token wins"
behavior.
**Cached-token gotcha & self-healing:** when a `tokenstore` is supplied,
`login()` loads those tokens *before* the strategy chain and short-circuits if
they load — so stale/poisoned cached tokens used to fail every run. The library
now detects this: if cached tokens are rejected by the API, it discards them and
performs a fresh credential login automatically. To force a clean slate yourself
(e.g. between a failed resume and a retry), call:
```python
g.logout() # clears in-memory auth + cached tokens (uses GARMINTOKENS)
g.logout(tokenstore) # or pass an explicit path
```
`logout()` removes only the local `garmin_tokens.json` file and preserves its
directory and unrelated files. It does **not** revoke a token that has already
been issued by Garmin. Revoke account access from Garmin's account/security
settings if a token may have been copied or exposed.
### What running it locally does
This is an unofficial client for Garmin's web services; it does not pair with
the Garmin Connect phone app or connect directly to a watch. When you call
`login()`, your credentials and MFA code are sent over HTTPS to Garmin's login
service. The library receives an access/refresh token pair and, when a token
store is supplied, caches it locally for later sessions. Subsequent API methods
send that token to Garmin and can read or change the same account data that the
selected method targets.
The library does not automatically download an entire account. The demo writes
responses, activity downloads, and health reports only when you select those
actions. Demo exports are stored under `your_data/` with owner-only directory
and file permissions. Run the project in a dedicated virtual environment, read
the method you plan to call, and start with read-only methods. Upload, edit,
delete, schedule, hydration, and weigh-in methods can change Garmin account data.
## 🧪 Testing
The default suite is credential-free and excludes live-account integration tests:
```bash
pdm run test # Run all tests
pdm run testcov # Run tests with coverage report
```
To explicitly run live integration tests, use a test account if possible. This
can create local VCR recordings and includes methods that may mutate the account:
```bash
export GARMIN_EMAIL="you@example.com"
read -s GARMIN_PASSWORD && export GARMIN_PASSWORD
pdm run pytest -m integration --vcr-record=once
unset GARMIN_PASSWORD
```
VCR recordings are ignored by Git because Garmin responses contain sensitive
health, activity, location, and account data. Do not commit them.
## 📦 Publishing
For package maintainers:
**Setup PyPI credentials:**
```bash
pip install twine
# Edit with your preferred editor, or create via here-doc:
# cat > ~/.pypirc <<'EOF'
# [pypi]
# username = __token__
# password = <PyPI_API_TOKEN>
# EOF
```
```ini
[pypi]
username = __token__
password = <PyPI_API_TOKEN>
```
Recommended: use environment variables and restrict file perms
```bash
chmod 600 ~/.pypirc
export TWINE_USERNAME="__token__"
export TWINE_PASSWORD="<PyPI_API_TOKEN>"
```
**Publish new version:**
```bash
pdm run publish # Build and publish to PyPI
```
**Alternative publishing steps:**
```bash
pdm run build # Build package only
pdm publish # Publish pre-built package
```
## 🤝 Contributing
We welcome contributions! Here's how you can help:
- **Report Issues**: Bug reports and feature requests via GitHub issues
- **Submit PRs**: Code improvements, new features, documentation updates
- **Testing**: Help test new features and report compatibility issues
- **Documentation**: Improve examples, add use cases, fix typos
**Before contributing:**
1. Set up your dev environment (see [Development](#️-development) above)
2. Format and lint: `pdm run format && pdm run lint`
3. Run tests: `pdm run test`
4. Follow existing code style and patterns
### Jupyter Notebook
Explore the API interactively with our [reference notebook](https://github.com/cyberjunky/python-garminconnect/blob/master/docs/reference.ipynb).
### Python Code Examples
```python
import os
from getpass import getpass
from datetime import date
from garminconnect import Garmin
# First run: logs in and saves tokens to ~/.garminconnect
# Subsequent runs: loads saved tokens and auto-refreshes
client = Garmin(
os.getenv("GARMIN_EMAIL"),
getpass("Garmin password: "),
prompt_mfa=lambda: input("MFA code: "),
)
client.login("~/.garminconnect")
# Get today's stats
today = date.today().isoformat()
stats = client.get_stats(today)
# Get heart rate data
hr_data = client.get_heart_rates(today)
print(f"Resting HR: {hr_data.get('restingHeartRate', 'n/a')}")
```
### Typed Workouts (Pydantic Models)
The library includes optional typed workout models for creating type-safe workout definitions:
```bash
pip install garminconnect[workout]
```
```python
from garminconnect.workout import (
RunningWorkout,
WorkoutSegment,
create_warmup_step,
create_interval_step,
create_distance_interval_step,
create_cooldown_step,
create_repeat_group,
)
# Create a structured running workout
workout = RunningWorkout(
workoutName="Easy Run",
estimatedDurationInSecs=1800,
workoutSegments=[
WorkoutSegment(
segmentOrder=1,
sportType={"sportTypeId": 1, "sportTypeKey": "running"},
workoutSteps=[create_warmup_step(300.0)],
)
],
)
# Upload and optionally schedule it
result = client.upload_running_workout(workout)
client.schedule_workout(result["workoutId"], "2026-03-20")
# Edit it in place - keeps its id, so any schedules pointing at it stay valid
workout_data = client.get_workout_by_id(result["workoutId"])
workout_data["workoutName"] = "Easy Run (revised)"
client.update_workout(result["workoutId"], workout_data)
# Delete a workout or remove it from the calendar
client.delete_workout(workout_id)
client.unschedule_workout(scheduled_workout_id)
# Push a workout to a device - defaults to the last workout / last used device
client.push_workout_to_device(result["workoutId"], device_id)
```
**Available workout classes:** `RunningWorkout`, `CyclingWorkout`, `SwimmingWorkout`, `WalkingWorkout`, `HikingWorkout`, `StrengthWorkout`, `MultiSportWorkout`, `FitnessEquipmentWorkout`
**Strength workouts** are rep-based. Build each exercise with `create_strength_set(category, step_order, sets, reps, rest_seconds, exercise_name="", weight_kg=None)` and identify exercises with a `category` / `exercise` pair from the bundled catalog in `garminconnect.exercises` (1,527 exercises across 47 categories, with `resolve(name)` and `find(term)` helpers):
```python
from garminconnect import exercises
from garminconnect.workout import StrengthWorkout, WorkoutSegment, create_strength_set
lat = exercises.resolve(
"Lat Pull-down"
) # {'category': 'PULL_UP', 'exercise': 'LAT_PULLDOWN'}
workout = StrengthWorkout(
workoutName="Upper Body",
estimatedDurationInSecs=0,
workoutSegments=[
WorkoutSegment(
segmentOrder=1,
sportType={"sportTypeId": 5, "sportTypeKey": "strength_training"},
workoutSteps=[
create_strength_set(
"BENCH_PRESS", step_order=1, sets=4, reps=10, rest_seconds=120
),
create_strength_set(
lat["category"],
step_order=4,
sets=3,
reps=12,
rest_seconds=90,
exercise_name=lat["exercise"],
),
],
)
],
)
client.upload_strength_workout(workout)
```
**Helper functions:** `create_warmup_step`, `create_interval_step`, `create_distance_interval_step`, `create_recovery_step`, `create_cooldown_step`, `create_repeat_group`, `create_strength_exercise_step`, `create_strength_rest_step`, `create_strength_set`
Use `create_distance_interval_step(600.0, step_order=1)` for interval steps that should end after a distance in meters instead of after a duration.
### Golf
The library supports reading Garmin Golf scorecards, shot-level data, club statistics, and golf user statistics.
```python
# List recent golf scorecards
summary = client.get_golf_summary(limit=10)
scorecard_id = summary["scorecardSummaries"][0]["id"]
# Get the full scorecard details
scorecard = client.get_golf_scorecard(scorecard_id)
# Get shot-by-shot data
# - Omit hole_numbers to receive every hole (recommended for holes 10-18).
# - Single-digit holes work as a comma- or dash-separated list, e.g. "1,2,3".
# Commas are normalized to dashes because Garmin only accepts '-' as the
# separator.
# - Double-digit holes (10-18) cannot be fetched with a filter at all; the
# API either drops them or returns an empty response. If you request holes
# 10-18, the library falls back to returning all 18 holes.
shots = client.get_golf_shot_data(scorecard_id)
shots = client.get_golf_shot_data(scorecard_id, hole_numbers="1,2,3")
# Club and player statistics
club_stats = client.get_golf_club_stats()
user_stats = client.get_golf_user_stats()
```
### Additional Resources
- **Simple Example**: [example.py](https://raw.githubusercontent.com/cyberjunky/python-garminconnect/master/example.py) - Getting started guide
- **Comprehensive Demo**: [demo.py](https://raw.githubusercontent.com/cyberjunky/python-garminconnect/master/demo.py) - All 130+ API methods
- **API Documentation**: Comprehensive method documentation in source code
- **Test Cases**: Real-world usage examples in `tests/` directory
## 🙏 Acknowledgments
Special thanks to all contributors who have helped improve this project:
- **Community Contributors**: Bug reports, feature requests, and code improvements
- **Issue Reporters**: Helping identify and resolve compatibility issues
- **Feature Developers**: Adding new API endpoints and functionality
- **Documentation Authors**: Improving examples and user guides
This project thrives thanks to community involvement and feedback.
## 💖 Support This Project
If you find this library useful for your projects, please consider supporting its continued development and maintenance:
### 🌟 Ways to Support
- **⭐ Star this repository** - Help others discover the project
- **💰 Financial Support** - Contribute to development and hosting costs
- **🐛 Report Issues** - Help improve stability and compatibility
- **📖 Spread the Word** - Share with other developers
### 💳 Financial Support Options
[![Donate via PayPal](https://img.shields.io/badge/Donate-PayPal-blue.svg?style=for-the-badge&logo=paypal)](https://www.paypal.me/cyberjunkynl/)
[![Sponsor on GitHub](https://img.shields.io/badge/Sponsor-GitHub-red.svg?style=for-the-badge&logo=github)](https://github.com/sponsors/cyberjunky)
**Why Support?**
- Keeps the project actively maintained
- Enables faster bug fixes and new features
- Supports infrastructure costs (testing, AI, CI/CD)
- Shows appreciation for hundreds of hours of development
Every contribution, no matter the size, makes a difference and is greatly appreciated! 🙏
[releases-shield]: https://img.shields.io/github/release/cyberjunky/python-garminconnect.svg?style=for-the-badge
[releases]: https://github.com/cyberjunky/python-garminconnect/releases
[commits-shield]: https://img.shields.io/github/commit-activity/y/cyberjunky/python-garminconnect.svg?style=for-the-badge
[commits]: https://github.com/cyberjunky/python-garminconnect/commits/master
[license-shield]: https://img.shields.io/github/license/cyberjunky/python-garminconnect.svg?style=for-the-badge
[maintenance-shield]: https://img.shields.io/badge/maintainer-cyberjunky-blue.svg?style=for-the-badge

3743
garminconnect/__init__.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,44 @@
"""Helpers for parsing the positional metrics returned by activity detail endpoints."""
from __future__ import annotations
from typing import Any
def parse_activity_detail_metrics(details: dict[str, Any]) -> list[dict[str, Any]]:
"""Resolve positional activity detail samples into per-sample dicts keyed by metric name.
`details` is the raw response from `Garmin.get_activity_details()`. Each sample in
`activityDetailMetrics` stores values positionally in `metrics`; the position-to-name
mapping is given by `metricDescriptors[].metricsIndex`/`key` and varies by device and
activity type. Descriptors with a missing, non-int, or negative `metricsIndex` are
skipped, and a sample missing a channel entirely (index out of range for that sample)
simply omits that key rather than raising. Duration keys (`sumDuration`,
`sumElapsedDuration`, `sumMovingDuration`) are not equivalent and are passed through
unchanged under their own names — callers must pick the one they mean.
"""
index_to_key: dict[int, str] = {}
for descriptor in details.get("metricDescriptors") or []:
key = descriptor.get("key")
index = descriptor.get("metricsIndex")
if (
not isinstance(key, str)
or not isinstance(index, int)
or isinstance(index, bool)
):
continue
if index < 0:
continue
index_to_key[index] = key
parsed: list[dict[str, Any]] = []
for sample in details.get("activityDetailMetrics") or []:
metrics = sample.get("metrics") or []
parsed.append(
{
key: metrics[index]
for index, key in index_to_key.items()
if index < len(metrics)
}
)
return parsed

1734
garminconnect/client.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,33 @@
from typing import Any
class GarminConnectConnectionError(Exception):
"""Raised when communication ended in error.
``response`` carries the original HTTP response when the error wraps an
HTTP failure, so callers can inspect status codes and bodies.
"""
response: Any = None
class GarminConnectNotFoundError(GarminConnectConnectionError):
"""Raised when a requested resource does not exist (HTTP 404).
Subclasses GarminConnectConnectionError for backwards compatibility, so
existing ``except GarminConnectConnectionError`` handlers keep working while
callers can now catch a missing resource specifically (e.g. deleting an
already-deleted workout).
"""
class GarminConnectTooManyRequestsError(Exception):
"""Raised when rate limit is exceeded."""
class GarminConnectAuthenticationError(Exception):
"""Raised when authentication is failed."""
class GarminConnectInvalidFileFormatError(Exception):
"""Raised when an invalid file format is provided."""

2635
garminconnect/exercises.py Normal file

File diff suppressed because it is too large Load Diff

518
garminconnect/fit.py Normal file
View File

@ -0,0 +1,518 @@
# type: ignore # Complex binary data handling - mypy errors expected
import time
from datetime import datetime
from io import BytesIO
from struct import pack, unpack
from typing import Any
def _calcCRC(crc: int, byte: int) -> int:
table = [
0x0000,
0xCC01,
0xD801,
0x1400,
0xF001,
0x3C00,
0x2800,
0xE401,
0xA001,
0x6C00,
0x7800,
0xB401,
0x5000,
0x9C01,
0x8801,
0x4400,
]
# compute checksum of lower four bits of byte
tmp = table[crc & 0xF]
crc = (crc >> 4) & 0x0FFF
crc = crc ^ tmp ^ table[byte & 0xF]
# now compute checksum of upper four bits of byte
tmp = table[crc & 0xF]
crc = (crc >> 4) & 0x0FFF
return crc ^ tmp ^ table[(byte >> 4) & 0xF]
class FitBaseType:
"""BaseType Definition.
see FIT Protocol Document(Page.20)
"""
enum = {
"#": 0,
"endian": 0,
"field": 0x00,
"name": "enum",
"invalid": 0xFF,
"size": 1,
}
sint8 = {
"#": 1,
"endian": 0,
"field": 0x01,
"name": "sint8",
"invalid": 0x7F,
"size": 1,
}
uint8 = {
"#": 2,
"endian": 0,
"field": 0x02,
"name": "uint8",
"invalid": 0xFF,
"size": 1,
}
sint16 = {
"#": 3,
"endian": 1,
"field": 0x83,
"name": "sint16",
"invalid": 0x7FFF,
"size": 2,
}
uint16 = {
"#": 4,
"endian": 1,
"field": 0x84,
"name": "uint16",
"invalid": 0xFFFF,
"size": 2,
}
sint32 = {
"#": 5,
"endian": 1,
"field": 0x85,
"name": "sint32",
"invalid": 0x7FFFFFFF,
"size": 4,
}
uint32 = {
"#": 6,
"endian": 1,
"field": 0x86,
"name": "uint32",
"invalid": 0xFFFFFFFF,
"size": 4,
}
string = {
"#": 7,
"endian": 0,
"field": 0x07,
"name": "string",
"invalid": 0x00,
"size": 1,
}
float32 = {
"#": 8,
"endian": 1,
"field": 0x88,
"name": "float32",
"invalid": 0xFFFFFFFF,
"size": 2,
}
float64 = {
"#": 9,
"endian": 1,
"field": 0x89,
"name": "float64",
"invalid": 0xFFFFFFFFFFFFFFFF,
"size": 4,
}
uint8z = {
"#": 10,
"endian": 0,
"field": 0x0A,
"name": "uint8z",
"invalid": 0x00,
"size": 1,
}
uint16z = {
"#": 11,
"endian": 1,
"field": 0x8B,
"name": "uint16z",
"invalid": 0x0000,
"size": 2,
}
uint32z = {
"#": 12,
"endian": 1,
"field": 0x8C,
"name": "uint32z",
"invalid": 0x00000000,
"size": 4,
}
byte = {
"#": 13,
"endian": 0,
"field": 0x0D,
"name": "byte",
"invalid": 0xFF,
"size": 1,
} # array of byte, field is invalid if all bytes are invalid
@staticmethod
def get_format(basetype: int) -> str:
formats = {
0: "B",
1: "b",
2: "B",
3: "h",
4: "H",
5: "i",
6: "I",
7: "s",
8: "f",
9: "d",
10: "B",
11: "H",
12: "I",
13: "c",
}
return formats[basetype["#"]]
@staticmethod
def pack(basetype: dict[str, Any], value: Any) -> bytes:
"""Function to avoid DeprecationWarning."""
if basetype["#"] in (1, 2, 3, 4, 5, 6, 10, 11, 12):
value = int(value)
fmt = FitBaseType.get_format(basetype)
return pack(fmt, value)
class Fit:
HEADER_SIZE = 12
# not sure if this is the mesg_num
GMSG_NUMS = {
"file_id": 0,
"device_info": 23,
"weight_scale": 30,
"file_creator": 49,
"blood_pressure": 51,
}
class FitEncoder(Fit):
FILE_TYPE = 9
LMSG_TYPE_FILE_INFO = 0
LMSG_TYPE_FILE_CREATOR = 1
LMSG_TYPE_DEVICE_INFO = 2
def __init__(self) -> None:
self.buf = BytesIO()
self.write_header() # create header first
self.device_info_defined = False
def __str__(self) -> str:
orig_pos = self.buf.tell()
self.buf.seek(0)
lines = []
while True:
b = self.buf.read(16)
if not b:
break
lines.append(" ".join([f"{ord(c):02x}" for c in b]))
self.buf.seek(orig_pos)
return "\n".join(lines)
def write_header(
self,
header_size: int = 12, # Fit.HEADER_SIZE
protocol_version: int = 16,
profile_version: int = 108,
data_size: int = 0,
data_type: bytes = b".FIT",
) -> None:
self.buf.seek(0)
s = pack(
"BBHI4s",
header_size,
protocol_version,
profile_version,
data_size,
data_type,
)
self.buf.write(s)
def _build_content_block(self, content: dict[str, Any]) -> bytes:
field_defs = []
values = []
for num, basetype, value, scale in content:
s = pack("BBB", num, basetype["size"], basetype["field"])
field_defs.append(s)
if value is None:
# invalid value
value = basetype["invalid"]
elif scale is not None:
value *= scale
values.append(FitBaseType.pack(basetype, value))
return (b"".join(field_defs), b"".join(values))
def write_file_info(
self,
serial_number: int | None = None,
time_created: datetime | None = None,
manufacturer: int | None = None,
product: int | None = None,
number: int | None = None,
) -> None:
if time_created is None:
time_created = datetime.now()
content = [
(3, FitBaseType.uint32z, serial_number, None),
(4, FitBaseType.uint32, self.timestamp(time_created), None),
(1, FitBaseType.uint16, manufacturer, None),
(2, FitBaseType.uint16, product, None),
(5, FitBaseType.uint16, number, None),
(0, FitBaseType.enum, self.FILE_TYPE, None), # type
]
fields, values = self._build_content_block(content)
# create fixed content
msg_number = self.GMSG_NUMS["file_id"]
fixed_content = pack(
"BBHB", 0, 0, msg_number, len(content)
) # reserved, architecture(0: little endian)
self.buf.write(
b"".join(
[
# definition
self.record_header(
definition=True, lmsg_type=self.LMSG_TYPE_FILE_INFO
),
fixed_content,
fields,
# record
self.record_header(lmsg_type=self.LMSG_TYPE_FILE_INFO),
values,
]
)
)
def write_file_creator(
self,
software_version: int | None = None,
hardware_version: int | None = None,
) -> None:
content = [
(0, FitBaseType.uint16, software_version, None),
(1, FitBaseType.uint8, hardware_version, None),
]
fields, values = self._build_content_block(content)
msg_number = self.GMSG_NUMS["file_creator"]
fixed_content = pack(
"BBHB", 0, 0, msg_number, len(content)
) # reserved, architecture(0: little endian)
self.buf.write(
b"".join(
[
# definition
self.record_header(
definition=True, lmsg_type=self.LMSG_TYPE_FILE_CREATOR
),
fixed_content,
fields,
# record
self.record_header(lmsg_type=self.LMSG_TYPE_FILE_CREATOR),
values,
]
)
)
def write_device_info(
self,
timestamp: datetime,
serial_number: int | None = None,
cum_operationg_time: int | None = None,
manufacturer: int | None = None,
product: int | None = None,
software_version: int | None = None,
battery_voltage: int | None = None,
device_index: int | None = None,
device_type: int | None = None,
hardware_version: int | None = None,
battery_status: int | None = None,
) -> None:
content = [
(253, FitBaseType.uint32, self.timestamp(timestamp), 1),
(3, FitBaseType.uint32z, serial_number, 1),
(7, FitBaseType.uint32, cum_operationg_time, 1),
(8, FitBaseType.uint32, None, None), # unknown field(undocumented)
(2, FitBaseType.uint16, manufacturer, 1),
(4, FitBaseType.uint16, product, 1),
(5, FitBaseType.uint16, software_version, 100),
(10, FitBaseType.uint16, battery_voltage, 256),
(0, FitBaseType.uint8, device_index, 1),
(1, FitBaseType.uint8, device_type, 1),
(6, FitBaseType.uint8, hardware_version, 1),
(11, FitBaseType.uint8, battery_status, None),
]
fields, values = self._build_content_block(content)
if not self.device_info_defined:
header = self.record_header(
definition=True, lmsg_type=self.LMSG_TYPE_DEVICE_INFO
)
msg_number = self.GMSG_NUMS["device_info"]
fixed_content = pack(
"BBHB", 0, 0, msg_number, len(content)
) # reserved, architecture(0: little endian)
self.buf.write(header + fixed_content + fields)
self.device_info_defined = True
header = self.record_header(lmsg_type=self.LMSG_TYPE_DEVICE_INFO)
self.buf.write(header + values)
def record_header(self, definition: bool = False, lmsg_type: int = 0) -> bytes:
msg = 0
if definition:
msg = 1 << 6 # 6th bit is a definition message
return pack("B", msg + lmsg_type)
def crc(self) -> int:
orig_pos = self.buf.tell()
self.buf.seek(0)
crc = 0
while True:
b = self.buf.read(1)
if not b:
break
crc = _calcCRC(crc, unpack("b", b)[0])
self.buf.seek(orig_pos)
return pack("H", crc)
def finish(self) -> None:
"""re-weite file-header, then append crc to end of file."""
data_size = self.get_size() - self.HEADER_SIZE
self.write_header(data_size=data_size)
crc = self.crc()
self.buf.seek(0, 2)
self.buf.write(crc)
def get_size(self) -> int:
orig_pos = self.buf.tell()
self.buf.seek(0, 2)
size = self.buf.tell()
self.buf.seek(orig_pos)
return size
def getvalue(self) -> bytes:
return self.buf.getvalue()
def timestamp(self, t: datetime | float) -> float:
"""The timestamp in fit protocol is seconds since
UTC 00:00 Dec 31 1989 (631065600).
"""
if isinstance(t, datetime):
t = time.mktime(t.timetuple())
return t - 631065600
class FitEncoderBloodPressure(FitEncoder):
# Here might be dragons - no idea what lsmg stand for, found 14 somewhere in the deepest web
LMSG_TYPE_BLOOD_PRESSURE = 14
def __init__(self) -> None:
super().__init__()
self.blood_pressure_monitor_defined = False
def write_blood_pressure(
self,
timestamp: datetime | int | float,
diastolic_blood_pressure: int | None = None,
systolic_blood_pressure: int | None = None,
mean_arterial_pressure: int | None = None,
map_3_sample_mean: int | None = None,
map_morning_values: int | None = None,
map_evening_values: int | None = None,
heart_rate: int | None = None,
) -> None:
# BLOOD PRESSURE FILE MESSAGES
content = [
(253, FitBaseType.uint32, self.timestamp(timestamp), 1),
(0, FitBaseType.uint16, systolic_blood_pressure, 1),
(1, FitBaseType.uint16, diastolic_blood_pressure, 1),
(2, FitBaseType.uint16, mean_arterial_pressure, 1),
(3, FitBaseType.uint16, map_3_sample_mean, 1),
(4, FitBaseType.uint16, map_morning_values, 1),
(5, FitBaseType.uint16, map_evening_values, 1),
(6, FitBaseType.uint8, heart_rate, 1),
]
fields, values = self._build_content_block(content)
if not self.blood_pressure_monitor_defined:
header = self.record_header(
definition=True, lmsg_type=self.LMSG_TYPE_BLOOD_PRESSURE
)
msg_number = self.GMSG_NUMS["blood_pressure"]
fixed_content = pack(
"BBHB", 0, 0, msg_number, len(content)
) # reserved, architecture(0: little endian)
self.buf.write(header + fixed_content + fields)
self.blood_pressure_monitor_defined = True
header = self.record_header(lmsg_type=self.LMSG_TYPE_BLOOD_PRESSURE)
self.buf.write(header + values)
class FitEncoderWeight(FitEncoder):
LMSG_TYPE_WEIGHT_SCALE = 3
def __init__(self) -> None:
super().__init__()
self.weight_scale_defined = False
def write_weight_scale(
self,
timestamp: datetime | int | float,
weight: int | float,
percent_fat: int | float | None = None,
percent_hydration: int | float | None = None,
visceral_fat_mass: int | float | None = None,
bone_mass: int | float | None = None,
muscle_mass: int | float | None = None,
basal_met: int | float | None = None,
active_met: int | float | None = None,
physique_rating: int | float | None = None,
metabolic_age: int | float | None = None,
visceral_fat_rating: int | float | None = None,
bmi: int | float | None = None,
) -> None:
content = [
(253, FitBaseType.uint32, self.timestamp(timestamp), 1),
(0, FitBaseType.uint16, weight, 100),
(1, FitBaseType.uint16, percent_fat, 100),
(2, FitBaseType.uint16, percent_hydration, 100),
(3, FitBaseType.uint16, visceral_fat_mass, 100),
(4, FitBaseType.uint16, bone_mass, 100),
(5, FitBaseType.uint16, muscle_mass, 100),
(7, FitBaseType.uint16, basal_met, 4),
(9, FitBaseType.uint16, active_met, 4),
(8, FitBaseType.uint8, physique_rating, 1),
(10, FitBaseType.uint8, metabolic_age, 1),
(11, FitBaseType.uint8, visceral_fat_rating, 1),
(13, FitBaseType.uint16, bmi, 10),
]
fields, values = self._build_content_block(content)
if not self.weight_scale_defined:
header = self.record_header(
definition=True, lmsg_type=self.LMSG_TYPE_WEIGHT_SCALE
)
msg_number = self.GMSG_NUMS["weight_scale"]
fixed_content = pack(
"BBHB", 0, 0, msg_number, len(content)
) # reserved, architecture(0: little endian)
self.buf.write(header + fixed_content + fields)
self.weight_scale_defined = True
header = self.record_header(lmsg_type=self.LMSG_TYPE_WEIGHT_SCALE)
self.buf.write(header + values)

593
garminconnect/typed.py Normal file
View File

@ -0,0 +1,593 @@
"""Optional Pydantic response models for typed Garmin Connect API access.
Experimental — model shapes and the ``g.typed`` surface may change between
minor releases until the pattern stabilises. Pin a specific version if you
depend on typed response shapes.
The typed namespace wraps a small, curated set of high-value endpoints. All
other endpoints remain available via the standard ``g.get_*()`` methods with
``dict[str, Any]`` responses — this layer is purely additive.
Usage:
from garminconnect import Garmin
g = Garmin(email, password)
g.login()
raw = g.get_stats("2026-04-21") # dict[str, Any] — unchanged
stats = g.typed.get_stats("2026-04-21") # DailyStats (Pydantic)
print(stats.total_steps, stats.resting_heart_rate)
Install the optional dependency first::
pip install 'garminconnect[typed]'
On validation failure, raises :class:`GarminConnectResponseValidationError`
with the unvalidated response preserved as ``.raw`` so callers can still
access the data.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, TypeVar
try:
from pydantic import BaseModel, ConfigDict, Field
from pydantic import ValidationError as _PydanticValidationError
except ImportError as _exc: # pragma: no cover - exercised via integration
raise ImportError(
"The `typed` namespace requires pydantic. Install it with:\n"
" pip install 'garminconnect[typed]'"
) from _exc
_M = TypeVar("_M", bound=BaseModel)
if TYPE_CHECKING:
from . import Garmin
class GarminConnectResponseValidationError(Exception):
"""Raised when a Garmin response fails Pydantic validation.
The unvalidated response is available as ``raw`` so callers can still
inspect the data. The underlying :class:`pydantic.ValidationError` is
available as ``pydantic_error``.
"""
def __init__(
self,
message: str,
raw: Any,
pydantic_error: _PydanticValidationError,
) -> None:
super().__init__(message)
self.raw = raw
self.pydantic_error = pydantic_error
# ---------------------------------------------------------------------------
# Base
# ---------------------------------------------------------------------------
# ``extra='allow'`` is deliberate: Garmin occasionally adds fields with new
# firmware / subscription tiers, and we don't want validation failures for
# benign additions. ``populate_by_name=True`` lets callers construct models
# using either the Python attribute name or the JSON alias, which is useful
# for tests.
_COMMON_CONFIG = ConfigDict(
extra="allow",
populate_by_name=True,
)
class _BaseResponse(BaseModel):
model_config = _COMMON_CONFIG
# ---------------------------------------------------------------------------
# Daily Stats (get_stats / get_user_summary)
# ---------------------------------------------------------------------------
class DailyStats(_BaseResponse):
"""Daily summary returned by ``get_stats`` and ``get_user_summary``.
Only the most commonly consumed fields are modelled explicitly; additional
fields are accessible through ``model_extra`` or by dumping the model.
"""
user_profile_id: int | None = Field(default=None, alias="userProfileId")
calendar_date: str | None = Field(default=None, alias="calendarDate")
total_steps: int | None = Field(default=None, alias="totalSteps")
daily_step_goal: int | None = Field(default=None, alias="dailyStepGoal")
total_distance_meters: float | None = Field(
default=None, alias="totalDistanceMeters"
)
total_kilocalories: float | None = Field(default=None, alias="totalKilocalories")
active_kilocalories: float | None = Field(default=None, alias="activeKilocalories")
bmr_kilocalories: float | None = Field(default=None, alias="bmrKilocalories")
wellness_kilocalories: float | None = Field(
default=None, alias="wellnessKilocalories"
)
min_heart_rate: int | None = Field(default=None, alias="minHeartRate")
max_heart_rate: int | None = Field(default=None, alias="maxHeartRate")
resting_heart_rate: int | None = Field(default=None, alias="restingHeartRate")
sleeping_seconds: int | None = Field(default=None, alias="sleepingSeconds")
sedentary_seconds: int | None = Field(default=None, alias="sedentarySeconds")
active_seconds: int | None = Field(default=None, alias="activeSeconds")
highly_active_seconds: int | None = Field(default=None, alias="highlyActiveSeconds")
moderate_intensity_minutes: int | None = Field(
default=None, alias="moderateIntensityMinutes"
)
vigorous_intensity_minutes: int | None = Field(
default=None, alias="vigorousIntensityMinutes"
)
floors_ascended: float | None = Field(default=None, alias="floorsAscended")
floors_descended: float | None = Field(default=None, alias="floorsDescended")
average_stress_level: int | None = Field(default=None, alias="averageStressLevel")
max_stress_level: int | None = Field(default=None, alias="maxStressLevel")
stress_duration: int | None = Field(default=None, alias="stressDuration")
rest_stress_duration: int | None = Field(default=None, alias="restStressDuration")
body_battery_charged_value: int | None = Field(
default=None, alias="bodyBatteryChargedValue"
)
body_battery_drained_value: int | None = Field(
default=None, alias="bodyBatteryDrainedValue"
)
body_battery_highest_value: int | None = Field(
default=None, alias="bodyBatteryHighestValue"
)
body_battery_lowest_value: int | None = Field(
default=None, alias="bodyBatteryLowestValue"
)
privacy_protected: bool | None = Field(default=None, alias="privacyProtected")
# ---------------------------------------------------------------------------
# Sleep (get_sleep_data)
# ---------------------------------------------------------------------------
class SleepScoreValue(_BaseResponse):
"""One component of the Garmin sleep score breakdown (value + qualifier)."""
value: int | None = None
qualifier_key: str | None = Field(default=None, alias="qualifierKey")
class SleepScores(_BaseResponse):
"""Sub-scores that make up the overall nightly sleep score."""
overall: SleepScoreValue | None = None
total_duration: SleepScoreValue | None = Field(default=None, alias="totalDuration")
stress: SleepScoreValue | None = None
awake_count: SleepScoreValue | None = Field(default=None, alias="awakeCount")
rem_percentage: SleepScoreValue | None = Field(default=None, alias="remPercentage")
restlessness: SleepScoreValue | None = None
light_percentage: SleepScoreValue | None = Field(
default=None, alias="lightPercentage"
)
deep_percentage: SleepScoreValue | None = Field(
default=None, alias="deepPercentage"
)
class DailySleepDTO(_BaseResponse):
"""Nested sleep summary inside a :class:`SleepData` response."""
user_profile_pk: int | None = Field(default=None, alias="userProfilePK")
calendar_date: str | None = Field(default=None, alias="calendarDate")
sleep_time_seconds: int | None = Field(default=None, alias="sleepTimeSeconds")
nap_time_seconds: int | None = Field(default=None, alias="napTimeSeconds")
sleep_window_confirmed: bool | None = Field(
default=None, alias="sleepWindowConfirmed"
)
deep_sleep_seconds: int | None = Field(default=None, alias="deepSleepSeconds")
light_sleep_seconds: int | None = Field(default=None, alias="lightSleepSeconds")
rem_sleep_seconds: int | None = Field(default=None, alias="remSleepSeconds")
awake_sleep_seconds: int | None = Field(default=None, alias="awakeSleepSeconds")
sleep_start_timestamp_gmt: int | None = Field(
default=None, alias="sleepStartTimestampGMT"
)
sleep_end_timestamp_gmt: int | None = Field(
default=None, alias="sleepEndTimestampGMT"
)
sleep_start_timestamp_local: int | None = Field(
default=None, alias="sleepStartTimestampLocal"
)
sleep_end_timestamp_local: int | None = Field(
default=None, alias="sleepEndTimestampLocal"
)
avg_sleep_hrv: float | None = Field(default=None, alias="avgSleepHRV")
avg_spo2: float | None = Field(default=None, alias="avgSpO2")
avg_respiration_value: float | None = Field(
default=None, alias="avgRespirationValue"
)
lowest_respiration_value: float | None = Field(
default=None, alias="lowestRespirationValue"
)
highest_respiration_value: float | None = Field(
default=None, alias="highestRespirationValue"
)
sleep_scores: SleepScores | None = Field(default=None, alias="sleepScores")
class SleepData(_BaseResponse):
"""Response for ``get_sleep_data``.
The most useful summary lives under ``daily_sleep_dto``; callers that want
per-minute heart rate / movement / SpO2 arrays should use the raw dict via
``g.get_sleep_data`` since those arrays are large and rarely needed in
typed form.
"""
daily_sleep_dto: DailySleepDTO | None = Field(default=None, alias="dailySleepDTO")
# ---------------------------------------------------------------------------
# HRV (get_hrv_data)
# ---------------------------------------------------------------------------
class HrvBaseline(_BaseResponse):
"""Personal HRV baseline ranges derived from the user's history."""
low_upper: float | None = Field(default=None, alias="lowUpper")
balanced_low: float | None = Field(default=None, alias="balancedLow")
balanced_upper: float | None = Field(default=None, alias="balancedUpper")
marker_value: float | None = Field(default=None, alias="markerValue")
class HrvSummary(_BaseResponse):
"""Summary of HRV stats (weekly / last-night averages, status, feedback)."""
calendar_date: str | None = Field(default=None, alias="calendarDate")
weekly_avg: float | None = Field(default=None, alias="weeklyAvg")
last_night_avg: float | None = Field(default=None, alias="lastNightAvg")
last_night_5_min_high: float | None = Field(default=None, alias="lastNight5MinHigh")
status: str | None = None
feedback_phrase: str | None = Field(default=None, alias="feedbackPhrase")
baseline: HrvBaseline | None = None
class HrvData(_BaseResponse):
"""Response for ``get_hrv_data``.
Note: ``get_hrv_data`` may return ``None`` if HRV data is not available for
the requested date. The typed wrapper preserves this — ``g.typed.get_hrv_data``
returns ``HrvData | None``.
"""
user_profile_pk: int | None = Field(default=None, alias="userProfilePK")
hrv_summary: HrvSummary | None = Field(default=None, alias="hrvSummary")
hrv_readings: list[dict[str, Any]] | None = Field(default=None, alias="hrvReadings")
start_timestamp_gmt: str | None = Field(default=None, alias="startTimestampGMT")
end_timestamp_gmt: str | None = Field(default=None, alias="endTimestampGMT")
start_timestamp_local: str | None = Field(default=None, alias="startTimestampLocal")
end_timestamp_local: str | None = Field(default=None, alias="endTimestampLocal")
sleep_start_timestamp_gmt: str | None = Field(
default=None, alias="sleepStartTimestampGMT"
)
sleep_end_timestamp_gmt: str | None = Field(
default=None, alias="sleepEndTimestampGMT"
)
# ---------------------------------------------------------------------------
# Body Battery (get_body_battery)
# ---------------------------------------------------------------------------
class BodyBatteryEntry(_BaseResponse):
"""One entry from ``get_body_battery``.
``get_body_battery`` always returns a list; for a single date the list has
one entry. ``body_battery_values_array`` is a list of ``[timestamp, level]``
pairs sampled throughout the day.
"""
date: str | None = None
charged: int | None = None
drained: int | None = None
start_timestamp_gmt: str | None = Field(default=None, alias="startTimestampGMT")
end_timestamp_gmt: str | None = Field(default=None, alias="endTimestampGMT")
start_timestamp_local: str | None = Field(default=None, alias="startTimestampLocal")
end_timestamp_local: str | None = Field(default=None, alias="endTimestampLocal")
body_battery_values_array: list[list[Any]] | None = Field(
default=None, alias="bodyBatteryValuesArray"
)
body_battery_value_descriptors_dto_list: list[dict[str, Any]] | None = Field(
default=None, alias="bodyBatteryValueDescriptorDTOList"
)
# ---------------------------------------------------------------------------
# Training Readiness (get_training_readiness)
# ---------------------------------------------------------------------------
class TrainingReadiness(_BaseResponse):
"""One snapshot from ``get_training_readiness``.
The endpoint returns a list of snapshots — typically one per wake-up event
or scheduled update. Use the snapshot with the most recent ``timestamp``
for the current reading.
``recovery_time`` is reported in **minutes**. When
``recovery_time_change_phrase == 'REACHED_ZERO'`` the user is fully
recovered regardless of the numeric value (Garmin keeps the last assigned
value after the clock drains).
"""
user_profile_pk: int | None = Field(default=None, alias="userProfilePK")
calendar_date: str | None = Field(default=None, alias="calendarDate")
timestamp: str | None = None
timestamp_local: str | None = Field(default=None, alias="timestampLocal")
device_id: int | None = Field(default=None, alias="deviceId")
score: int | None = None
level: str | None = None
feedback_long: str | None = Field(default=None, alias="feedbackLong")
feedback_short: str | None = Field(default=None, alias="feedbackShort")
sleep_score: int | None = Field(default=None, alias="sleepScore")
sleep_score_factor_percent: int | None = Field(
default=None, alias="sleepScoreFactorPercent"
)
sleep_score_factor_feedback: str | None = Field(
default=None, alias="sleepScoreFactorFeedback"
)
recovery_time: int | None = Field(default=None, alias="recoveryTime")
recovery_time_factor_percent: int | None = Field(
default=None, alias="recoveryTimeFactorPercent"
)
recovery_time_factor_feedback: str | None = Field(
default=None, alias="recoveryTimeFactorFeedback"
)
recovery_time_change_phrase: str | None = Field(
default=None, alias="recoveryTimeChangePhrase"
)
acwr_factor_percent: int | None = Field(default=None, alias="acwrFactorPercent")
acwr_factor_feedback: str | None = Field(default=None, alias="acwrFactorFeedback")
hrv_factor_percent: int | None = Field(default=None, alias="hrvFactorPercent")
hrv_factor_feedback: str | None = Field(default=None, alias="hrvFactorFeedback")
stress_history_factor_percent: int | None = Field(
default=None, alias="stressHistoryFactorPercent"
)
stress_history_factor_feedback: str | None = Field(
default=None, alias="stressHistoryFactorFeedback"
)
input_context: str | None = Field(default=None, alias="inputContext")
# ---------------------------------------------------------------------------
# Activity (get_activities_by_date)
# ---------------------------------------------------------------------------
class ActivityType(_BaseResponse):
"""Garmin activity type classification (``typeKey`` is the main lookup)."""
type_id: int | None = Field(default=None, alias="typeId")
type_key: str | None = Field(default=None, alias="typeKey")
parent_type_id: int | None = Field(default=None, alias="parentTypeId")
is_hidden: bool | None = Field(default=None, alias="isHidden")
class Activity(_BaseResponse):
"""One activity from ``get_activities_by_date``.
Strength-training activities populate ``total_sets``, ``total_reps`` and
``total_volume``; other activity types leave those fields as ``None``.
"""
activity_id: int | None = Field(default=None, alias="activityId")
activity_name: str | None = Field(default=None, alias="activityName")
start_time_local: str | None = Field(default=None, alias="startTimeLocal")
start_time_gmt: str | None = Field(default=None, alias="startTimeGMT")
activity_type: ActivityType | None = Field(default=None, alias="activityType")
duration: float | None = None
moving_duration: float | None = Field(default=None, alias="movingDuration")
elapsed_duration: float | None = Field(default=None, alias="elapsedDuration")
distance: float | None = None
elevation_gain: float | None = Field(default=None, alias="elevationGain")
elevation_loss: float | None = Field(default=None, alias="elevationLoss")
average_speed: float | None = Field(default=None, alias="averageSpeed")
max_speed: float | None = Field(default=None, alias="maxSpeed")
average_hr: float | None = Field(default=None, alias="averageHR")
max_hr: float | None = Field(default=None, alias="maxHR")
calories: float | None = None
bmr_calories: float | None = Field(default=None, alias="bmrCalories")
avg_power: float | None = Field(default=None, alias="avgPower")
max_power: float | None = Field(default=None, alias="maxPower")
normalized_power: float | None = Field(default=None, alias="normPower")
aerobic_training_effect: float | None = Field(
default=None, alias="aerobicTrainingEffect"
)
anaerobic_training_effect: float | None = Field(
default=None, alias="anaerobicTrainingEffect"
)
activity_training_load: float | None = Field(
default=None, alias="activityTrainingLoad"
)
training_effect_label: str | None = Field(default=None, alias="trainingEffectLabel")
average_running_cadence: float | None = Field(
default=None, alias="averageRunningCadenceInStepsPerMinute"
)
max_running_cadence: float | None = Field(
default=None, alias="maxRunningCadenceInStepsPerMinute"
)
total_sets: int | None = Field(default=None, alias="totalSets")
active_sets: int | None = Field(default=None, alias="activeSets")
total_reps: int | None = Field(default=None, alias="totalReps")
total_volume: float | None = Field(default=None, alias="totalVolume")
# ---------------------------------------------------------------------------
# Wrapper
# ---------------------------------------------------------------------------
class TypedGarmin:
"""Typed namespace accessor for a curated set of Garmin Connect endpoints.
Access via the :attr:`Garmin.typed` cached property, never instantiate
directly::
g = Garmin(email, password)
g.login()
stats = g.typed.get_stats("2026-04-21")
Each method is a thin wrapper around the corresponding ``Garmin`` method
that validates the response with a Pydantic model. On validation failure,
raises :class:`GarminConnectResponseValidationError` with the unvalidated
response available as ``.raw``.
**Experimental.** Model shapes and method signatures may change in future
releases; pin a specific version if you depend on them.
"""
def __init__(self, garmin: Garmin) -> None:
self._garmin = garmin
@staticmethod
def _validate(model_cls: type[_M], raw: Any, method_name: str) -> _M:
try:
return model_cls.model_validate(raw)
except _PydanticValidationError as exc:
raise GarminConnectResponseValidationError(
f"Response from {method_name}() failed {model_cls.__name__} "
f"validation: {exc}",
raw=raw,
pydantic_error=exc,
) from exc
# -- Daily stats ---------------------------------------------------------
def get_stats(self, cdate: str) -> DailyStats:
"""Return daily stats for ``cdate`` as a :class:`DailyStats` model."""
raw = self._garmin.get_stats(cdate)
return self._validate(DailyStats, raw, "get_stats")
def get_user_summary(self, cdate: str) -> DailyStats:
"""Return the user summary for ``cdate`` as a :class:`DailyStats` model."""
raw = self._garmin.get_user_summary(cdate)
return self._validate(DailyStats, raw, "get_user_summary")
# -- Sleep ---------------------------------------------------------------
def get_sleep_data(self, cdate: str) -> SleepData:
"""Return sleep data for ``cdate`` as a :class:`SleepData` model."""
raw = self._garmin.get_sleep_data(cdate)
return self._validate(SleepData, raw, "get_sleep_data")
# -- HRV -----------------------------------------------------------------
def get_hrv_data(self, cdate: str) -> HrvData | None:
"""Return HRV data for ``cdate`` as :class:`HrvData`, or ``None`` if absent."""
raw = self._garmin.get_hrv_data(cdate)
if raw is None:
return None
return self._validate(HrvData, raw, "get_hrv_data")
# -- Body battery --------------------------------------------------------
def get_body_battery(
self, startdate: str, enddate: str | None = None
) -> list[BodyBatteryEntry]:
"""Return body battery entries between ``startdate`` and ``enddate``."""
raw = self._garmin.get_body_battery(startdate, enddate)
if not isinstance(raw, list):
return []
return [
self._validate(BodyBatteryEntry, item, "get_body_battery") for item in raw
]
# -- Training readiness --------------------------------------------------
def get_training_readiness(self, cdate: str) -> list[TrainingReadiness]:
"""Return training readiness snapshots for ``cdate``.
The underlying endpoint may return either a list of snapshots or a
single snapshot object depending on account/firmware behavior. This
wrapper normalizes both shapes to ``list[TrainingReadiness]``.
"""
raw = self._garmin.get_training_readiness(cdate)
if not raw:
# Empty list / empty dict / None — no snapshots available.
return []
if isinstance(raw, list):
return [
self._validate(TrainingReadiness, item, "get_training_readiness")
for item in raw
]
if isinstance(raw, dict):
return [self._validate(TrainingReadiness, raw, "get_training_readiness")]
return []
# -- Activities ----------------------------------------------------------
def get_activities_by_date(
self,
startdate: str,
enddate: str | None = None,
activitytype: str | None = None,
sortorder: str | None = None,
) -> list[Activity]:
"""Return activities between two dates as a list of :class:`Activity`."""
raw = self._garmin.get_activities_by_date(
startdate, enddate, activitytype, sortorder
)
if not isinstance(raw, list):
return []
return [
self._validate(Activity, item, "get_activities_by_date") for item in raw
]
__all__ = [
"Activity",
"ActivityType",
"BodyBatteryEntry",
"DailySleepDTO",
"DailyStats",
"GarminConnectResponseValidationError",
"HrvBaseline",
"HrvData",
"HrvSummary",
"SleepData",
"SleepScoreValue",
"SleepScores",
"TrainingReadiness",
"TypedGarmin",
]

573
garminconnect/workout.py Normal file
View File

@ -0,0 +1,573 @@
"""Typed workout models for Garmin Connect workouts.
This module provides Pydantic models for creating type-safe workout definitions.
Pydantic is an optional dependency - install it with: pip install pydantic
or: pip install garminconnect[workout]
"""
from __future__ import annotations
from contextlib import suppress
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from pydantic import BaseModel, ConfigDict, Field
else:
try:
from pydantic import BaseModel, ConfigDict, Field
except ImportError:
# Fallback if pydantic is not installed
BaseModel = object # type: ignore[assignment,misc]
ConfigDict = dict # type: ignore[assignment,misc]
def Field(*_args: Any, **_kwargs: Any) -> Any: # type: ignore[misc]
"""Placeholder Field function when pydantic is not installed."""
return None
# Sport Type IDs — from /workout-service/workout/types
class SportType:
"""Garmin workout sport type IDs."""
RUNNING = 1
CYCLING = 2
OTHER = 3
SWIMMING = 4
STRENGTH_TRAINING = 5
CARDIO_TRAINING = 6
YOGA = 7
PILATES = 8
HIIT = 9
MULTI_SPORT = 10
MOBILITY = 11
# Step Type IDs — from /workout-service/workout/types
class StepType:
"""Garmin workout step type IDs."""
WARMUP = 1
COOLDOWN = 2
INTERVAL = 3
RECOVERY = 4
REST = 5
REPEAT = 6
OTHER = 7
MAIN = 8
# Condition Type IDs — from /workout-service/workout/types
class ConditionType:
"""Garmin end condition type IDs."""
LAP_BUTTON = 1
TIME = 2
DISTANCE = 3
CALORIES = 4
POWER = 5
HEART_RATE = 6
ITERATIONS = 7
FIXED_REST = 8
FIXED_REPETITION = 9
REPS = 10
# Target Type IDs — from /workout-service/workout/types
class TargetType:
"""Garmin workout target type IDs."""
NO_TARGET = 1
POWER_ZONE = 2
CADENCE = 3
HEART_RATE_ZONE = 4
SPEED_ZONE = 5
PACE_ZONE = 6
GRADE = 7
HEART_RATE_LAP = 8
POWER_LAP = 9
RESISTANCE = 15
# Weight unit for strength workout target loads.
# Garmin stores ``weightValue`` in GRAMS tagged with this kilogram unit.
WEIGHT_UNIT_KILOGRAM = {"unitId": 8, "unitKey": "kilogram", "factor": 1000.0}
class SportTypeModel(BaseModel):
"""Sport type model."""
sportTypeId: int
sportTypeKey: str
displayOrder: int = 1
class EndConditionModel(BaseModel):
"""End condition model for workout steps."""
conditionTypeId: int
conditionTypeKey: str
displayOrder: int
displayable: bool = True
class TargetTypeModel(BaseModel):
"""Target type model for workout steps."""
workoutTargetTypeId: int
workoutTargetTypeKey: str
displayOrder: int
class StrokeTypeModel(BaseModel):
"""Stroke type model (for swimming workouts)."""
strokeTypeId: int = 0
displayOrder: int = 0
class EquipmentTypeModel(BaseModel):
"""Equipment type model."""
equipmentTypeId: int = 0
displayOrder: int = 0
class ExecutableStep(BaseModel):
"""Executable workout step (warmup, interval, recovery, cooldown, etc.)."""
type: str = "ExecutableStepDTO"
stepOrder: int
stepType: dict[str, Any] | None = None
endCondition: dict[str, Any] | None = None
endConditionValue: float | None = None
targetType: dict[str, Any] | None = None
strokeType: dict[str, Any] | None = None
equipmentType: dict[str, Any] | None = None
childStepId: int | None = None
model_config = ConfigDict(extra="allow")
class RepeatGroup(BaseModel):
"""Repeat group for repeating workout steps."""
type: str = "RepeatGroupDTO"
stepOrder: int
stepType: dict[str, Any] | None = None
numberOfIterations: int
workoutSteps: list[ExecutableStep | RepeatGroup]
endCondition: dict[str, Any] | None = None
endConditionValue: float | None = None
childStepId: int | None = None
smartRepeat: bool = False
model_config = ConfigDict(extra="allow")
# Update forward reference (only if pydantic is available)
with suppress(AttributeError, TypeError):
RepeatGroup.model_rebuild()
class WorkoutSegment(BaseModel):
"""Workout segment containing workout steps."""
segmentOrder: int
sportType: dict[str, Any]
workoutSteps: list[ExecutableStep | RepeatGroup]
model_config = ConfigDict(extra="allow")
class BaseWorkout(BaseModel):
"""Base workout model."""
workoutName: str
sportType: dict[str, Any]
estimatedDurationInSecs: int
workoutSegments: list[WorkoutSegment]
author: dict[str, Any] = Field(default_factory=dict)
description: str | None = None
model_config = ConfigDict(extra="allow")
def to_dict(self) -> dict[str, Any]:
"""Convert workout to dictionary for API upload."""
return self.model_dump(exclude_none=True, mode="json")
class RunningWorkout(BaseWorkout):
"""Running workout model."""
sportType: dict[str, Any] = Field(
default_factory=lambda: {
"sportTypeId": SportType.RUNNING,
"sportTypeKey": "running",
"displayOrder": 1,
}
)
class CyclingWorkout(BaseWorkout):
"""Cycling workout model."""
sportType: dict[str, Any] = Field(
default_factory=lambda: {
"sportTypeId": SportType.CYCLING,
"sportTypeKey": "cycling",
"displayOrder": 2,
}
)
class SwimmingWorkout(BaseWorkout):
"""Swimming workout model."""
sportType: dict[str, Any] = Field(
default_factory=lambda: {
"sportTypeId": SportType.SWIMMING,
"sportTypeKey": "swimming",
"displayOrder": 3,
}
)
class WalkingWorkout(BaseWorkout):
"""Walking workout model."""
sportType: dict[str, Any] = Field(
default_factory=lambda: {
"sportTypeId": 17,
"sportTypeKey": "walking",
"displayOrder": 17,
}
)
class MultiSportWorkout(BaseWorkout):
"""Multi-sport workout model."""
sportType: dict[str, Any] = Field(
default_factory=lambda: {
"sportTypeId": SportType.MULTI_SPORT,
"sportTypeKey": "multi_sport",
"displayOrder": 10,
}
)
class FitnessEquipmentWorkout(BaseWorkout):
"""Fitness equipment workout model."""
sportType: dict[str, Any] = Field(
default_factory=lambda: {
"sportTypeId": SportType.CARDIO_TRAINING,
"sportTypeKey": "cardio_training",
"displayOrder": 6,
}
)
class HikingWorkout(BaseWorkout):
"""Hiking workout model."""
sportType: dict[str, Any] = Field(
default_factory=lambda: {
"sportTypeId": 18,
"sportTypeKey": "hiking",
"displayOrder": 18,
}
)
class StrengthWorkout(BaseWorkout):
"""Strength training workout model.
Strength workouts are rep-based rather than time/distance-based. Build the
steps with :func:`create_strength_exercise_step` /
:func:`create_strength_rest_step` (or the :func:`create_strength_set`
convenience), and identify each exercise with a ``category`` /
``exerciseName`` pair from :mod:`garminconnect.exercises`.
"""
sportType: dict[str, Any] = Field(
default_factory=lambda: {
"sportTypeId": SportType.STRENGTH_TRAINING,
"sportTypeKey": "strength_training",
"displayOrder": 5,
}
)
# Helper functions for creating common workout steps
def create_warmup_step(
duration_seconds: float,
step_order: int = 1,
target_type: dict[str, Any] | None = None,
) -> ExecutableStep:
"""Create a warmup step."""
return ExecutableStep(
stepOrder=step_order,
stepType={
"stepTypeId": StepType.WARMUP,
"stepTypeKey": "warmup",
"displayOrder": 1,
},
endCondition={
"conditionTypeId": ConditionType.TIME,
"conditionTypeKey": "time",
"displayOrder": 2,
"displayable": True,
},
endConditionValue=duration_seconds,
targetType=target_type
or {
"workoutTargetTypeId": TargetType.NO_TARGET,
"workoutTargetTypeKey": "no.target",
"displayOrder": 1,
},
)
def create_interval_step(
duration_seconds: float,
step_order: int,
target_type: dict[str, Any] | None = None,
) -> ExecutableStep:
"""Create an interval step."""
return ExecutableStep(
stepOrder=step_order,
stepType={
"stepTypeId": StepType.INTERVAL,
"stepTypeKey": "interval",
"displayOrder": 3,
},
endCondition={
"conditionTypeId": ConditionType.TIME,
"conditionTypeKey": "time",
"displayOrder": 2,
"displayable": True,
},
endConditionValue=duration_seconds,
targetType=target_type
or {
"workoutTargetTypeId": TargetType.NO_TARGET,
"workoutTargetTypeKey": "no.target",
"displayOrder": 1,
},
)
def create_distance_interval_step(
distance_meters: float,
step_order: int,
target_type: dict[str, Any] | None = None,
) -> ExecutableStep:
"""Create an interval step that ends after a distance in meters."""
return ExecutableStep(
stepOrder=step_order,
stepType={
"stepTypeId": StepType.INTERVAL,
"stepTypeKey": "interval",
"displayOrder": 3,
},
endCondition={
"conditionTypeId": ConditionType.DISTANCE,
"conditionTypeKey": "distance",
"displayOrder": 3,
"displayable": True,
},
endConditionValue=distance_meters,
targetType=target_type
or {
"workoutTargetTypeId": TargetType.NO_TARGET,
"workoutTargetTypeKey": "no.target",
"displayOrder": 1,
},
)
def create_recovery_step(
duration_seconds: float,
step_order: int,
target_type: dict[str, Any] | None = None,
) -> ExecutableStep:
"""Create a recovery step."""
return ExecutableStep(
stepOrder=step_order,
stepType={
"stepTypeId": StepType.RECOVERY,
"stepTypeKey": "recovery",
"displayOrder": 4,
},
endCondition={
"conditionTypeId": ConditionType.TIME,
"conditionTypeKey": "time",
"displayOrder": 2,
"displayable": True,
},
endConditionValue=duration_seconds,
targetType=target_type
or {
"workoutTargetTypeId": TargetType.NO_TARGET,
"workoutTargetTypeKey": "no.target",
"displayOrder": 1,
},
)
def create_cooldown_step(
duration_seconds: float,
step_order: int,
target_type: dict[str, Any] | None = None,
) -> ExecutableStep:
"""Create a cooldown step."""
return ExecutableStep(
stepOrder=step_order,
stepType={
"stepTypeId": StepType.COOLDOWN,
"stepTypeKey": "cooldown",
"displayOrder": 2,
},
endCondition={
"conditionTypeId": ConditionType.TIME,
"conditionTypeKey": "time",
"displayOrder": 2,
"displayable": True,
},
endConditionValue=duration_seconds,
targetType=target_type
or {
"workoutTargetTypeId": TargetType.NO_TARGET,
"workoutTargetTypeKey": "no.target",
"displayOrder": 1,
},
)
def create_repeat_group(
iterations: int,
workout_steps: list[ExecutableStep | RepeatGroup],
step_order: int,
) -> RepeatGroup:
"""Create a repeat group."""
return RepeatGroup(
stepOrder=step_order,
stepType={
"stepTypeId": StepType.REPEAT,
"stepTypeKey": "repeat",
"displayOrder": 6,
},
numberOfIterations=iterations,
workoutSteps=workout_steps,
endCondition={
"conditionTypeId": ConditionType.ITERATIONS,
"conditionTypeKey": "iterations",
"displayOrder": 7,
"displayable": False,
},
endConditionValue=float(iterations),
)
def create_strength_exercise_step(
category: str,
step_order: int,
reps: int,
exercise_name: str = "",
weight_kg: float | None = None,
) -> ExecutableStep:
"""Create a rep-based strength exercise step.
Args:
category: Garmin exercise category, e.g. ``"BENCH_PRESS"``. See
:mod:`garminconnect.exercises` for the full list of valid values.
step_order: Position of this step within the segment (1-indexed, unique).
reps: Number of repetitions to perform.
exercise_name: Specific exercise variant, e.g. ``"LAT_PULLDOWN"``. An
empty string shows only the category name.
weight_kg: Optional target weight in kilograms.
"""
extra: dict[str, Any] = {"category": category, "exerciseName": exercise_name}
if weight_kg is not None:
extra["weightValue"] = float(weight_kg) * 1000.0
extra["weightUnit"] = dict(WEIGHT_UNIT_KILOGRAM)
return ExecutableStep(
stepOrder=step_order,
stepType={
"stepTypeId": StepType.INTERVAL,
"stepTypeKey": "interval",
"displayOrder": 3,
},
endCondition={
"conditionTypeId": ConditionType.REPS,
"conditionTypeKey": "reps",
"displayOrder": 10,
"displayable": True,
},
endConditionValue=float(reps),
targetType={
"workoutTargetTypeId": TargetType.NO_TARGET,
"workoutTargetTypeKey": "no.target",
"displayOrder": 1,
},
**extra,
)
def create_strength_rest_step(
duration_seconds: float,
step_order: int,
) -> ExecutableStep:
"""Create a timed rest step between strength sets."""
return ExecutableStep(
stepOrder=step_order,
stepType={
"stepTypeId": StepType.REST,
"stepTypeKey": "rest",
"displayOrder": 5,
},
endCondition={
"conditionTypeId": ConditionType.TIME,
"conditionTypeKey": "time",
"displayOrder": 2,
"displayable": True,
},
endConditionValue=float(duration_seconds),
targetType={
"workoutTargetTypeId": TargetType.NO_TARGET,
"workoutTargetTypeKey": "no.target",
"displayOrder": 1,
},
)
def create_strength_set(
category: str,
step_order: int,
sets: int,
reps: int,
rest_seconds: float,
exercise_name: str = "",
weight_kg: float | None = None,
) -> RepeatGroup:
"""Create a full strength exercise block as a repeat group.
Produces ``sets`` repetitions of ``reps`` reps of the exercise followed by
a timed rest, i.e. one "N Sets" block in the Garmin workout editor.
``step_order`` is the order of the repeat group; the inner exercise and rest
steps take ``step_order + 1`` and ``step_order + 2``. Advance the caller's
running order counter by 3 for each block so every ``stepOrder`` is unique.
"""
exercise = create_strength_exercise_step(
category,
step_order + 1,
reps,
exercise_name=exercise_name,
weight_kg=weight_kg,
)
rest = create_strength_rest_step(rest_seconds, step_order + 2)
return create_repeat_group(sets, [exercise, rest], step_order)

357
pyproject.toml Normal file
View File

@ -0,0 +1,357 @@
[project]
name = "garminconnect"
version = "0.3.11"
description = "Python 3 API wrapper for Garmin Connect"
authors = [
{ name = "Ron Klinkien", email = "ron@cyberjunky.nl" },
]
dependencies = [
"requests>=2.33.0",
"ua-generator>=1.0",
]
readme = "README.md"
classifiers = [
"Development Status :: 5 - Production/Stable",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"License :: OSI Approved :: MIT License",
"Operating System :: MacOS :: MacOS X",
"Operating System :: Microsoft :: Windows",
"Operating System :: POSIX :: Linux",
"Operating System :: OS Independent",
]
keywords = [
"garmin connect",
"api",
"garmin",
]
requires-python = ">=3.12"
[project.license]
text = "MIT"
[project.urls]
Homepage = "https://github.com/cyberjunky/python-garminconnect"
Issues = "https://github.com/cyberjunky/python-garminconnect/issues"
Changelog = "https://github.com/cyberjunky/python-garminconnect/releases"
[project.optional-dependencies]
tls = [
"curl_cffi>=0.15.0",
]
dev = [
"ipython",
"ipdb",
"ipykernel",
"pandas",
"matplotlib",
]
workout = [
"pydantic>=2.4.0",
]
typed = [
"pydantic>=2.4.0",
]
security = [
"bandit[toml]",
"pip-audit",
]
linting = [
"black[jupyter]",
"ruff",
"mypy",
"isort",
"types-requests",
"pre-commit",
"codespell",
]
testing = [
"coverage",
"pytest",
"pytest-vcr>=1.0.2",
"vcrpy>=7.0.0",
]
example = [
"readchar",
]
[build-system]
requires = [
"pdm-backend",
]
build-backend = "pdm.backend"
[tool.pytest.ini_options]
addopts = "--ignore=__pypackages__ --ignore-glob=*.yaml -m 'not integration'"
markers = [
"integration: requires a real Garmin account or a private VCR recording",
]
[tool.mypy]
ignore_missing_imports = true
python_version = "3.12"
disallow_untyped_defs = true
warn_unused_ignores = true
[[tool.mypy.overrides]]
module = [
"test_garmin",
"test_garmin_unit",
"test_retry_decorator",
"test_workout_constants",
"test_strength_workout",
"test_login_recovery",
"test_token_permissions",
"test_widget_mfa",
"test_cassette_sanitization",
"test_activity_details",
"test_demo_security",
"conftest",
]
disallow_untyped_defs = false
warn_unused_ignores = false
[tool.isort]
profile = "black"
line_length = 88
known_first_party = "garminconnect"
skip_glob = [
"tests/*",
"test_data/*",
]
[tool.pdm]
distribution = true
[tool.pdm.build]
excludes = [
"tests/**",
"test_data/**",
".github/**",
]
[tool.pdm.scripts]
install = "pdm install --group :all"
codespell = "pre-commit run codespell --all-files"
clean = "python -c \"import shutil, pathlib; [shutil.rmtree(p, ignore_errors=True) for p in pathlib.Path('.').rglob('__pycache__')]; [p.unlink(missing_ok=True) for p in pathlib.Path('.').rglob('*.py[co]')]\""
pre-commit-install = "pre-commit install"
pre-commit-run = "pre-commit run --all-files"
pre-commit-run-staged = "pre-commit run"
pre-commit-update = "pre-commit autoupdate"
build = "pdm build"
clean-vcr = "python3 -c \"import pathlib; p=pathlib.Path('tests/cassettes'); [f.unlink() for f in p.glob('*.yaml')]\""
[tool.pdm.scripts.format]
composite = [
"pdm run ruff check . --fix --unsafe-fixes",
"pdm run isort . --skip-gitignore",
"pdm run black -l 88 .",
]
[tool.pdm.scripts.lint]
composite = [
"pdm run isort --check-only . --skip-gitignore",
"pdm run ruff check .",
"pdm run black -l 88 . --check --diff",
"pdm run mypy garminconnect tests",
]
[tool.pdm.scripts.test]
cmd = "pdm run coverage run -m pytest -v --durations=10"
[tool.pdm.scripts.testcov]
composite = [
"test",
"pdm run coverage html",
"pdm run coverage xml -o coverage/coverage.xml",
]
[tool.pdm.scripts.publish]
composite = [
"build",
"pdm publish",
]
[tool.pdm.scripts.record-vcr]
cmd = "pdm run pytest tests/test_garmin.py -v --vcr-record=new_episodes"
[tool.pdm.scripts.record-vcr.env]
GARMINTOKENS = "~/.garminconnect"
[tool.pdm.scripts.reset-vcr]
composite = [
"clean-vcr",
"record-vcr",
]
[tool.pdm.scripts.all]
composite = [
"lint",
"codespell",
"pre-commit-run",
"test",
]
[tool.pdm.dev-dependencies]
dev = [
"ipython",
"ipdb",
"ipykernel",
"pandas",
"matplotlib",
]
linting = [
"black[jupyter]",
"ruff",
"mypy",
"isort",
"types-requests",
"pre-commit",
"codespell",
]
testing = [
"coverage",
"pytest",
"pytest-vcr>=1.0.2",
"vcrpy>=7.0.0",
]
example = [
"readchar",
]
[tool.ruff]
line-length = 88
target-version = "py312"
exclude = [
".git",
".venv",
"__pycache__",
".pytest_cache",
"build",
"dist",
"tests",
"test_data",
]
[tool.ruff.lint]
select = [
"E",
"W",
"F",
"I",
"B",
"C4",
"UP",
"ARG",
"SIM",
"S",
"D",
"PTH",
"PL",
"RUF",
"TRY",
"PERF",
"LOG",
"G",
"T20",
"PIE",
"RET",
"TCH",
"ERA",
]
ignore = [
"E501",
"B008",
"C901",
"D100",
"D104",
"D105",
"D107",
"D203",
"D213",
"PLR0913",
"PLR0917",
"PLR2004",
"PLR0912",
"PLR0915",
"PLC0415",
"TRY003",
"TRY004",
"TRY301",
"G004",
"D205",
"D401",
"D102",
"D106",
"D417",
"TRY401",
"TRY203",
"TRY300",
"ERA001",
]
unfixable = []
[tool.ruff.lint.per-file-ignores]
"tests/*" = [
"ARG",
"S101",
"D103",
]
"garminconnect/fit.py" = [
"D",
"RUF012",
"PLW2901",
]
"garminconnect/workout.py" = [
"D401",
]
"demo.py" = [
"T20",
"S101",
"ERA",
"RUF001",
"PTH",
"PERF401",
"PERF203",
"PLR0911",
"D103",
]
"example.py" = [
"S110",
"PLR0911",
"T20",
"PERF203",
]
"test_strategy.py" = [
"T20",
"D103",
]
"scripts/*" = [
"T20",
"D103",
]
"tests/test_token_permissions.py" = [
"S105",
]
[tool.coverage.run]
source = [
"garminconnect",
]
omit = [
"*/tests/*",
"*/test_*",
]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"if self.debug:",
"if settings.DEBUG",
"raise AssertionError",
"raise NotImplementedError",
"if 0:",
"if __name__ == \"__main__\":",
"@(abc\\.)?abstractmethod",
]