diff --git a/.env-temmplate b/.env-temmplate new file mode 100644 index 0000000..3511d59 --- /dev/null +++ b/.env-temmplate @@ -0,0 +1,9 @@ +# .env-template + +STRAVA_CLIENT_ID="12345" +STRAVA_CLIENT_SECRET="" +STRAVA_ACCESS_TOKEN="" +STRAVA_EXPIRES_AT="" +STRAVA_EXPIRES_IN="" +STRAVA_REFRESH_TOKEN="" +GARMIN_TOKENS="" \ No newline at end of file diff --git a/.github/workflows/self-hosted-runner.yml b/.github/workflows/self-hosted-runner.yml new file mode 100644 index 0000000..1461c24 --- /dev/null +++ b/.github/workflows/self-hosted-runner.yml @@ -0,0 +1,50 @@ +name: Self-hosted runner β€” Run MyWhoosh2Garmin + +on: + workflow_dispatch: {} + +jobs: + run-on-self-hosted: + name: Run on self-hosted runner + runs-on: [self-hosted] + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + + - name: Ensure `uv` CLI available (or install fallback) + shell: bash + run: | + set -euo pipefail + if command -v uv >/dev/null 2>&1; then + uv --version + echo "uv found: $(command -v uv)" + else + echo "uv not found; installing via curl" + curl -LsSf https://astral.sh/uv/install.sh | sh + fi + + - name: Create virtual environment + shell: bash + run: | + uv venv --python 3.13 + + - name: Install project (from pyproject.toml) + shell: bash + run: | + uv pip install -r pyproject.toml + + - name: Run myWhoosh2Garmin.py + shell: bash + env: + STRAVA_CLIENT_ID: ${{ secrets.STRAVA_CLIENT_ID }} + STRAVA_CLIENT_SECRET: ${{ secrets.STRAVA_CLIENT_SECRET }} + STRAVA_ACCESS_TOKEN: ${{ secrets.STRAVA_ACCESS_TOKEN }} + STRAVA_EXPIRES_AT: ${{ secrets.STRAVA_EXPIRES_AT }} + STRAVA_EXPIRES_IN: ${{ secrets.STRAVA_EXPIRES_IN }} + STRAVA_REFRESH_TOKEN: ${{ secrets.STRAVA_REFRESH_TOKEN }} + GARMIN_TOKENS: ${{ secrets.GARMIN_TOKENS }} + run: | + set -euo pipefail + source .venv/bin/activate + python myWhoosh2Garmin.py diff --git a/.gitignore b/.gitignore index d03c1b0..bcf31b1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,64 @@ -.garth/* +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +.venv/ +venv/ +ENV/ +env/ +.python-version + +# UV +.uv/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Logs *.log -*.json -*.lock \ No newline at end of file + +# Project specific +.garth/ +installed_packages.json +backup_path.json +*.fit + +# Lock files (exclude Pipfile.lock if migrating) +Pipfile.lock + +# OS +.DS_Store +Thumbs.db + +data/ +.env +**token** +strava.db + +.terraform/ +.terraform.lock.hcl +terraform.tfstate +terraform.tfstate.backup \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..0daa41e --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: trailing-whitespace + - id: check-json + - id: check-toml + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.14.5 + hooks: + # Run the linter + - id: ruff + args: [--fix] + # Run the formatter + - id: ruff-format diff --git a/MyWhooshMonitor.ps1 b/MyWhooshMonitor.ps1 deleted file mode 100644 index 7889bda..0000000 --- a/MyWhooshMonitor.ps1 +++ /dev/null @@ -1,44 +0,0 @@ -# Define the JSON config file path -$configFile = "$PSScriptRoot\mywhoosh_config.json" -$myWhooshApp = "myWhoosh Indoor Cycling App.app" - -# Check if the JSON file exists and read the stored path -if (Test-Path $configFile) { - $config = Get-Content -Path $configFile | ConvertFrom-Json - $mywhooshPath = $config.path -} else { - $mywhooshPath = $null -} - -# Validate the stored path -if (-not $mywhooshPath -or -not (Test-Path $mywhooshPath)) { - Write-Host "Searching for $myWhooshApp" - $mywhooshPath = Get-ChildItem -Path "/Applications" -Filter $myWhooshApp -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 - - if (-not $mywhooshPath) { - Write-Host " not found!" - exit 1 - } - - $mywhooshPath = $mywhooshPath.FullName - - # Store the path in the JSON file - $config = @{ path = $mywhooshPath } - $config | ConvertTo-Json | Set-Content -Path $configFile -} - -Write-Host "Found $myWhooshApp at $mywhooshPath" - -# Start mywhoosh.exe -Start-Process -FilePath $mywhooshPath - -# Wait for the application to finish -Write-Host "Waiting for $myWhooshApp to finish..." -while ($process = ps -ax | grep -i $myWhooshApp | grep -v "grep") { - Write-Output $process - Start-Sleep -Seconds 5 -} - -# Run the Python script -Write-Host "$myWhooshApp has finished, running Python script..." -python3 /Users/jayqueue/Development/Python/MyWhoosh2Garmin/myWhoosh2Garmin.py diff --git a/Pipfile b/Pipfile deleted file mode 100644 index 1f83237..0000000 --- a/Pipfile +++ /dev/null @@ -1,11 +0,0 @@ -[[source]] -url = "https://pypi.org/simple" -verify_ssl = true -name = "pypi" - -[packages] -garth = "0.5.2" -fit_tool = "0.9.13" - -[requires] -python_version = "3.13" diff --git a/README.md b/README.md index b22afe4..91f544f 100644 --- a/README.md +++ b/README.md @@ -1,203 +1,228 @@ -

myWhoosh2Garmin

+# MyWhoosh2Garmin -

🧐Features

+> **Automated Strava-to-Garmin Sync for MyWhoosh Activities** +> +> Zero manual steps. Just ride, and your training effect appears on Garmin Connect automatically. -* Finds the .fit files from your MyWhoosh installation. -* Fix the missing power & heart rate averages. -* Removes the temperature. -* Create a backup file to a folder you select. -* Uploads the fixed .fit file to Garmin Connect. +## 🎯 What This Does -

πŸ› οΈ Installation Steps:

+This project automatically syncs your MyWhoosh virtual cycling activities from Strava to Garmin Connect as `.fit` files, ensuring they're recognized as Garmin device uploads. This is crucial for having your **training effect** properly reflected in your **Garmin Training Readiness** and **Preparation Score**. -

1. Download myWhoosh2Garmin.py to your filesystem to a folder or your choosing.

+### The Problem This Solves -

2. Go to the folder where you downloaded the script in a shell.

+The original [forked project](https://github.com/OriginalRepo/MyWhoosh2Garmin) required multiple manual steps: +- πŸ“ Manually downloading `.fit` files from MyWhoosh website +- πŸ”§ Applying transformations to fix power/heart rate averages +- 🌑️ Removing temperature data +- πŸ“€ Manually uploading to Garmin Connect -- MacOS: Terminal of your choice. -- Windows: Start > Run > cmd or Start > Run > powershell +**This was tedious and error-prone.** Big training sessions weren't impacting training preparation scores because manual uploads were missed. -

3. Install `pipenv` (if not already installed):

+### The Solution -``` -pip3 install pipenv -or -pip install pipenv -``` -

4. Install dependencies in a virtual envioronment:

+This fully automated workflow: +- βœ… **Zero manual steps** β€” runs completely in the cloud via GitHub Actions +- βœ… **Automatic detection** β€” finds new MyWhoosh activities on Strava +- βœ… **Smart filtering** β€” only uploads activities not already on Garmin Connect +- βœ… **Training effect preserved** β€” `.fit` files recognized as Garmin device uploads +- βœ… **Webhook support** β€” optional instant sync when you upload to Strava -``` -pipenv install +## πŸ—οΈ Architecture + +```mermaid +graph LR + A[MyWhoosh App] -->|Auto-upload| B[Strava] + B -->|Webhook trigger| C{GitHub Actions} + C -->|If Created & Virtual Ride: Fetch activity via API| D[Strava Client] + D -->|Download .fit data| E[FIT Builder] + E -->|Convert to Garmin .fit| F[Garmin Client] + F -->|Upload as device| G[Garmin Connect] ``` -

5. Activate the virtual environment:

+### Key Components Reused -``` -pipenv shell -``` +- **Garmin Client** ([garth](https://github.com/matin/garth)) β€” handles authentication and upload +- **FIT Builder** ([fit_tool](https://bitbucket.org/stagescycling/fit_tool/src/main/)) β€” converts Strava JSON to Garmin-compatible `.fit` +- **Strava API** β€” OAuth2 authentication and activity download -

5. Run the script:

+## πŸš€ Quick Start -``` -python3 myWhoosh2Garmin.py -or -python myWhoosh2Garmin.py -``` - -

6. Choose your backup folder.

+> **πŸ“– For detailed step-by-step instructions, see [SETUP.md](SETUP.md)** -

MacOS

+### Prerequisites -![image](https://github.com/user-attachments/assets/2c6c1072-bacf-4f0c-8861-78f62bf51648) +- **GitHub account** (for running GitHub Actions) +- **Strava account** with MyWhoosh activities auto-uploaded +- **Garmin Connect account** +- **Strava API application** ([create one here](https://www.strava.com/settings/api)) +### 1️⃣ Repository Setup -

Windows

+1. **Fork this repository** or clone it to your GitHub account +2. **Enable GitHub Actions** in your repository settings +### 2️⃣ Authentication Setup -![image](https://github.com/user-attachments/assets/d1540291-4e6d-488e-9dcf-8d7b68651103) +#### Garmin Authentication -

7. Enter your Garmin Connect credentials

+Run the Garmin setup script locally to authenticate and generate tokens: -``` -2024-11-21 10:08:04,014 No existing session. Please log in. -Username: -Password: -2024-11-21 10:08:33,545 Authenticating... +```bash +# Clone the repo +git clone https://github.com/YourUsername/MyWhoosh2Garmin.git +cd MyWhoosh2Garmin -2024-11-21 10:08:37,107 Successfully authenticated! +# Install dependencies +pip install uv +uv venv +source .venv/bin/activate # On Windows: .venv\Scripts\activate +uv pip install -r pyproject.toml + +# Run Garmin authentication +python garmin/utils.py ``` -

8. Run the script when you're done riding or running.

+You'll be prompted for: +- Garmin username (email) +- Garmin password +- **2FA code** (if enabled) -``` -2024-11-21 10:08:37,107 Checking for .fit files in directory: . -2024-11-21 10:08:37,107 Found the most recent .fit file: MyNewActivity-3.8.5.fit. -2024-11-21 10:08:37,107 Cleaning up yNewActivity-3.8.5_2024-11-21_100837.fit. -2024-11-21 10:08:37,855 Cleaned-up file saved as MyNewActivity-3.8.5_2024-11-21_100837.fit -2024-11-21 10:08:37,871 Successfully cleaned MyNewActivity-3.8.5.fit and saved it as MyNewActivity-3.8.5_2024-11-21_100837.fit. -2024-11-21 10:08:38,408 Duplicate activity found on Garmin Connect. +The script will output a `GARMIN_TOKENS` string β€” **save this securely!** + +#### Strava Authentication + +Run the Strava setup script to initialize OAuth tokens: + +```bash +python strava/client.py ``` -

(9. Or see below to automate the process)

+Follow the prompts: +1. Click the authorization URL that appears +2. Authorize the application +3. Copy the callback URL from your browser +4. Paste it back into the terminal -

ℹ️ Automation tips

+This creates `strava_tokens.json` with your access/refresh tokens. -What if you want to automate the whole process: -

MacOS

+### 3️⃣ Configure GitHub Secrets -PowerShell on MacOS (Verified & works) +Go to your repository β†’ **Settings** β†’ **Secrets and variables** β†’ **Actions** β†’ **New repository secret** -You need Powershell +Add the following secrets: -```shell -brew install powershell/tap/powershell -``` +| Secret Name | Description | Where to Find | +|-------------|-------------|---------------| +| `STRAVA_CLIENT_ID` | Your Strava API application ID | [Strava API Settings](https://www.strava.com/settings/api) | +| `STRAVA_CLIENT_SECRET` | Your Strava API secret | [Strava API Settings](https://www.strava.com/settings/api) | +| `STRAVA_ACCESS_TOKEN` | OAuth access token | From `strava_tokens.json` after setup | +| `STRAVA_EXPIRES_AT` | Token expiration timestamp | From `strava_tokens.json` | +| `STRAVA_EXPIRES_IN` | Token expiration duration | From `strava_tokens.json` | +| `STRAVA_REFRESH_TOKEN` | OAuth refresh token | From `strava_tokens.json` | +| `GARMIN_TOKENS` | Garmin authentication tokens | From `garmin/utils.py` output | -```powershell -# Define the JSON config file path -$configFile = "$PSScriptRoot\mywhoosh_config.json" -$myWhooshApp = "myWhoosh Indoor Cycling App.app" - -# Check if the JSON file exists and read the stored path -if (Test-Path $configFile) { - $config = Get-Content -Path $configFile | ConvertFrom-Json - $mywhooshPath = $config.path -} else { - $mywhooshPath = $null -} - -# Validate the stored path -if (-not $mywhooshPath -or -not (Test-Path $mywhooshPath)) { - Write-Host "Searching for $myWhooshApp" - $mywhooshPath = Get-ChildItem -Path "/Applications" -Filter $myWhooshApp -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 - - if (-not $mywhooshPath) { - Write-Host " not found!" - exit 1 - } - - $mywhooshPath = $mywhooshPath.FullName - - # Store the path in the JSON file - $config = @{ path = $mywhooshPath } - $config | ConvertTo-Json | Set-Content -Path $configFile -} - -Write-Host "Found $myWhooshApp at $mywhooshPath" - -Start-Process -FilePath $mywhooshPath - -# Wait for the application to finish -Write-Host "Waiting for $myWhooshApp to finish..." -while ($process = ps -ax | grep -i $myWhooshApp | grep -v "grep") { - Write-Output $process - Start-Sleep -Seconds 5 -} - -# Run the Python script -Write-Host "$myWhooshApp has finished, running Python script..." -python3 "/MyWhoosh2Garmin/myWhoosh2Garmin.py" -``` +### 4️⃣ Run the Workflow + +#### Manual Trigger -AppleScript (need to test further) +Go to **Actions** β†’ **Self-hosted runner β€” Run MyWhoosh2Garmin** β†’ **Run workflow** -```applescript -TODO: needs more work +That's it! The workflow will: +1. Fetch your last 7 days of Garmin virtual cycling activities +2. Check Strava for MyWhoosh activities +3. Download new activities not on Garmin +4. Convert to `.fit` format +5. Upload to Garmin Connect + +#### Automatic Webhook Trigger (Optional) + +For **instant sync** after every Strava upload, set up a webhook using my [WebhookProcessor](https://github.com/MarcChen/WebhookProcessor): + +1. Deploy the WebhookProcessor to handle Strava webhook events +2. Configure it to trigger your GitHub Actions workflow +3. Register the webhook URL with Strava + +Now every MyWhoosh activity uploaded to Strava will automatically sync to Garmin within minutes! ⚑ + +## πŸ“‹ Environment Variables + +The following environment variables are required for the complete workflow: + +```bash +# Strava OAuth credentials +STRAVA_CLIENT_ID="12345" +STRAVA_CLIENT_SECRET="your_strava_client_secret" +STRAVA_ACCESS_TOKEN="your_strava_access_token" +STRAVA_EXPIRES_AT="1234567890" +STRAVA_EXPIRES_IN="21600" +STRAVA_REFRESH_TOKEN="your_strava_refresh_token" + +# Garmin authentication (from garth client) +GARMIN_TOKENS="your_garmin_tokens_string" ``` -

Windows

+> **Note:** For local development, copy `.env-template` to `.env` and fill in your values. -Windows .ps1 (PowerShell) file (Untested on Windows) -```powershell -# Define the JSON config file path -$configFile = "$PSScriptRoot\mywhoosh_config.json" +## πŸ› οΈ How It Works -# Check if the JSON file exists and read the stored path -if (Test-Path $configFile) { - $config = Get-Content -Path $configFile | ConvertFrom-Json - $mywhooshPath = $config.path -} else { - $mywhooshPath = $null -} +### Workflow Steps -# Validate the stored path -if (-not $mywhooshPath -or -not (Test-Path $mywhooshPath)) { - Write-Host "Searching for mywhoosh.exe..." - $mywhooshPath = Get-ChildItem -Path "C:\PROGRAM FILES" -Filter "mywhoosh.exe" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 +1. **Authentication** + - Garmin: Uses pre-authenticated tokens from `GARMIN_TOKENS` + - Strava: Uses OAuth2 with automatic token refresh - if (-not $mywhooshPath) { - Write-Host "mywhoosh.exe not found!" - exit 1 - } +2. **Activity Discovery** + - Fetches last 7 days of virtual cycling activities from Garmin Connect + - Fetches recent MyWhoosh activities from Strava (filtered by name containing "MyWhoosh") - $mywhooshPath = $mywhooshPath.FullName +3. **Smart Filtering** + - Compares Strava and Garmin activities by start time + - Only processes activities not already on Garmin - # Store the path in the JSON file - $config = @{ path = $mywhooshPath } - $config | ConvertTo-Json | Set-Content -Path $configFile -} +4. **Data Conversion** + - Downloads Strava activity data (metadata + streams: power, heart rate, cadence, etc.) + - Builds Garmin-compatible `.fit` file using `fit_tool` -Write-Host "Found mywhoosh.exe at $mywhooshPath" +5. **Upload** + - Uploads `.fit` file to Garmin Connect using `garth` client + - File is recognized as a Garmin device upload (triggers training effect) -# Start mywhoosh.exe -Start-Process -FilePath $mywhooshPath +### Database Tracking -# Wait for the application to finish -Write-Host "Waiting for mywhoosh to finish..." -while (Get-Process -Name "mywhoosh" -ErrorAction SilentlyContinue) { - Start-Sleep -Seconds 5 -} +The workflow uses SQLite (`strava.db`) to track downloaded activities and prevent duplicate processing. -# Run the Python script -Write-Host "mywhoosh has finished, running Python script..." -python "C:\Path\to\myWhoosh2Garmin.py" -``` +## πŸ”’ Security Notes + +- ⚠️ **Never commit** `.env` or token files to version control +- πŸ” Store all credentials as **GitHub Secrets** +- πŸ”„ Tokens are automatically refreshed when expired +- πŸ—‘οΈ Processed `.fit` files are deleted after upload + +## πŸ“¦ Dependencies + +- **Python 3.13+** +- **uv** (package manager) +- Key libraries: + - `garth` β€” Garmin Connect API client + - `pydantic` / `pydantic-settings` β€” configuration management + - `requests` β€” HTTP client + - `fit_tool` β€” FIT file manipulation + +## 🀝 Contributing + +Feel free to open issues or pull requests! This project is a personal automation tool, but improvements are welcome. + +## πŸ“„ License + +[GPL-3.0 License](LICENSE) + +## πŸ™ Acknowledgments -

πŸ’» Built with

+- Original inspiration from [MyWhoosh2Garmin](https://github.com/OriginalRepo/MyWhoosh2Garmin) +- [matin/garth](https://github.com/matin/garth) for excellent Garmin API client +- [fit_tool](https://bitbucket.org/stagescycling/fit_tool/src/main/) for FIT file utilities +- Strava API for activity data access -Technologies used in the project: +--- -* Neovim -* Garth -* tKinter -* Fit\_tool +**Made with ❀️ for cyclists who want their training data to just work.** diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..e716ff8 --- /dev/null +++ b/SETUP.md @@ -0,0 +1,186 @@ +# πŸ”§ Setup Guide + +This guide walks you through setting up the MyWhoosh2Garmin automation from scratch. + +## Prerequisites + +Before you begin, make sure you have: + +- βœ… A GitHub account (for running GitHub Actions) +- βœ… A Strava account with MyWhoosh auto-upload enabled +- βœ… A Garmin Connect account +- βœ… Python 3.13+ installed locally (for initial setup only) + +## Step 1: Fork the Repository + +1. Click the **Fork** button at the top of this repository +2. Clone your fork to your local machine: + ```bash + git clone https://github.com/YourUsername/MyWhoosh2Garmin.git + cd MyWhoosh2Garmin + ``` + +## Step 2: Local Environment Setup + +Install dependencies using `uv` (recommended) or `pip`: + +```bash +# Install uv package manager +pip install uv + +# Create virtual environment +uv venv + +# Activate virtual environment +source .venv/bin/activate # On macOS/Linux +# OR +.venv\Scripts\activate # On Windows + +# Install dependencies +uv pip install -r pyproject.toml +``` + +## Step 3: Strava API Application + +1. Go to [Strava API Settings](https://www.strava.com/settings/api) +2. Create a new application with these settings: + - **Application Name**: MyWhoosh2Garmin (or your choice) + - **Category**: Training + - **Club**: Leave empty + - **Website**: Your GitHub repo URL + - **Authorization Callback Domain**: `localhost` +3. Note your **Client ID** and **Client Secret** + +## Step 4: Authenticate with Strava + +Run the Strava setup script: + +```bash +python setup_strava_auth.py +``` + +Follow the prompts: +1. Enter your **Client ID** and **Client Secret** +2. Open the authorization URL in your browser +3. Click **Authorize** +4. Copy the full callback URL from your browser (it will look like `http://localhost/exchange_token?code=...`) +5. Paste it back into the terminal + +The script will output all the GitHub Secrets you need. **Keep this window open** β€” you'll need these values in Step 6. + +## Step 5: Authenticate with Garmin + +Run the Garmin setup script: + +```bash +python setup_garmin_auth.py +``` + +Follow the prompts: +1. Enter your Garmin email address +2. Enter your Garmin password +3. If you have 2FA enabled, enter the code when prompted + +The script will output the `GARMIN_TOKENS` secret. **Copy this** β€” you'll need it in Step 6. + +## Step 6: Configure GitHub Secrets + +Now add all the secrets to your GitHub repository: + +1. Go to your repository on GitHub +2. Click **Settings** β†’ **Secrets and variables** β†’ **Actions** +3. Click **New repository secret** for each of the following: + +### From Strava Setup Script + +| Secret Name | Value | +|-------------|-------| +| `STRAVA_CLIENT_ID` | Your Strava Client ID | +| `STRAVA_CLIENT_SECRET` | Your Strava Client Secret | +| `STRAVA_ACCESS_TOKEN` | From script output | +| `STRAVA_EXPIRES_AT` | From script output | +| `STRAVA_EXPIRES_IN` | From script output | +| `STRAVA_REFRESH_TOKEN` | From script output | + +### From Garmin Setup Script + +| Secret Name | Value | +|-------------|-------| +| `GARMIN_TOKENS` | The full token string from script output | + +## Step 7: Test the Workflow + +1. Go to **Actions** tab in your repository +2. Click **Self-hosted runner β€” Run MyWhoosh2Garmin** +3. Click **Run workflow** β†’ **Run workflow** + +The workflow will: +- βœ… Check your recent Garmin activities +- βœ… Fetch MyWhoosh activities from Strava +- βœ… Upload any new activities to Garmin Connect + +Check the workflow logs to verify everything worked! + +## Step 8: Optional - Set Up Webhook for Instant Sync + +For automatic sync immediately after uploading to Strava, you can set up a webhook using my [WebhookProcessor](https://github.com/MarcChen/WebhookProcessor): + +### Quick Setup + +1. Deploy WebhookProcessor to your preferred cloud platform (Vercel, Cloudflare Workers, etc.) +2. Set up the webhook to trigger your GitHub Actions workflow +3. Configure Strava webhook subscription: + ```bash + # Create webhook subscription + curl -X POST https://www.strava.com/api/v3/push_subscriptions \ + -F client_id=YOUR_CLIENT_ID \ + -F client_secret=YOUR_CLIENT_SECRET \ + -F callback_url=YOUR_WEBHOOK_URL \ + -F verify_token=RANDOM_STRING + ``` + +See [WebhookProcessor documentation](https://github.com/MarcChen/WebhookProcessor) for detailed instructions. + +## Troubleshooting + +### Token Expiration + +Strava tokens automatically refresh when expired. If you see authentication errors: +1. Re-run `setup_strava_auth.py` +2. Update the GitHub Secrets with new values + +### Garmin Authentication Failed + +If Garmin authentication fails: +1. Check your username/password are correct +2. If you have 2FA, make sure you're entering the code quickly +3. Re-run `setup_garmin_auth.py` and update `GARMIN_TOKENS` secret + +### No Activities Found + +Make sure: +- Your MyWhoosh activities are uploaded to Strava +- Activity names contain "MyWhoosh" +- Activity type is set to "Virtual Ride" +- You ran a ride within the last 7 days + +### Workflow Fails + +Check the workflow logs in the Actions tab for specific error messages. Common issues: +- Missing or incorrect GitHub Secrets +- Token expiration (re-authenticate and update secrets) +- Network connectivity issues (re-run the workflow) + +## Security Best Practices + +- πŸ”’ **Never commit** `.env` or `strava_tokens.json` to version control (they're gitignored) +- πŸ”’ **Never share** your GitHub Secrets publicly +- πŸ”’ **Rotate tokens** periodically for better security +- πŸ”’ **Use 2FA** on both Strava and Garmin accounts + +## Next Steps + +Once everything is working: +- Set up a webhook for instant sync (Step 8) +- Customize the workflow schedule in `.github/workflows/self-hosted-runner.yml` +- Star this repo if you find it useful! ⭐ diff --git a/apple-script/MyWhoosh2Garmin-AS.scpt b/apple-script/MyWhoosh2Garmin-AS.scpt deleted file mode 100644 index b39033d..0000000 Binary files a/apple-script/MyWhoosh2Garmin-AS.scpt and /dev/null differ diff --git a/apple-script/README.md b/apple-script/README.md deleted file mode 100644 index e27800c..0000000 --- a/apple-script/README.md +++ /dev/null @@ -1,44 +0,0 @@ -

Apple Script to automate Garmin Upload

-

The app will run and constantly check (every 30 seconds) whether MyWhoosh is running. Once started, it will listen to My Whoosh being quit/exited. In case My Whoosh was exited/quit, it will run the myWhoosh2Garmin.py script that needs to be installed and setup previously.

-

πŸ› οΈ Installation Steps:

-
    -
  1. Download MyWhoosh2Garmin-AS.scpt to your filesystem to a folder of your choosing.
  2. -
  3. Go to the folder where you downloaded the script via Mac Finder.
  4. -
  5. Open the script in the Apple Script Editor and set the property pythonScriptPath to the location where you downloaded the - myWhoosh2Garmin.py script.
  6. - -``` -property targetApp : "MyWhoosh Indoor Cycling App" -property pythonScriptPath : "/path/to/myWhoosh2Garmin.py" -property appRunning : false - -on idle - if application targetApp is running then - set appRunning to true - else if appRunning then - set appRunning to false - performActionOnExit() - end if - return 30 -- Check every 30 seconds -end idle - -on performActionOnExit() - do shell script "python3 " & quoted form of pythonScriptPath -end performActionOnExit - -on quit - continue quit -end quit - -``` - -
  7. After changing the property file, export the file as an app.
  8. -
  9. Please select File Format Application.
  10. -
  11. Please select Stay open after run handler an export option.
  12. -
  13. Store the app at a location of your choice. - Xnip2024-12-30_22-16-55 -
  14. -
  15. Before running the script, you need to grant the app full access to your hard drive. Otherwise, you will be prompted each time to allow access when the myWhoosh2Garmin is executed. Please search the web for a guide, given it slightly depends on your Mac OS version. See screenshot of my setup (I gave my App the My Whoosh icon): Xnip2024-12-30_22-28-22 -
  16. -
  17. Now you can run the App and start riding on My Whoosh. After you exit My Whoosh the Garmin upload script will be exectuded.
  18. -
diff --git a/fit_utils/fit_builder.py b/fit_utils/fit_builder.py new file mode 100644 index 0000000..baa4528 --- /dev/null +++ b/fit_utils/fit_builder.py @@ -0,0 +1,399 @@ +import json +from datetime import datetime +from pathlib import Path +from typing import List + +from fit_tool.fit_file_builder import FitFileBuilder +from fit_tool.profile.messages.activity_message import ActivityMessage +from fit_tool.profile.messages.event_message import EventMessage +from fit_tool.profile.messages.file_creator_message import FileCreatorMessage +from fit_tool.profile.messages.file_id_message import FileIdMessage +from fit_tool.profile.messages.lap_message import LapMessage +from fit_tool.profile.messages.record_message import RecordMessage +from fit_tool.profile.messages.session_message import SessionMessage +from fit_tool.profile.profile_type import ( + Event, + EventType, + FileType, + GarminProduct, + Intensity, + Manufacturer, + Sport, + SubSport, +) +from pydantic import BaseModel, ConfigDict, Field, computed_field, model_validator + + +class ActivityData(BaseModel): + """Model for MyWhoosh activity JSON data.""" + + model_config = ConfigDict( + extra="forbid", # Forbid extra fields + validate_assignment=True, # Validate on attribute assignment + str_to_lower=True, # Convert strings to lowercase + strict=True, # Enforce strict type checking + ) + + # From activity metadata api + name: str = Field(default_factory=str, alias="strava_activity_name") + id: int = Field(..., alias="strava_activity_id") + activity_distance: float + moving_time: int + elapsed_time: int + total_elevation_gain: float + type: str + start_date: datetime | int + start_date_local: datetime | int + timezone: str + utc_offset: float + average_speed: float + max_speed: float + average_cadence: float + average_watts: float + max_watts: int + weighted_average_watts: int + kilojoules: float + average_heartrate: float + max_heartrate: float + calories: float + + # From streams + lat: List[float] + long: List[float] + watts: List[int] + cadence: List[int] + velocity_smooth: List[float] + heartrate: List[int] + time: List[int] + heartrates: List[int] + distance: List[float] + grade_smooth: List[float] | None + altitude: List[float] | None + + @model_validator(mode="after") + def validate_streams(self) -> "ActivityData": + """Validate that all stream lists have the same length and records exist.""" + # Collect all stream attributes that are lists + stream_attrs = [ + "lat", + "long", + "watts", + "cadence", + "velocity_smooth", + "heartrate", + "time", + "heartrates", + "distance", + ] + # Optionally include nullable streams if present + if self.grade_smooth is not None: + stream_attrs.append("grade_smooth") + if self.altitude is not None: + stream_attrs.append("altitude") + + lengths = [ + len(getattr(self, attr)) + for attr in stream_attrs + if getattr(self, attr) is not None + ] + if lengths and any(length != lengths[0] for length in lengths): + raise ValueError("All stream lists must have the same length.") + + return self + + @property + def stream_length(self) -> int: + return len(self.time) + + @property + def elapsed_time(self) -> int: + """Get elapsed time in milliseconds.""" + return self.elapsed_time * 1000 + + @classmethod + def from_json_file(cls, json_file_path: str) -> "ActivityData": + """Load and parse the JSON activity file into the model.""" + with open(json_file_path, "r") as f: + raw_data = json.load(f) + + # Extract metadata and streams + metadata = raw_data.get("metadata", {}) + streams = raw_data.get("streams", {}) + + # Combine metadata fields with stream data + combined_data = { + # Metadata fields (activity summary) + "strava_activity_name": metadata.get("name", ""), + "strava_activity_id": metadata.get("id"), + "activity_distance": metadata.get("distance"), + "moving_time": metadata.get("moving_time"), + "elapsed_time": metadata.get("elapsed_time"), + "total_elevation_gain": metadata.get("total_elevation_gain"), + "type": metadata.get("type"), + "start_date": datetime.fromisoformat(metadata.get("start_date")) + if metadata.get("start_date") + else None, + "start_date_local": datetime.fromisoformat(metadata.get("start_date_local")) + if metadata.get("start_date_local") + else None, + "timezone": metadata.get("timezone"), + "utc_offset": metadata.get("utc_offset"), + "average_speed": metadata.get("average_speed"), + "max_speed": metadata.get("max_speed"), + "average_cadence": metadata.get("average_cadence"), + "average_watts": metadata.get("average_watts"), + "max_watts": metadata.get("max_watts"), + "weighted_average_watts": metadata.get("weighted_average_watts"), + "kilojoules": metadata.get("kilojoules"), + "average_heartrate": metadata.get("average_heartrate"), + "max_heartrate": metadata.get("max_heartrate"), + "calories": metadata.get("calories"), + # Stream data (time series) + # Extract and separate lat/long from latlng pairs + "lat": [], + "long": [], + } + + # Stream data (time series) + # Extract and separate lat/long from latlng pairs + latlng_data = streams.get("latlng", {}).get("data", []) + if latlng_data: + lat_values, long_values = zip(*latlng_data) + combined_data["lat"] = list(lat_values) + combined_data["long"] = list(long_values) + else: + combined_data["lat"] = [] + combined_data["long"] = [] + + combined_data.update( + { + "watts": streams.get("watts", {}).get("data", []), + "cadence": streams.get("cadence", {}).get("data", []), + "velocity_smooth": streams.get("velocity_smooth", {}).get("data", []), + "heartrate": streams.get("heartrate", {}).get("data", []), + "time": streams.get("time", {}).get("data", []), + "heartrates": streams.get("heartrate", {}).get("data", []), + "distance": streams.get("distance", {}).get("data", []), + "grade_smooth": streams.get("grade_smooth", {}).get("data"), + "altitude": streams.get("altitude", {}).get("data"), + } + ) + + return cls(**combined_data) + + @computed_field + def max_cadence(self) -> int: + return max(self.cadence) if self.cadence else 0 + + @computed_field + @property + def start_ts_miliseconds(self) -> int: + return round(self.start_date.timestamp()) * 1000 + + +class MyWhooshFitBuilder: + """Convert MyWhoosh activity JSON to FIT file format.""" + + def __init__(self, json_file_path: str): + """Initialize with path to MyWhoosh JSON file.""" + self.json_path = json_file_path + self.activity_data = ActivityData.from_json_file(json_file_path) + self.builder = FitFileBuilder(auto_define=True) + self.end_date_fit_ts = ( + self.activity_data.start_ts_miliseconds + + 1000 * self.activity_data.stream_length + ) + + def _add_file_id(self): + """Add file_id message.""" + file_id = FileIdMessage() + file_id.type = FileType.ACTIVITY + file_id.manufacturer = Manufacturer.GARMIN.value + file_id.product = GarminProduct.EDGE_530.value + file_id.serial_number = 3313379353 + file_id.time_created = self.activity_data.start_ts_miliseconds + self.builder.add(file_id) + + def _add_file_creator(self): + """Add file creator message.""" + file_creator = FileCreatorMessage() + file_creator.software_version = 29 + self.builder.add(file_creator) + + def _add_event(self, timestamp: int, event: Event, event_type: EventType): + """Add event message.""" + event_msg = EventMessage() + event_msg.timestamp = timestamp + event_msg.event = event + event_msg.event_type = event_type + self.builder.add(event_msg) + + def _add_records(self): + """Add all record messages from the activity data.""" + if not self.activity_data or self.activity_data.stream_length == 0: + return + + for i in range(self.activity_data.stream_length): + record = RecordMessage() + + # Timestamp - time[i] est en secondes, on convertit en millisecondes + record.timestamp = self.activity_data.start_ts_miliseconds + ( + self.activity_data.time[i] * 1000 + ) + + # Position (lat/long en degrΓ©s) + record.position_lat = self.activity_data.lat[i] + record.position_long = self.activity_data.long[i] + + # Heart rate + record.heart_rate = self.activity_data.heartrate[i] + + # Cadence + record.cadence = self.activity_data.cadence[i] + + # Distance (meters) + record.distance = self.activity_data.distance[i] + + # Altitude (meters) - optional + if self.activity_data.altitude is not None: + record.altitude = self.activity_data.altitude[i] + + # Power (watts) + record.power = self.activity_data.watts[i] + + # Speed (m/s) + record.speed = self.activity_data.velocity_smooth[i] + + self.builder.add(record) + + def _add_lap(self): + """Add lap message.""" + lap = LapMessage() + + lap.timestamp = ( + self.activity_data.start_ts_miliseconds + self.activity_data.elapsed_time + ) + lap.start_time = self.activity_data.start_ts_miliseconds + lap.total_elapsed_time = self.activity_data.elapsed_time + lap.total_timer_time = self.activity_data.elapsed_time + lap.intensity = Intensity.ACTIVE + lap.total_distance = self.activity_data.activity_distance + lap.avg_heart_rate = int(self.activity_data.average_heartrate) + lap.max_heart_rate = int(self.activity_data.max_heartrate) + + lap.avg_cadence = int(self.activity_data.average_cadence) + lap.max_cadence = int(self.activity_data.max_cadence) + + lap.avg_power = int(self.activity_data.average_watts) + lap.max_power = int(self.activity_data.max_watts) + + lap.avg_speed = self.activity_data.average_speed + lap.max_speed = self.activity_data.max_speed + + lap.total_calories = int(self.activity_data.calories) + lap.sport = Sport.CYCLING + lap.sub_sport = SubSport.VIRTUAL_ACTIVITY + + self.builder.add(lap) + + def _add_session(self): + """Add session message.""" + session = SessionMessage() + + session.timestamp = self.end_date_fit_ts + session.start_time = self.activity_data.start_ts_miliseconds + session.total_elapsed_time = self.activity_data.elapsed_time + session.total_timer_time = self.activity_data.elapsed_time + session.total_distance = self.activity_data.activity_distance + + session.avg_heart_rate = int(self.activity_data.average_heartrate) + session.max_heart_rate = int(self.activity_data.max_heartrate) + + session.avg_cadence = int(self.activity_data.average_cadence) + session.max_cadence = int(self.activity_data.max_cadence) + + session.avg_power = int(self.activity_data.average_watts) + session.max_power = int(self.activity_data.max_watts) + + session.avg_speed = self.activity_data.average_speed + session.max_speed = self.activity_data.max_speed + + session.total_calories = int(self.activity_data.calories) + session.sport = Sport.CYCLING + session.sub_sport = SubSport.VIRTUAL_ACTIVITY + session.first_lap_index = 0 + session.num_laps = 1 + + self.builder.add(session) + + def _add_activity(self): + """Add activity message.""" + activity = ActivityMessage() + activity.timestamp = self.end_date_fit_ts + activity.total_timer_time = self.activity_data.elapsed_time + activity.num_sessions = 1 + activity.type = 0 + activity.event = Event.ACTIVITY + activity.event_type = EventType.STOP + activity.local_timestamp = round(self.activity_data.start_date.timestamp()) + self.builder.add(activity) + + def build(self, output_path: str = None): + """Build and write the FIT file.""" + if not output_path: + raise ValueError("output_path is required for build.") + + # Add messages in order + self._add_file_id() + self._add_file_creator() + + # Timer start event + self._add_event( + self.activity_data.start_ts_miliseconds, Event.TIMER, EventType.START + ) + + # Add all record points + self._add_records() + + # Add lap + self._add_lap() + + # Timer stop event + self._add_event(self.end_date_fit_ts, Event.SESSION, EventType.STOP_DISABLE_ALL) + + # Add session and activity + self._add_session() + self._add_activity() + + # Build FIT file and write to disk + fit_file = self.builder.build() + + # Ensure the output directory exists + output_dir = Path(output_path).parent + output_dir.mkdir(parents=True, exist_ok=True) + + fit_file.to_file(output_path) + + print(f"FIT file saved to: {output_path}") + + +# Example usage +if __name__ == "__main__": + # # Example usage + data_dir = Path(__file__).parent.parent / "data" + input_file = str( + data_dir / "raw" / "MyWhoosh - The Seven Gems_2025-11-13_combined.json" + ) + output_file = str( + data_dir / "processed" / "MyWhoosh - The Seven Gems_2025-11-13_combined.fit" + ) + + data = ActivityData.from_json_file(input_file) + + # Create builder and generate FIT file + builder = MyWhooshFitBuilder(input_file) + builder.build(output_file) + + # print("FIT file created successfully!") + +# What I need to retrieve from the json : diff --git a/garmin/utils.py b/garmin/utils.py new file mode 100644 index 0000000..99b5b69 --- /dev/null +++ b/garmin/utils.py @@ -0,0 +1,171 @@ +import logging +import os +import sys +from datetime import datetime, timedelta +from pathlib import Path +from typing import List, Tuple + +import garth +from garth.exc import GarthException, GarthHTTPError +from pydantic import Field, SecretStr +from pydantic_settings import BaseSettings, SettingsConfigDict + +logger = logging.getLogger(__name__) + + +class GarminSettings(BaseSettings): + """Configuration settings for Garmin API client.""" + + garmin_username: str | None = Field( + default=None, + alias="GARMIN_USERNAME", + description="Garmin Connect username only used to authenticate and retrieve tokens.", # noqa: E501 + ) + garmin_password: SecretStr | None = Field( + default=None, + alias="GARMIN_PASSWORD", + description="Garmin Connect password used for authentication.", + ) + garmin_tokens_path: Path = Field( + default=Path(__file__).parent.parent / ".garth", + ) + garmin_tokens: SecretStr | None = Field( + default=None, + alias="GARMIN_TOKENS", + description="Garmin Connect tokens from garth client.dumps() for authentication.", # noqa: E501 + ) + + model_config = SettingsConfigDict( + env_file=Path(__file__).parent.parent / ".env", extra="ignore" + ) + + +def get_credentials_for_garmin(garmin_settings: GarminSettings = GarminSettings()): + """ + Prompt the user for Garmin credentials and authenticate using Garth. + + Returns: + None + + Exits: + Exits with status 1 if authentication fails. + """ + logger.info("Authenticating...") + try: + # First try to restore from environment variable + token_env = os.getenv("GARMIN_TOKENS") + if token_env: + garth.client.loads(token_env) + logger.info("Authenticated using GARMIN_TOKENS environment variable.") + return + # Fallback to username/password login + garth.login( + garmin_settings.garmin_username, + garmin_settings.garmin_password.get_secret_value(), + prompt_mfa=lambda: input("Enter MFA code: "), + ) + garth.save(garmin_settings.garmin_tokens_path) + print() + logger.info("Successfully authenticated!") + except GarthHTTPError: + logger.info( + "Wrong credentials or authentication failed. Please check username and password." # noqa: E501 + ) + sys.exit(1) + + +def authenticate_to_garmin(garmin_settings: GarminSettings = GarminSettings()): + """ + Authenticate the user to Garmin by checking for existing tokens and + resuming the session, or prompting for credentials if no session + exists or the session is expired. + + Returns: + None + + Exits: + Exits with status 1 if authentication fails. + """ + try: + if garmin_settings.garmin_tokens_path.exists(): + garth.resume(garmin_settings.garmin_tokens_path) + try: + logger.info(f"Authenticated as: {garth.client.username}") + except GarthException: + logger.info("Session expired. Re-authenticating...") + get_credentials_for_garmin(garmin_settings) + else: + logger.info("No existing session. Please log in.") + get_credentials_for_garmin(garmin_settings) + except GarthException as e: + logger.info(f"Authentication error: {e}") + sys.exit(1) + + +def upload_fit_file_to_garmin(new_file_path: Path): + """ + Upload a .fit file to Garmin using the Garth client. + + Args: + new_file_path (Path): The path to the .fit file to upload. + + Returns: + None + """ + try: + if new_file_path and new_file_path.exists(): + with open(new_file_path, "rb") as f: + uploaded = garth.client.upload(f) + logger.debug(uploaded) + else: + logger.info(f"Invalid file path: {new_file_path}.") + except GarthHTTPError: + logger.info("Duplicate activity found on Garmin Connect.") + + +def list_virtual_cycling_activities( + last_n_days: int = 30, +) -> Tuple[List[str], List[datetime]]: + """Return two lists: activity names and start times of virtual cycling activities from Garmin Connect.""" # noqa: E501 + logger.info( + f"Retrieving virtual cycling activities from the last {last_n_days} days..." + ) + end_date = datetime.now().date() + start_date = end_date - timedelta(days=last_n_days) + activities = garth.connectapi( + "/activitylist-service/activities/search/activities", + params={ + "startDate": start_date.strftime("%Y-%m-%d"), + "endDate": end_date.strftime("%Y-%m-%d"), + }, + ) + names, start_times = [], [] + for activity in activities: + if activity.get("activityType", {}).get("typeKey") == "virtual_ride": + names.append(activity["activityName"]) + start_time_str = activity.get("startTimeLocal", "") + try: + start_time = datetime.strptime(start_time_str, "%Y-%m-%d %H:%M:%S") + except (ValueError, TypeError): + start_time = None + start_times.append(start_time) + logger.debug( + f"Found virtual cycling activity: {activity['activityName']} at {activity.get('startTimeLocal', '')} with elapsed time {activity.get('elapsedTime', '')}." # noqa: E501 + ) + return names, start_times + + +def dump_token_string_as_vars(): + """Utility function to dump Garmin tokens as environment variable strings.""" + token_string = garth.client.dumps() + logger.info("Garmin token string (CAREFUL: save it securely!") + logger.info(token_string) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + authenticate_to_garmin() + # Example usage: + names, start_times = list_virtual_cycling_activities() + for name, start_time in zip(names, start_times): + print(f"Activity: {name}, Start Time: {start_time}") diff --git a/myWhoosh2Garmin.py b/myWhoosh2Garmin.py index f5dcb8d..388f595 100644 --- a/myWhoosh2Garmin.py +++ b/myWhoosh2Garmin.py @@ -1,197 +1,62 @@ -#!/usr/bin/env python3 -""" -Script name: myWhoosh2Garmin.py -Usage: "python3 myWhoosh2Garmin.py" -Description: Checks for MyNewActivity-.fit - Adds avg power and heartrade - Removes temperature - Creates backup for the file with a timestamp as a suffix -Credits: Garth by matin - for authenticating and uploading with - Garmin Connect. - https://github.com/matin/garth - Fit_tool by mtucker - for parsing the fit file. - https://bitbucket.org/stagescycling/python_fit_tool.git/src - mw2gc by embeddedc - used as an example to fix the avg's. - https://github.com/embeddedc/mw2gc -""" -import os -import json -import subprocess -import sys import logging -import re -from typing import List -import tkinter as tk -from tkinter import filedialog -from datetime import datetime -from getpass import getpass from pathlib import Path -import importlib.util +from fit_utils.fit_builder import MyWhooshFitBuilder +from garmin.utils import ( + authenticate_to_garmin, + list_virtual_cycling_activities, + upload_fit_file_to_garmin, +) +from strava.client import StravaClientBuilder +from strava.utils import sanitize_filename SCRIPT_DIR = Path(__file__).resolve().parent log_file_path = SCRIPT_DIR / "myWhoosh2Garmin.log" -json_file_path = SCRIPT_DIR / "backup_path.json" +RAW_FIT_FILE_PATH = SCRIPT_DIR / "data" / "raw" logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) file_handler = logging.FileHandler(log_file_path) -formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') +formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s") file_handler.setFormatter(formatter) logger.addHandler(file_handler) -INSTALLED_PACKAGES_FILE = SCRIPT_DIR / "installed_packages.json" - - -def load_installed_packages(): - """Load the set of installed packages from a JSON file.""" - if INSTALLED_PACKAGES_FILE.exists(): - with INSTALLED_PACKAGES_FILE.open("r") as f: - return set(json.load(f)) - return set() - - -def save_installed_packages(installed_packages): - """Save the set of installed packages to a JSON file.""" - with INSTALLED_PACKAGES_FILE.open("w") as f: - json.dump(list(installed_packages), f) - - -def get_pip_command(): - """Return the pip command if pip is available.""" - try: - subprocess.check_call( - [sys.executable, "-m", "pip", "--version"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE - ) - return [sys.executable, "-m", "pip"] - except subprocess.CalledProcessError: - return None - - -def install_package(package): - """Install the specified package using pip.""" - pip_command = get_pip_command() - if pip_command: - try: - logger.info(f"Installing missing package: {package}.") - subprocess.check_call( - pip_command + ["install", package] - ) - except subprocess.CalledProcessError as e: - logger.error(f"Error installing {package}: {e}.") - else: - logger.debug("pip is not available. Unable to install packages.") - - -def ensure_packages(): - """Ensure all required packages are installed and tracked.""" - required_packages = ["garth", "fit_tool"] - installed_packages = load_installed_packages() - - for package in required_packages: - if package in installed_packages: - logger.info(f"Package {package} is already tracked as installed.") - continue - - if not importlib.util.find_spec(package): - logger.info(f"Package {package} not found." - "Attempting to install...") - install_package(package) - - try: - __import__(package) - logger.info(f"Successfully imported {package}.") - installed_packages.add(package) - except ModuleNotFoundError: - logger.error(f"Failed to import {package} even " - "after installation.") - - save_installed_packages(installed_packages) - - -ensure_packages() - - -# Imports -try: - import garth - from garth.exc import GarthException, GarthHTTPError - from fit_tool.fit_file import FitFile - from fit_tool.fit_file_builder import FitFileBuilder - from fit_tool.profile.messages.file_creator_message import ( - FileCreatorMessage - ) - from fit_tool.profile.messages.record_message import ( - RecordMessage, - RecordTemperatureField +def main(): + authenticate_to_garmin() + client_builder = StravaClientBuilder() + client = client_builder.with_auth().with_cookies().build() + + strava_retrieved_activities = client.get_filtered_activities() + names, start_times = list_virtual_cycling_activities(last_n_days=7) + + def strip_timezone(dt): + if dt.tzinfo is not None: + return dt.replace(tzinfo=None) + return dt + + start_times_no_tz = {strip_timezone(dt) for dt in start_times} + + new_activities = [ + activity + for activity in strava_retrieved_activities + if strip_timezone(activity.start_date_local) not in start_times_no_tz + ] + logger.info( + f"Found {len(new_activities)} new virtual cycling activities to upload to Garmin." # noqa: E501 ) - from fit_tool.profile.messages.session_message import SessionMessage - from fit_tool.profile.messages.lap_message import LapMessage -except ImportError as e: - logger.error(f"Error importing modules: {e}") - - -TOKENS_PATH = SCRIPT_DIR / '.garth' -FILE_DIALOG_TITLE = "MyWhoosh2Garmin" -# Fix for https://github.com/JayQueue/MyWhoosh2Garmin/issues/2 -MYWHOOSH_PREFIX_WINDOWS = "MyWhooshTechnologyService." - -def get_fitfile_location() -> Path: - """ - Get the location of the FIT file directory based on the operating system. - - Returns: - Path: The path to the FIT file directory. - - Raises: - RuntimeError: If the operating system is unsupported. - SystemExit: If the target path does not exist. - """ - if os.name == "posix": # macOS and Linux - target_path = ( - Path.home() - / "Library" - / "Containers" - / "com.whoosh.whooshgame" - / "Data" - / "Library" - / "Application Support" - / "Epic" - / "MyWhoosh" - / "Content" - / "Data" - ) - if target_path.is_dir(): - return target_path - else: - logger.error(f"Target path {target_path} does not exist. " - "Check your MyWhoosh installation.") - sys.exit(1) - elif os.name == "nt": # Windows + for activity in new_activities: + client.downloader.download_activity(activity.id) + safe_name = sanitize_filename(activity.name) + file_name = f"{safe_name}.json" + input_path = RAW_FIT_FILE_PATH / file_name + output_path = RAW_FIT_FILE_PATH.parent / "processed" / f"{safe_name}.fit" + builder = MyWhooshFitBuilder(input_path) + builder.build(output_path) + upload_fit_file_to_garmin(output_path) try: - base_path = Path.home() / "AppData" / "Local" / "Packages" - for directory in base_path.iterdir(): - if (directory.is_dir() and - directory.name.startswith(MYWHOOSH_PREFIX_WINDOWS)): - target_path = ( - directory - / "LocalCache" - / "Local" - / "MyWhoosh" - / "Content" - / "Data" - ) - if target_path.is_dir(): - return target_path - else: - raise FileNotFoundError(f"No valid MyWhoosh directory found in {target_path}") - except FileNotFoundError as e: - logger.error(str(e)) - except PermissionError as e: - logger.error(f"Permission denied: {e}") + output_path.unlink() + logger.info(f"Deleted file: {output_path}") except Exception as e: logger.error(f"Unexpected error: {e}") else: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b6538a6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,47 @@ +[project] +name = "mywhoosh2garmin" +version = "0.1.0" +description = "Upload MyWhoosh activities to Garmin Connect with proper fixes" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "pydantic-settings==2.11.0", + "pydantic==2.12.4", + "garth==0.5.2", + "fit-tool==0.9.13", + "playwright==1.55.0", +] + +[project.optional-dependencies] +dev = [ + "pre-commit==4.4.0", + "ruff==0.14.5", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.ruff] +line-length = 88 +target-version = "py313" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort +] + +[tool.ruff.lint.per-file-ignores] +"__init__.py" = ["F401"] # unused imports in __init__.py + +[tool.ruff.lint.isort] +known-first-party = ["mywhoosh2garmin"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" diff --git a/setup_garmin_auth.py b/setup_garmin_auth.py new file mode 100644 index 0000000..a2a93fb --- /dev/null +++ b/setup_garmin_auth.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +""" +Garmin Authentication Setup Script + +This script helps you authenticate with Garmin Connect and generate +the GARMIN_TOKENS secret needed for GitHub Actions. + +Usage: + python setup_garmin_auth.py +""" + +import logging +from pathlib import Path + +import garth + +from garmin.utils import GarminSettings, get_credentials_for_garmin + +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + + +def main(): + """Main function to set up Garmin authentication.""" + print("=" * 70) + print("πŸƒ Garmin Connect Authentication Setup") + print("=" * 70) + print() + + # Prompt for Garmin credentials + username = input("Enter your Garmin Connect Username (Email): ").strip() + password = input("Enter your Garmin Connect Password: ").strip() + + if not username or not password: + logger.error("❌ Username and Password are required!") + return + + # Create/update .env file with credentials + env_file = Path(__file__).parent / ".env" + env_content = {} + if env_file.exists(): + with open(env_file, "r") as f: + for line in f: + line = line.strip() + if line and not line.startswith("#") and "=" in line: + key, value = line.split("=", 1) + env_content[key.strip()] = value.strip("\"'") + + env_content["GARMIN_USERNAME"] = username + env_content["GARMIN_PASSWORD"] = password + + with open(env_file, "w") as f: + for key, value in env_content.items(): + f.write(f'{key}="{value}"\n') + + print() + print("πŸ” Starting authentication...") + print() + + # Load settings from .env + settings = GarminSettings() + + # Authenticate and save tokens + get_credentials_for_garmin(settings) + + # Dump tokens as string for GitHub secrets + token_string = garth.client.dumps() + + print() + print("=" * 70) + print("βœ… Authentication Successful!") + print("=" * 70) + print() + print("πŸ“‹ Copy the following token string and save it as a GitHub Secret:") + print(" This is the recommended way to authenticate in CI/CD environments.") + print() + print("-" * 70) + print("Secret: GARMIN_TOKENS") + print(token_string) + print("-" * 70) + print() + print("Alternatively, you can use your username and password as secrets:") + print("Secret: GARMIN_USERNAME") + print(f"Value: {username}") + print("Secret: GARMIN_PASSWORD") + print(f"Value: {password}") + print() + print("⚠️ IMPORTANT: Keep these tokens and credentials secure!") + print() + print("πŸ”— Add it to GitHub:") + print(" 1. Go to your repository β†’ Settings β†’ Secrets and variables β†’ Actions") + print(" 2. Click 'New repository secret'") + print(" 3. Name: GARMIN_TOKENS") + print(" 4. Value: Paste the token string above") + print() + + +if __name__ == "__main__": + main() diff --git a/setup_strava_auth.py b/setup_strava_auth.py new file mode 100644 index 0000000..67a2c97 --- /dev/null +++ b/setup_strava_auth.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +""" +Strava Authentication Setup Script + +This script helps you authenticate with Strava API and generate +the OAuth tokens needed for GitHub Actions. + +Before running this script: +1. Create a Strava API application at https://www.strava.com/settings/api +2. Set the authorization callback domain to: localhost +3. Note your Client ID and Client Secret + +Usage: + python setup_strava_auth.py +""" + +import json +import logging +from pathlib import Path + +from strava.client import StravaAuth, StravaSettings + +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + + +def main(): + """Main function to set up Strava authentication.""" + print("=" * 70) + print("🚴 Strava API Authentication Setup") + print("=" * 70) + print() + print("πŸ“ Before continuing, make sure you have:") + print(" 1. Created a Strava API application at:") + print(" https://www.strava.com/settings/api") + print(" 2. Set authorization callback domain to: localhost") + print() + + # Prompt for Client ID and Secret + client_id = input("Enter your Strava Client ID: ").strip() + client_secret = input("Enter your Strava Client Secret: ").strip() + + if not client_id or not client_secret: + logger.error("❌ Client ID and Secret are required!") + return + + # Create temporary settings with user input + env_file = Path(__file__).parent / ".env" + + # Read existing .env or create from template + env_content = {} + if env_file.exists(): + with open(env_file, "r") as f: + for line in f: + line = line.strip() + if line and not line.startswith("#") and "=" in line: + key, value = line.split("=", 1) + env_content[key] = value.strip("\"'") + + # Update with new credentials + env_content["STRAVA_CLIENT_ID"] = client_id + env_content["STRAVA_CLIENT_SECRET"] = client_secret + + # Write back to .env + with open(env_file, "w") as f: + f.write("# Strava API Configuration\n") + for key, value in env_content.items(): + f.write(f'{key}="{value}"\n') + + print() + print("πŸ” Starting OAuth flow...") + print() + + # Initialize settings which will now load from .env + settings = StravaSettings() + auth = StravaAuth(settings) + + # Perform OAuth flow + auth._perform_oauth_flow() + + # Load the saved tokens + token_file = Path(__file__).parent / "strava_tokens.json" + if not token_file.exists(): + logger.error("❌ Token file not created. Authentication may have failed.") + return + + with open(token_file, "r") as f: + tokens = json.load(f) + + print() + print("=" * 70) + print("βœ… Authentication Successful!") + print("=" * 70) + print() + print("πŸ“‹ Add these as GitHub Secrets in your repository:") + print(" (Settings β†’ Secrets and variables β†’ Actions β†’ New repository secret)") + print() + print("-" * 70) + print("Secret: STRAVA_CLIENT_ID") + print(f"Value: {client_id}") + print("-" * 70) + print("Secret: STRAVA_CLIENT_SECRET") + print(f"Value: {client_secret}") + print("-" * 70) + print("Secret: STRAVA_ACCESS_TOKEN") + print(f"Value: {tokens.get('access_token', 'N/A')}") + print("-" * 70) + print("Secret: STRAVA_EXPIRES_AT") + print(f"Value: {tokens.get('expires_at', 'N/A')}") + print("-" * 70) + print("Secret: STRAVA_EXPIRES_IN") + print(f"Value: {tokens.get('expires_in', 'N/A')}") + print("-" * 70) + print("Secret: STRAVA_REFRESH_TOKEN") + print(f"Value: {tokens.get('refresh_token', 'N/A')}") + print("-" * 70) + print() + print("⚠️ IMPORTANT: Keep these tokens secure! Don't share them publicly.") + print() + print("πŸ’Ύ Tokens have also been saved to: strava_tokens.json") + print(" (This file is gitignored for your security)") + print() + + +if __name__ == "__main__": + main() diff --git a/strava/README.md b/strava/README.md deleted file mode 100644 index 1c58073..0000000 --- a/strava/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# What does it do - -strava.py connects to the API to get all the activities and then uses your Strava cookie to login and get your .fit file from https://www.strava.com/activities/{activity.id}/export_original. -I did this because we are not allowed to scrape MyWhoosh site. It's in the TOS. - -## Installation - -### Register your app on Strava - -1. Go to [Strava API settings](https://www.strava.com/settings/api) -2. Get your Strava cookie and turn it into a cookies.json -3. To continue, hold on diff --git a/strava/main.py b/strava/client.py similarity index 64% rename from strava/main.py rename to strava/client.py index 1919d20..b8c36b4 100644 --- a/strava/main.py +++ b/strava/client.py @@ -5,37 +5,81 @@ """ import json +import logging import os import sqlite3 -import requests +import time from datetime import datetime, timedelta from pathlib import Path -from typing import List, Optional +from typing import Any, List, Optional from urllib.parse import parse_qs, urlparse -from pydantic import BaseModel, Field +import requests +from pydantic import BaseModel, Field, SecretStr from pydantic_settings import BaseSettings, SettingsConfigDict from requests import Session +from strava.utils import sanitize_filename + +logger = logging.getLogger(__name__) + class StravaSettings(BaseSettings): """Configuration settings for Strava API client.""" - - client_id: str = Field(..., validation_alias="CLIENT_ID") - client_secret: str = Field(..., validation_alias="CLIENT_SECRET") + + client_id: str + client_secret: SecretStr + token_type: str = Field( + default="Bearer", + ) + access_token: SecretStr | None = Field( + default=None, + description="Strava API Access Token.", + ) + expires_at: int | None = Field( + default=None, + description="Strava API Access Token Expiration Time.", + ) + expires_in: int | None = Field( + default=None, + description="Strava API Access Token Expiration Time.", + ) + refresh_token: SecretStr | None = Field( + default=None, + description="Strava API Refresh Token.", + ) token_url: str = "https://www.strava.com/oauth/token" auth_base_url: str = "https://www.strava.com/oauth/authorize" - token_file: str = "strava_tokens.json" - cookie_file: str = "cookie.json" + token_file: Path = Path(__file__).parent.parent / "strava_tokens.json" + cookie_file: Path = Path(__file__).parent.parent / "cookie.json" activities_url: str = "https://www.strava.com/api/v3/athlete/activities" - database_file: str = "strava.db" - - model_config = SettingsConfigDict(env_file=".env", extra="ignore") + database_file: Path = Path(__file__).parent.parent / "strava.db" + + model_config = SettingsConfigDict( + env_file=Path(__file__).parent.parent / ".env", + extra="ignore", + env_prefix="STRAVA_", + ) # noqa: E501 + + def model_post_init(self, __context: Any) -> None: + """Create necessary files if they don't exist.""" + # Create token file if it doesn't exist + token_path = self.token_file + # Only dump selected fields to token file if it doesn't exist + if not token_path.exists() and self.access_token and self.refresh_token: + token_data = { + "token_type": self.token_type, + "access_token": str(self.access_token.get_secret_value()), + "expires_at": self.expires_at, + "expires_in": self.expires_in, + "refresh_token": str(self.refresh_token.get_secret_value()), + } + token_path.write_text(json.dumps(token_data)) class TokenData(BaseModel): """Model for storing Strava API token data.""" - + access_token: str refresh_token: str expires_at: datetime @@ -50,16 +94,19 @@ def from_json(cls, data: dict): class ActivityDetails(BaseModel): """Model representing Strava activity details.""" - + id: int name: str start_date: datetime + start_date_local: datetime + elapsed_time: int + distance: float type: str class ActivityDatabase: """Database handler for tracking downloaded activities.""" - + def __init__(self, db_file: str): self.conn = sqlite3.connect(db_file) self._create_table() @@ -78,8 +125,7 @@ def _create_table(self): def is_downloaded(self, activity_id: int) -> bool: """Check if activity is already downloaded.""" cursor = self.conn.execute( - "SELECT 1 FROM downloaded_activities WHERE activity_id = ?", - (activity_id,) + "SELECT 1 FROM downloaded_activities WHERE activity_id = ?", (activity_id,) ) return bool(cursor.fetchone()) @@ -87,7 +133,7 @@ def mark_downloaded(self, activity_id: int): """Mark an activity as downloaded.""" self.conn.execute( "INSERT OR IGNORE INTO downloaded_activities (activity_id) VALUES (?)", - (activity_id,) + (activity_id,), ) self.conn.commit() @@ -98,7 +144,7 @@ def close(self): class StravaAuth: """Handles Strava OAuth2 authentication and token management.""" - + def __init__(self, settings: StravaSettings): self.settings = settings self.token_data: Optional[TokenData] = None @@ -108,9 +154,9 @@ def __init__(self, settings: StravaSettings): def _initialize_session(self): """Initialize requests session with valid token.""" if self._load_tokens() and self._is_token_valid(): - self.session.headers.update({ - "Authorization": f"Bearer {self.token_data.access_token}" - }) + self.session.headers.update( + {"Authorization": f"Bearer {self.token_data.access_token}"} + ) def _is_token_valid(self) -> bool: """Check if access token is still valid.""" @@ -132,7 +178,7 @@ def authenticate(self) -> None: self.refresh_token() except requests.HTTPError as e: if e.response.status_code == 400: - print("Refresh token expired, re-authenticating...") + logger.warning("Refresh token expired, re-authenticating...") self._perform_oauth_flow() else: raise @@ -162,7 +208,7 @@ def _fetch_token(self, redirect_url: str) -> None: self.settings.token_url, data={ "client_id": self.settings.client_id, - "client_secret": self.settings.client_secret, + "client_secret": str(self.settings.client_secret.get_secret_value()), "code": code[0], "grant_type": "authorization_code", }, @@ -195,7 +241,7 @@ def refresh_token(self) -> None: self.settings.token_url, data={ "client_id": self.settings.client_id, - "client_secret": self.settings.client_secret, + "client_secret": str(self.settings.client_secret.get_secret_value()), "grant_type": "refresh_token", "refresh_token": self.token_data.refresh_token, }, @@ -206,7 +252,7 @@ def refresh_token(self) -> None: class CookieManager: """Manages HTTP cookies for persistent session.""" - + def __init__(self, cookie_file: str): self.cookie_file = cookie_file self.session = Session() @@ -222,13 +268,13 @@ def load_cookies(self) -> None: class ActivityDownloader: """Handles activity file downloads with Chrome-like headers.""" - + CHROME_HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/119.0.0.0 Safari/537.36", + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/119.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9," - "image/avif,image/webp,image/apng,*/*;q=0.8", + "image/avif,image/webp,image/apng,*/*;q=0.8", "Accept-Language": "en-US,en;q=0.9", "Accept-Encoding": "gzip, deflate, br", "Connection": "keep-alive", @@ -236,7 +282,7 @@ class ActivityDownloader: "Sec-Fetch-Mode": "navigate", "Sec-Fetch-Site": "same-origin", "Sec-Fetch-User": "?1", - "Upgrade-Insecure-Requests": "1" + "Upgrade-Insecure-Requests": "1", } def __init__(self, session: Session, database: ActivityDatabase): @@ -249,36 +295,79 @@ def download_activity(self, activity_id: int) -> bool: return self._download_attempt(activity_id) except requests.HTTPError as e: if e.response.status_code == 401: - print("Token expired during download, refreshing...") + logger.warning("Token expired during download, refreshing...") self.session.auth.refresh_token() return self._download_attempt(activity_id) raise def _download_attempt(self, activity_id: int) -> bool: - """Perform single download attempt for an activity.""" + """Perform single download attempt with metadata + streams.""" if self.db.is_downloaded(activity_id): return False - response = self.session.get( - f"https://www.strava.com/activities/{activity_id}/export_original", - stream=True, - headers=self.CHROME_HEADERS + # 1. Fetch activity metadata + activity_response = self.session.get( + f"https://www.strava.com/api/v3/activities/{activity_id}", + params={"include_all_efforts": "true"}, + headers=self.CHROME_HEADERS, ) - response.raise_for_status() + activity_response.raise_for_status() + activity_data = activity_response.json() + + activity_name = activity_data.get("name", f"activity_{activity_id}") + # Parse and trim start_date to only date part (YYYY-MM-DD) + start_date_str = activity_data.get("start_date", "unknown_date") + _ = start_date_str.split("T")[0] if "T" in start_date_str else start_date_str + + # 2. Fetch time-series streams + stream_types = [ + "time", + "latlng", + "distance", + "altitude", + "velocity_smooth", + "heartrate", + "cadence", + "watts", + "temp", + "moving", + "grade_smooth", + ] + + streams_response = self.session.get( + f"https://www.strava.com/api/v3/activities/{activity_id}/streams", + params={ + "keys": ",".join(stream_types), + "key_by_type": "true", + }, + headers=self.CHROME_HEADERS, + ) + streams_response.raise_for_status() + streams_data = streams_response.json() - filename = f"activity_{activity_id}_original.fit" - with open(filename, "wb") as f: - for chunk in response.iter_content(chunk_size=8192): - f.write(chunk) + # 3. Combine both datasets + combined_data = { + "metadata": activity_data, + "streams": streams_data, + } + + # 4. Save combined JSON + data_dir = Path(__file__).parent.parent / "data" / "raw" + data_dir.mkdir(parents=True, exist_ok=True) + + safe_activity_name = sanitize_filename(activity_name) + json_filename = data_dir / f"{safe_activity_name}.json" + with open(json_filename, "w") as f: + json.dump(combined_data, f, indent=2, default=str) self.db.mark_downloaded(activity_id) - print(f"βœ… Downloaded {filename}") + logger.info(f"βœ… Downloaded {json_filename}") return True class StravaClient: """Main client for interacting with Strava API.""" - + def __init__(self, auth: StravaAuth, downloader: ActivityDownloader): self.auth = auth self.downloader = downloader @@ -289,14 +378,13 @@ def get_filtered_activities(self) -> List[ActivityDetails]: try: response = self.auth.session.get( - self.auth.settings.activities_url, - params={"per_page": 100} + self.auth.settings.activities_url, params={"per_page": 10} ) response.raise_for_status() except requests.HTTPError as e: if e.response.status_code == 401: - print("Token expired during request, refreshing...") + logger.warning("Token expired during request, refreshing...") self.auth.refresh_token() return self.get_filtered_activities() raise @@ -311,7 +399,7 @@ def get_filtered_activities(self) -> List[ActivityDetails]: class StravaClientBuilder: """Builder pattern implementation for StravaClient.""" - + def __init__(self): self.settings = StravaSettings() self.auth = StravaAuth(self.settings) @@ -330,10 +418,7 @@ def with_cookies(self) -> "StravaClientBuilder": def build(self) -> StravaClient: """Build configured StravaClient instance.""" - downloader = ActivityDownloader( - self.auth.session, - self.database - ) + downloader = ActivityDownloader(self.auth.session, self.database) return StravaClient(self.auth, downloader) def __del__(self): @@ -342,6 +427,7 @@ def __del__(self): if __name__ == "__main__": + logging.basicConfig(level=logging.DEBUG) client_builder = None try: client_builder = StravaClientBuilder() @@ -349,31 +435,32 @@ def __del__(self): all_activities = client.get_filtered_activities() new_activities = [ - a for a in all_activities - if not client.downloader.db.is_downloaded(a.id) + a for a in all_activities if not client.downloader.db.is_downloaded(a.id) ] if not new_activities: - print("No new activities found") + logger.warning("No new activities found") exit() - print("\nπŸ† New Virtual Rides with 'MyWhoosh' in name:") + logger.info("\nπŸ† New Virtual Rides with 'MyWhoosh' in name:") for activity in new_activities: date_str = activity.start_date.strftime("%Y-%m-%d %H:%M") - print(f"πŸ“… {date_str} - {activity.name} (ID: {activity.id})") + logger.info(f"πŸ“… {date_str} - {activity.name} (ID: {activity.id})") + + time.sleep(5) # Small delay before downloads new_downloads = 0 for activity in new_activities: if client.downloader.download_activity(activity.id): new_downloads += 1 - print("\nDownload summary:") - print(f"β€’ New activities downloaded: {new_downloads}") - print(f"β€’ Already existed: {len(all_activities) - len(new_activities)}") - print(f"β€’ Total processed: {len(all_activities)}") + logger.info("\nDownload summary:") + logger.info(f"β€’ New activities downloaded: {new_downloads}") + logger.info(f"β€’ Already existed: {len(all_activities) - len(new_activities)}") + logger.info(f"β€’ Total processed: {len(all_activities)}") except Exception as e: - print(f"❌ Error: {str(e)}") + logger.error(f"❌ Error: {str(e)}") finally: if client_builder: client_builder.database.close() diff --git a/strava/utils.py b/strava/utils.py new file mode 100644 index 0000000..03e2c5e --- /dev/null +++ b/strava/utils.py @@ -0,0 +1,13 @@ + +import re + +def sanitize_filename(name: str) -> str: + r""" + Sanitize the string to be safe for filenames. + Replaces special characters (Windows invalid characters) with underscores. + Invalid characters: < > : " / \ | ? * + """ + # Replace invalid characters with underscore + sanitized = re.sub(r'[<>:"/\\|?*]', '_', name) + # Strip leading/trailing whitespace + return sanitized.strip() diff --git a/terraform/README.md b/terraform/README.md new file mode 100644 index 0000000..faabd33 --- /dev/null +++ b/terraform/README.md @@ -0,0 +1,12 @@ +```bash +terraform plan \ + -var="github_owner=${GITHUB_OWNER:-MarcChen}" \ + -var="github_repository=${GITHUB_REPOSITORY:-MyWhoosh2Garmin}" \ + -var="strava_client_id=${STRAVA_CLIENT_ID}" \ + -var="strava_client_secret=${STRAVA_CLIENT_SECRET}" \ + -var="strava_access_token=${STRAVA_ACCESS_TOKEN}" \ + -var="strava_refresh_token=${STRAVA_REFRESH_TOKEN}" \ + -var="strava_expires_at=${STRAVA_EXPIRES_AT}" \ + -var="strava_expires_in=${STRAVA_EXPIRES_IN}" \ + -var="garmin_tokens=${GARMIN_TOKENS}" +``` \ No newline at end of file diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 0000000..4a74ddc --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,36 @@ +terraform { + required_providers { + github = { + source = "integrations/github" + version = "~> 5.0" + } + } +} + +provider "github" { + # The provider will use the GITHUB_TOKEN environment variable by default. + # If you prefer, pass a token with `-var="github_token=..."` or set `TF_VAR_github_token`. + owner = var.github_owner +} + +locals { + raw_secrets = { + STRAVA_CLIENT_ID = var.strava_client_id + STRAVA_CLIENT_SECRET = var.strava_client_secret + STRAVA_ACCESS_TOKEN = var.strava_access_token + STRAVA_EXPIRES_AT = var.strava_expires_at + STRAVA_EXPIRES_IN = var.strava_expires_in + STRAVA_REFRESH_TOKEN = var.strava_refresh_token + GARMIN_TOKENS = var.garmin_tokens + } + + # remove empty values so we don't create blank secrets + secrets = { for k, v in local.raw_secrets : k => v if v != "" } +} + +resource "github_actions_secret" "repo_secrets" { + for_each = local.secrets + repository = var.github_repository + secret_name = each.key + plaintext_value = each.value +} diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000..e8063f4 --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,47 @@ +variable "github_owner" { + description = "The GitHub owner (user or organization) that owns the repository." + type = string + default = "MarcChen" +} + +variable "github_repository" { + description = "The repository name where secrets will be created." + type = string + default = "MyWhoosh2Garmin" +} + + +variable "strava_client_id" { + description = "STRAVA_CLIENT_ID from .env-temmplate" + type = string +} + +variable "strava_client_secret" { + description = "STRAVA_CLIENT_SECRET from .env-temmplate" + type = string +} + +variable "strava_access_token" { + description = "STRAVA_ACCESS_TOKEN from .env-temmplate" + type = string +} + +variable "strava_expires_at" { + description = "STRAVA_EXPIRES_AT from .env-temmplate" + type = string +} + +variable "strava_expires_in" { + description = "STRAVA_EXPIRES_IN from .env-temmplate" + type = string +} + +variable "strava_refresh_token" { + description = "STRAVA_REFRESH_TOKEN from .env-temmplate" + type = string +} + +variable "garmin_tokens" { + description = "GARMIN_TOKENS from .env-temmplate" + type = string +} diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..63e8894 --- /dev/null +++ b/uv.lock @@ -0,0 +1,525 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "bitstruct" +version = "8.11.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/33/9f094b5e32bc0927acf282199d35c384092dd73505c88fadb69292106eaf/bitstruct-8.11.1.tar.gz", hash = "sha256:4e7b8769c0f09fee403d0a5f637f8b575b191a79a92e140811aa109ce7461f0c", size = 34140, upload-time = "2020-11-19T09:30:04.021Z" } + +[[package]] +name = "certifi" +version = "2025.11.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, +] + +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + +[[package]] +name = "filelock" +version = "3.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" }, +] + +[[package]] +name = "fit-tool" +version = "0.9.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bitstruct" }, + { name = "openpyxl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/8f/9446c4eb9a8e5720b18848b3036653c4fdd0b58746e2c62f1eef05e7632b/fit-tool-0.9.13.tar.gz", hash = "sha256:63d5655dbacf4121178e7743ad4cf0d980abd53da6316a419c205941ce049c55", size = 137841, upload-time = "2022-10-05T18:31:20.446Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/c1/27e6ff55c0bca9b61b374f72dad8143908b1d1e792e04e2dbd74ade26857/fit_tool-0.9.13-py3-none-any.whl", hash = "sha256:15269280635ff3a4baedfe1346840767f660817f136f4dec69b858ee855a9e9c", size = 224079, upload-time = "2022-10-05T18:31:18.298Z" }, +] + +[[package]] +name = "garth" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "requests" }, + { name = "requests-oauthlib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/a9/872cba34553b66afb86c2091718e05b4acd34cd4e09bea6c13af9b465e6f/garth-0.5.2.tar.gz", hash = "sha256:594acafe27989daa3ffbc8460caf063802359c6b1f40c98ffd3b21f69adc6e09", size = 1733749, upload-time = "2024-12-12T12:18:26.004Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/38/dbdecb82297cfbd38920d0e009eaf47cb30eade41b529fc3cd831a20ec5b/garth-0.5.2-py3-none-any.whl", hash = "sha256:bd0297ef82afdf06e10ed0c271c9270bbf6bec832d62d108a325a8ac4723d74a", size = 24492, upload-time = "2024-12-12T12:18:23.027Z" }, +] + +[[package]] +name = "greenlet" +version = "3.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload-time = "2025-08-07T13:24:33.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" }, + { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" }, + { url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" }, + { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" }, + { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" }, + { url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" }, + { url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" }, + { url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" }, + { url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" }, + { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" }, + { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" }, + { url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" }, + { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" }, + { url = "https://files.pythonhosted.org/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760, upload-time = "2025-11-04T12:42:25.341Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" }, +] + +[[package]] +name = "identify" +version = "2.6.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "jdcal" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/b0/fa20fce23e9c3b55b640e629cb5edf32a85e6af3cf7af599940eb0c753fe/jdcal-1.4.1.tar.gz", hash = "sha256:472872e096eb8df219c23f2689fc336668bdb43d194094b5cc1707e1640acfc8", size = 7479, upload-time = "2019-04-24T10:22:15.079Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/da/572cbc0bc582390480bbd7c4e93d14dc46079778ed915b505dc494b37c57/jdcal-1.4.1-py2.py3-none-any.whl", hash = "sha256:1abf1305fce18b4e8aa248cf8fe0c56ce2032392bc64bbd61b5dff2a19ec8bba", size = 9522, upload-time = "2019-04-24T10:22:13.201Z" }, +] + +[[package]] +name = "mywhoosh2garmin" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "fit-tool" }, + { name = "garth" }, + { name = "playwright" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pre-commit" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "fit-tool", specifier = "==0.9.13" }, + { name = "garth", specifier = "==0.5.2" }, + { name = "playwright", specifier = "==1.55.0" }, + { name = "pre-commit", marker = "extra == 'dev'", specifier = "==4.4.0" }, + { name = "pydantic", specifier = "==2.12.4" }, + { name = "pydantic-settings", specifier = "==2.11.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = "==0.14.5" }, +] +provides-extras = ["dev"] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, +] + +[[package]] +name = "openpyxl" +version = "2.5.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, + { name = "jdcal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/8a/509eb6f58672288da9a5884e1cc7e90819bc8dbef501161c4b40a6a4e46b/openpyxl-2.5.12.tar.gz", hash = "sha256:7bcf019a0be528673a8aec1e60b5c863342c3231962dbf7922fd4da42a49a91a", size = 173659, upload-time = "2018-11-29T11:29:14.739Z" } + +[[package]] +name = "platformdirs" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, +] + +[[package]] +name = "playwright" +version = "1.55.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/3a/c81ff76df266c62e24f19718df9c168f49af93cabdbc4608ae29656a9986/playwright-1.55.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:d7da108a95001e412effca4f7610de79da1637ccdf670b1ae3fdc08b9694c034", size = 40428109, upload-time = "2025-08-28T15:46:20.357Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f5/bdb61553b20e907196a38d864602a9b4a461660c3a111c67a35179b636fa/playwright-1.55.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8290cf27a5d542e2682ac274da423941f879d07b001f6575a5a3a257b1d4ba1c", size = 38687254, upload-time = "2025-08-28T15:46:23.925Z" }, + { url = "https://files.pythonhosted.org/packages/4a/64/48b2837ef396487807e5ab53c76465747e34c7143fac4a084ef349c293a8/playwright-1.55.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:25b0d6b3fd991c315cca33c802cf617d52980108ab8431e3e1d37b5de755c10e", size = 40428108, upload-time = "2025-08-28T15:46:27.119Z" }, + { url = "https://files.pythonhosted.org/packages/08/33/858312628aa16a6de97839adc2ca28031ebc5391f96b6fb8fdf1fcb15d6c/playwright-1.55.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c6d4d8f6f8c66c483b0835569c7f0caa03230820af8e500c181c93509c92d831", size = 45905643, upload-time = "2025-08-28T15:46:30.312Z" }, + { url = "https://files.pythonhosted.org/packages/83/83/b8d06a5b5721931aa6d5916b83168e28bd891f38ff56fe92af7bdee9860f/playwright-1.55.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29a0777c4ce1273acf90c87e4ae2fe0130182100d99bcd2ae5bf486093044838", size = 45296647, upload-time = "2025-08-28T15:46:33.221Z" }, + { url = "https://files.pythonhosted.org/packages/06/2e/9db64518aebcb3d6ef6cd6d4d01da741aff912c3f0314dadb61226c6a96a/playwright-1.55.0-py3-none-win32.whl", hash = "sha256:29e6d1558ad9d5b5c19cbec0a72f6a2e35e6353cd9f262e22148685b86759f90", size = 35476046, upload-time = "2025-08-28T15:46:36.184Z" }, + { url = "https://files.pythonhosted.org/packages/46/4f/9ba607fa94bb9cee3d4beb1c7b32c16efbfc9d69d5037fa85d10cafc618b/playwright-1.55.0-py3-none-win_amd64.whl", hash = "sha256:7eb5956473ca1951abb51537e6a0da55257bb2e25fc37c2b75af094a5c93736c", size = 35476048, upload-time = "2025-08-28T15:46:38.867Z" }, + { url = "https://files.pythonhosted.org/packages/21/98/5ca173c8ec906abde26c28e1ecb34887343fd71cc4136261b90036841323/playwright-1.55.0-py3-none-win_arm64.whl", hash = "sha256:012dc89ccdcbd774cdde8aeee14c08e0dd52ddb9135bf10e9db040527386bd76", size = 31225543, upload-time = "2025-08-28T15:46:41.613Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/49/7845c2d7bf6474efd8e27905b51b11e6ce411708c91e829b93f324de9929/pre_commit-4.4.0.tar.gz", hash = "sha256:f0233ebab440e9f17cabbb558706eb173d19ace965c68cdce2c081042b4fab15", size = 197501, upload-time = "2025-11-08T21:12:11.607Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/11/574fe7d13acf30bfd0a8dd7fa1647040f2b8064f13f43e8c963b1e65093b/pre_commit-4.4.0-py2.py3-none-any.whl", hash = "sha256:b35ea52957cbf83dcc5d8ee636cbead8624e3a15fbfa61a370e42158ac8a5813", size = 226049, upload-time = "2025-11-08T21:12:10.228Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/ad/a17bc283d7d81837c061c49e3eaa27a45991759a1b7eae1031921c6bd924/pydantic-2.12.4.tar.gz", hash = "sha256:0f8cb9555000a4b5b617f66bfd2566264c4984b27589d3b845685983e8ea85ac", size = 821038, upload-time = "2025-11-05T10:50:08.59Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/2f/e68750da9b04856e2a7ec56fc6f034a5a79775e9b9a81882252789873798/pydantic-2.12.4-py3-none-any.whl", hash = "sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e", size = 463400, upload-time = "2025-11-05T10:50:06.732Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/c5/dbbc27b814c71676593d1c3f718e6cd7d4f00652cefa24b75f7aa3efb25e/pydantic_settings-2.11.0.tar.gz", hash = "sha256:d0e87a1c7d33593beb7194adb8470fc426e95ba02af83a0f23474a04c9a08180", size = 188394, upload-time = "2025-09-24T14:19:11.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/d6/887a1ff844e64aa823fb4905978d882a633cfe295c32eacad582b78a7d8b/pydantic_settings-2.11.0-py3-none-any.whl", hash = "sha256:fe2cea3413b9530d10f3a5875adffb17ada5c1e1bab0b2885546d7310415207c", size = 48608, upload-time = "2025-09-24T14:19:10.015Z" }, +] + +[[package]] +name = "pyee" +version = "13.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/03/1fd98d5841cd7964a27d729ccf2199602fe05eb7a405c1462eb7277945ed/pyee-13.0.0.tar.gz", hash = "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37", size = 31250, upload-time = "2025-03-17T18:53:15.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/4d/b9add7c84060d4c1906abe9a7e5359f2a60f7a9a4f67268b2766673427d8/pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498", size = 15730, upload-time = "2025-03-17T18:53:14.532Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oauthlib" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/fa/fbb67a5780ae0f704876cb8ac92d6d76da41da4dc72b7ed3565ab18f2f52/ruff-0.14.5.tar.gz", hash = "sha256:8d3b48d7d8aad423d3137af7ab6c8b1e38e4de104800f0d596990f6ada1a9fc1", size = 5615944, upload-time = "2025-11-13T19:58:51.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/31/c07e9c535248d10836a94e4f4e8c5a31a1beed6f169b31405b227872d4f4/ruff-0.14.5-py3-none-linux_armv6l.whl", hash = "sha256:f3b8248123b586de44a8018bcc9fefe31d23dda57a34e6f0e1e53bd51fd63594", size = 13171630, upload-time = "2025-11-13T19:57:54.894Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/283c62516dca697cd604c2796d1487396b7a436b2f0ecc3fd412aca470e0/ruff-0.14.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f7a75236570318c7a30edd7f5491945f0169de738d945ca8784500b517163a72", size = 13413925, upload-time = "2025-11-13T19:57:59.181Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/aa319f4afc22cb6fcba2b9cdfc0f03bbf747e59ab7a8c5e90173857a1361/ruff-0.14.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d146132d1ee115f8802356a2dc9a634dbf58184c51bff21f313e8cd1c74899a", size = 12574040, upload-time = "2025-11-13T19:58:02.056Z" }, + { url = "https://files.pythonhosted.org/packages/f9/7f/cb5845fcc7c7e88ed57f58670189fc2ff517fe2134c3821e77e29fd3b0c8/ruff-0.14.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2380596653dcd20b057794d55681571a257a42327da8894b93bbd6111aa801f", size = 13009755, upload-time = "2025-11-13T19:58:05.172Z" }, + { url = "https://files.pythonhosted.org/packages/21/d2/bcbedbb6bcb9253085981730687ddc0cc7b2e18e8dc13cf4453de905d7a0/ruff-0.14.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2d1fa985a42b1f075a098fa1ab9d472b712bdb17ad87a8ec86e45e7fa6273e68", size = 12937641, upload-time = "2025-11-13T19:58:08.345Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/e25de28a572bdd60ffc6bb71fc7fd25a94ec6a076942e372437649cbb02a/ruff-0.14.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88f0770d42b7fa02bbefddde15d235ca3aa24e2f0137388cc15b2dcbb1f7c7a7", size = 13610854, upload-time = "2025-11-13T19:58:11.419Z" }, + { url = "https://files.pythonhosted.org/packages/7d/24/43bb3fd23ecee9861970978ea1a7a63e12a204d319248a7e8af539984280/ruff-0.14.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3676cb02b9061fee7294661071c4709fa21419ea9176087cb77e64410926eb78", size = 15061088, upload-time = "2025-11-13T19:58:14.551Z" }, + { url = "https://files.pythonhosted.org/packages/23/44/a022f288d61c2f8c8645b24c364b719aee293ffc7d633a2ca4d116b9c716/ruff-0.14.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b595bedf6bc9cab647c4a173a61acf4f1ac5f2b545203ba82f30fcb10b0318fb", size = 14734717, upload-time = "2025-11-13T19:58:17.518Z" }, + { url = "https://files.pythonhosted.org/packages/58/81/5c6ba44de7e44c91f68073e0658109d8373b0590940efe5bd7753a2585a3/ruff-0.14.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f55382725ad0bdb2e8ee2babcbbfb16f124f5a59496a2f6a46f1d9d99d93e6e2", size = 14028812, upload-time = "2025-11-13T19:58:20.533Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ef/41a8b60f8462cb320f68615b00299ebb12660097c952c600c762078420f8/ruff-0.14.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7497d19dce23976bdaca24345ae131a1d38dcfe1b0850ad8e9e6e4fa321a6e19", size = 13825656, upload-time = "2025-11-13T19:58:23.345Z" }, + { url = "https://files.pythonhosted.org/packages/7c/00/207e5de737fdb59b39eb1fac806904fe05681981b46d6a6db9468501062e/ruff-0.14.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:410e781f1122d6be4f446981dd479470af86537fb0b8857f27a6e872f65a38e4", size = 13959922, upload-time = "2025-11-13T19:58:26.537Z" }, + { url = "https://files.pythonhosted.org/packages/bc/7e/fa1f5c2776db4be405040293618846a2dece5c70b050874c2d1f10f24776/ruff-0.14.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c01be527ef4c91a6d55e53b337bfe2c0f82af024cc1a33c44792d6844e2331e1", size = 12932501, upload-time = "2025-11-13T19:58:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/67/d8/d86bf784d693a764b59479a6bbdc9515ae42c340a5dc5ab1dabef847bfaa/ruff-0.14.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f66e9bb762e68d66e48550b59c74314168ebb46199886c5c5aa0b0fbcc81b151", size = 12927319, upload-time = "2025-11-13T19:58:32.923Z" }, + { url = "https://files.pythonhosted.org/packages/ac/de/ee0b304d450ae007ce0cb3e455fe24fbcaaedae4ebaad6c23831c6663651/ruff-0.14.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d93be8f1fa01022337f1f8f3bcaa7ffee2d0b03f00922c45c2207954f351f465", size = 13206209, upload-time = "2025-11-13T19:58:35.952Z" }, + { url = "https://files.pythonhosted.org/packages/33/aa/193ca7e3a92d74f17d9d5771a765965d2cf42c86e6f0fd95b13969115723/ruff-0.14.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c135d4b681f7401fe0e7312017e41aba9b3160861105726b76cfa14bc25aa367", size = 13953709, upload-time = "2025-11-13T19:58:39.002Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f1/7119e42aa1d3bf036ffc9478885c2e248812b7de9abea4eae89163d2929d/ruff-0.14.5-py3-none-win32.whl", hash = "sha256:c83642e6fccfb6dea8b785eb9f456800dcd6a63f362238af5fc0c83d027dd08b", size = 12925808, upload-time = "2025-11-13T19:58:42.779Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9d/7c0a255d21e0912114784e4a96bf62af0618e2190cae468cd82b13625ad2/ruff-0.14.5-py3-none-win_amd64.whl", hash = "sha256:9d55d7af7166f143c94eae1db3312f9ea8f95a4defef1979ed516dbb38c27621", size = 14331546, upload-time = "2025-11-13T19:58:45.691Z" }, + { url = "https://files.pythonhosted.org/packages/e5/80/69756670caedcf3b9be597a6e12276a6cf6197076eb62aad0c608f8efce0/ruff-0.14.5-py3-none-win_arm64.whl", hash = "sha256:4b700459d4649e2594b31f20a9de33bc7c19976d4746d8d0798ad959621d64a4", size = 13433331, upload-time = "2025-11-13T19:58:48.434Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + +[[package]] +name = "virtualenv" +version = "20.35.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" }, +]