From 1477ec36fde7f4c6355768556015d13a8c32d865 Mon Sep 17 00:00:00 2001 From: Yandrik Date: Tue, 16 Jun 2026 15:14:37 +0200 Subject: [PATCH] INITIAL COMMIT --- .env.example | 24 + .gitignore | 11 + Dockerfile | 26 + README.md | 133 +++ docker-compose.yml | 32 + docs/edge1030-testing.md | 62 ++ docs/feasibility.md | 194 ++++ docs/limitations.md | 33 + docs/phase3-gate.md | 99 ++ docs/testing.md | 275 ++++++ pyproject.toml | 33 + scripts/analyze_dump.py | 110 +++ scripts/clone_today_workout.py | 144 +++ scripts/local_check.py | 64 ++ scripts/probe_garmin.py | 706 ++++++++++++++ src/garmin_coach_clone/__init__.py | 6 + src/garmin_coach_clone/app.py | 599 ++++++++++++ src/garmin_coach_clone/auth.py | 87 ++ src/garmin_coach_clone/coach.py | 197 ++++ src/garmin_coach_clone/config.py | 92 ++ src/garmin_coach_clone/crypto.py | 73 ++ src/garmin_coach_clone/dates.py | 12 + src/garmin_coach_clone/db.py | 112 +++ src/garmin_coach_clone/garmin_service.py | 120 +++ src/garmin_coach_clone/io.py | 17 + src/garmin_coach_clone/redact.py | 71 ++ src/garmin_coach_clone/repository.py | 351 +++++++ src/garmin_coach_clone/sync_service.py | 385 ++++++++ src/garmin_coach_clone/web_auth.py | 43 + src/garmin_coach_clone/workouts.py | 354 +++++++ static/style.css | 373 ++++++++ templates/base.html | 31 + templates/dashboard.html | 85 ++ templates/garmin.html | 32 + templates/login.html | 22 + templates/logs.html | 36 + templates/schedule.html | 47 + templates/search.html | 74 ++ templates/setup.html | 23 + templates/trace.html | 32 + templates/traces.html | 38 + tests/test_analyze_dump.py | 56 ++ tests/test_app.py | 57 ++ tests/test_coach.py | 51 ++ tests/test_redact.py | 45 + tests/test_repository.py | 33 + tests/test_sync_service.py | 147 +++ tests/test_workouts.py | 125 +++ uv.lock | 1063 ++++++++++++++++++++++ 49 files changed, 6835 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 docs/edge1030-testing.md create mode 100644 docs/feasibility.md create mode 100644 docs/limitations.md create mode 100644 docs/phase3-gate.md create mode 100644 docs/testing.md create mode 100644 pyproject.toml create mode 100755 scripts/analyze_dump.py create mode 100755 scripts/clone_today_workout.py create mode 100755 scripts/local_check.py create mode 100755 scripts/probe_garmin.py create mode 100644 src/garmin_coach_clone/__init__.py create mode 100644 src/garmin_coach_clone/app.py create mode 100644 src/garmin_coach_clone/auth.py create mode 100644 src/garmin_coach_clone/coach.py create mode 100644 src/garmin_coach_clone/config.py create mode 100644 src/garmin_coach_clone/crypto.py create mode 100644 src/garmin_coach_clone/dates.py create mode 100644 src/garmin_coach_clone/db.py create mode 100644 src/garmin_coach_clone/garmin_service.py create mode 100644 src/garmin_coach_clone/io.py create mode 100644 src/garmin_coach_clone/redact.py create mode 100644 src/garmin_coach_clone/repository.py create mode 100644 src/garmin_coach_clone/sync_service.py create mode 100644 src/garmin_coach_clone/web_auth.py create mode 100644 src/garmin_coach_clone/workouts.py create mode 100644 static/style.css create mode 100644 templates/base.html create mode 100644 templates/dashboard.html create mode 100644 templates/garmin.html create mode 100644 templates/login.html create mode 100644 templates/logs.html create mode 100644 templates/schedule.html create mode 100644 templates/search.html create mode 100644 templates/setup.html create mode 100644 templates/trace.html create mode 100644 templates/traces.html create mode 100644 tests/test_analyze_dump.py create mode 100644 tests/test_app.py create mode 100644 tests/test_coach.py create mode 100644 tests/test_redact.py create mode 100644 tests/test_repository.py create mode 100644 tests/test_sync_service.py create mode 100644 tests/test_workouts.py create mode 100644 uv.lock diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..236a9b6 --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +TZ=Europe/Berlin +APP_USERNAME=admin +APP_PASSWORD=change-me +APP_SECRET_KEY=replace-with-a-long-random-secret +SYNC_ENABLED=true +SYNC_DAYS_AHEAD=1 +GARMIN_EMAIL= +GARMIN_PASSWORD= +GARMIN_TOKEN_STORE=.garmin-tokens +GARMIN_TOKEN_DIR=/data/garmin-tokens +CLONE_PREFIX=GCClone +OVERWRITE_EXISTING=true +DELETE_OLD_CLONES=false +CHANGE_DETECTION_INTERVAL_MINUTES=30 +CHANGE_DETECTION_ACTIVE_WINDOW=05:00-22:00 +CHANGE_DETECTION_FIXED_TIMES=05:15,06:15,07:15,08:15,09:15,10:15,11:15,12:15,13:15 +LOG_LEVEL=INFO +DEBUG_DUMP_JSON=false + +# Optional, only needed when debugging Garmin browser-only /gc-api endpoints. +# Copy the Cookie request header from your logged-in browser's network request. +# Treat this like a password; never commit it. +GARMIN_GC_API_COOKIE= +GARMIN_GC_API_CSRF= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96fa0b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.venv/ +__pycache__/ +.pytest_cache/ +.ruff_cache/ +.ty/ +.env +.garmin-tokens/ +debug/ +dist/ +*.egg-info/ + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d34169f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +# syntax=docker/dockerfile:1 + +FROM ghcr.io/astral-sh/uv:0.11.16 AS uv-bin + +FROM python:3.12-slim AS runtime +WORKDIR /app + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONPATH=/app/src \ + DATA_DIR=/data \ + PATH="/app/.venv/bin:$PATH" + +COPY --from=uv-bin /uv /usr/local/bin/uv +COPY pyproject.toml uv.lock* ./ +RUN if [ -f uv.lock ]; then uv sync --frozen --no-dev; else uv sync --no-dev; fi + +COPY src/ ./src/ +COPY templates/ ./templates/ +COPY static/ ./static/ + +VOLUME ["/data"] +EXPOSE 8000 +HEALTHCHECK --interval=30s --timeout=5s --retries=3 CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=3)" + +CMD ["uvicorn", "garmin_coach_clone.app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..f891583 --- /dev/null +++ b/README.md @@ -0,0 +1,133 @@ +# Garmin Coach to Calendar Sync + +This tool clones Garmin Coach/adaptive cycling workouts into normal Garmin Connect cycling workouts and schedules the generated clone on your Garmin calendar. The target use case is an original Garmin Edge 1030: the Edge can sync and run the generated structured workout like a normal workout while still using normal Edge navigation. + +It uses unofficial Garmin Connect APIs. Run it locally or on a private network unless you harden it yourself. + +## Current Status + +Implemented: + +- Feasibility research in [docs/feasibility.md](/home/yannik/repos/garmin-coach-to-cal-sync/docs/feasibility.md). +- CLI probe scripts for Garmin login, discovery, Coach extraction, dummy upload/schedule, and clone dry-run. +- Dockerized FastAPI web app with first-launch local login setup, Garmin login/MFA, manual sync, scheduled change detection, logs, traces, search, and schedule editing. +- SQLite state under `/data/app.db`. +- Persistent Garmin tokens under `/data/garmin-tokens`. +- Coolify-compatible `Dockerfile` and `docker-compose.yml`. + +Still fragile: + +- Garmin can change private API endpoints or authentication behavior. +- Garmin Coach completion may not be marked when you complete the generated clone. +- Edge 1030 acceptance must be verified on your device after upload/schedule. +- FIT export fallback is not implemented because Garmin Connect scheduling is the primary path. + +## Quick Start + +```sh +cp .env.example .env +docker compose up --build +``` + +Open [http://localhost:8080](http://localhost:8080). + +First launch: + +1. Create the local app username/password. +2. Open `Garmin`. +3. Enter Garmin credentials. +4. Enter MFA if Garmin asks for it. +5. Use `Search` to inspect Coach/calendar/workout visibility. +6. Use `Dashboard > Dry-run today` first, then `Check today` or `Check tomorrow`. + +## Coolify Deployment + +Deploy from the repository link. + +Set: + +- Container port: `8000` +- Persistent storage mount: `/data` +- `APP_SECRET_KEY`: a long random secret +- `TZ`: `Europe/Berlin` +- Optional bootstrap `APP_USERNAME` / `APP_PASSWORD` + +The compose file uses a named volume for local Docker. In Coolify, configure persistent storage for `/data` instead of a repo bind mount. Runtime state, Garmin tokens, SQLite, logs, and traces all live under `/data`. + +## Environment + +Important defaults are in [.env.example](/home/yannik/repos/garmin-coach-to-cal-sync/.env.example): + +```text +TZ=Europe/Berlin +DATA_DIR=/data +APP_USERNAME=admin +APP_PASSWORD=change-me +APP_SECRET_KEY=replace-with-a-long-random-secret +SYNC_ENABLED=true +SYNC_DAYS_AHEAD=1 +CLONE_PREFIX=GCClone +OVERWRITE_EXISTING=true +DELETE_OLD_CLONES=false +CHANGE_DETECTION_INTERVAL_MINUTES=30 +CHANGE_DETECTION_ACTIVE_WINDOW=05:00-22:00 +CHANGE_DETECTION_FIXED_TIMES=05:15,06:15,07:15,08:15,09:15,10:15,11:15,12:15,13:15 +``` + +Scheduled runs are change-detection runs. They create a missing clone, skip an unchanged clone, and replace only when the Coach source hash changes. If a Coach day becomes rest/empty while a generated clone exists, scheduled sync records a warning and leaves the clone alone. + +## Web UI + +- `Dashboard`: Garmin status, last run, next run, clone state, manual sync actions. +- `Garmin`: store encrypted Garmin credentials and complete MFA. +- `Search`: list calendar entries, normal workouts, plans, Coach tasks, and generated clones. +- `Schedule`: edit sync enabled, interval window, fixed `:15` checks, days ahead, or restore defaults. +- `Traces`: inspect source Coach task/detail, generated payload, source hash, and upload/schedule result. +- `Logs`: sync events and warnings. + +Passwords, MFA codes, cookies, headers, and tokens are not logged. Trace JSON is redacted before storage. + +## CLI Probe Commands + +The CLI remains useful for low-level debugging: + +```sh +uv run python scripts/probe_garmin.py login +uv run python scripts/probe_garmin.py report --dump-json +uv run python scripts/probe_garmin.py coach --date today --dump-json +uv run python scripts/clone_today_workout.py --date today --dry-run --dump-json +uv run python scripts/clone_today_workout.py --date today --schedule +``` + +Run local verification: + +```sh +uv run ruff check . +uv run ty check +uv run pytest +``` + +## Edge 1030 Testing + +After a web or CLI sync creates a clone: + +1. Sync the Edge 1030. +2. Check `Training > Workouts`. +3. Check `Training > Training Plan > calendar icon > scheduled date`. +4. Start a route/course. +5. Start the generated workout and verify prompts run while navigation remains active. + +More detail is in [docs/edge1030-testing.md](/home/yannik/repos/garmin-coach-to-cal-sync/docs/edge1030-testing.md). + +## Cleanup + +Generated workouts use the `GCClone YYYY-MM-DD ...` prefix by default. Scheduled automation never deletes a current clone just because a check ran. Replacement only happens when the source hash changes and the stored mapping points to a generated clone. + +Use the CLI guarded cleanup helpers if you need manual cleanup: + +```sh +uv run python scripts/probe_garmin.py generated --prefix GCClone +uv run python scripts/probe_garmin.py generated-calendar --date today --prefix GCClone +``` + +Delete or unschedule only IDs shown by those generated-prefix listings. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2a8a63a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +services: + garmin-coach-clone: + build: . + container_name: garmin-coach-clone + ports: + - "8080:8000" + volumes: + - garmin-coach-clone-data:/data + environment: + TZ: "${TZ:-Europe/Berlin}" + APP_USERNAME: "${APP_USERNAME:-admin}" + APP_PASSWORD: "${APP_PASSWORD:-change-me}" + APP_SECRET_KEY: "${APP_SECRET_KEY:-replace-with-a-long-random-secret}" + SYNC_ENABLED: "${SYNC_ENABLED:-true}" + SYNC_DAYS_AHEAD: "${SYNC_DAYS_AHEAD:-1}" + CLONE_PREFIX: "${CLONE_PREFIX:-GCClone}" + OVERWRITE_EXISTING: "${OVERWRITE_EXISTING:-true}" + DELETE_OLD_CLONES: "${DELETE_OLD_CLONES:-false}" + CHANGE_DETECTION_INTERVAL_MINUTES: "${CHANGE_DETECTION_INTERVAL_MINUTES:-30}" + CHANGE_DETECTION_ACTIVE_WINDOW: "${CHANGE_DETECTION_ACTIVE_WINDOW:-05:00-22:00}" + CHANGE_DETECTION_FIXED_TIMES: "${CHANGE_DETECTION_FIXED_TIMES:-05:15,06:15,07:15,08:15,09:15,10:15,11:15,12:15,13:15}" + LOG_LEVEL: "${LOG_LEVEL:-INFO}" + DEBUG_DUMP_JSON: "${DEBUG_DUMP_JSON:-false}" + restart: unless-stopped + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=3)"] + interval: 30s + timeout: 5s + retries: 3 + +volumes: + garmin-coach-clone-data: diff --git a/docs/edge1030-testing.md b/docs/edge1030-testing.md new file mode 100644 index 0000000..484ea3a --- /dev/null +++ b/docs/edge1030-testing.md @@ -0,0 +1,62 @@ +# Edge 1030 Testing Checklist + +Use this after `probe_garmin.py dummy --schedule` or a successful clone upload. + +## Sync + +1. Open Garmin Connect on the phone or Garmin Express on the computer. +2. Sync the Edge 1030. +3. Wait for sync completion before checking menus. + +## Check Workout Library + +On the Edge 1030: + +```text +Training > Workouts +``` + +Expected: + +- The dummy workout or `GCClone YYYY-MM-DD ...` workout appears. +- It is listed as a cycling workout. +- Opening it shows structured steps. + +## Check Scheduled Calendar + +On the Edge 1030: + +```text +Training > Training Plan > calendar icon > scheduled date +``` + +Expected: + +- The scheduled dummy/clone workout appears on the selected date. +- It can be selected from the calendar entry. + +## Check Navigation Plus Workout + +1. Select a course or route as usual. +2. Start navigation. +3. Start the scheduled workout. +4. Start the ride timer. + +Expected: + +- Route/navigation remains usable. +- Structured workout prompts appear. +- Step transitions and alerts work. + +This is the key test because Extended Display is not acceptable for this project. + +## Report Back + +Report: + +- Whether the workout appeared in `Training > Workouts`. +- Whether it appeared in the calendar date view. +- Whether it could be started. +- Whether navigation continued while the workout ran. +- Whether Garmin Coach marked anything complete after the activity uploaded. + diff --git a/docs/feasibility.md b/docs/feasibility.md new file mode 100644 index 0000000..4dc7d78 --- /dev/null +++ b/docs/feasibility.md @@ -0,0 +1,194 @@ +# Garmin Coach to Edge 1030 Feasibility + +Research date: 2026-06-16. + +## Executive Summary + +The most realistic path is an unofficial Garmin Connect API probe: + +1. Read the user's Garmin Connect calendar/training-plan data. +2. Determine whether Garmin Coach cycling workout steps are exposed in the private training-plan/adaptive-plan JSON. +3. If step details are present, create a normal Garmin Connect cycling workout and schedule it. +4. Let the Edge 1030 sync that normal scheduled workout. + +What is proven from source documentation/code: + +- The Edge 1030 supports normal Garmin Connect structured workouts and scheduled workouts. +- `python-garminconnect` has methods for listing normal workouts, uploading a workout JSON, scheduling/unscheduling workouts, deleting workouts, listing calendar entries, and fetching training/adaptive plan details. +- The Garmin Connect workout JSON schema for normal workouts is partially represented by `python-garminconnect.workout`. + +What is proven from this user's account probe: + +- Garmin login/token reuse works with `python-garminconnect`. +- The account has an active `FBT_ADAPTIVE` Garmin Cycling Coach plan. +- The plan/calendar APIs expose dated Coach workout summaries, including name, date, sport type, duration, compact description, training effect label, and `workoutUuid`. +- The Garmin Connect browser uses `/gc-api/workout-service/fbt-adaptive/{workoutUuid}` to fetch the full Coach workout with `workoutSegments` and `workoutSteps`. +- The same detail is available to `python-garminconnect` through the Connect API path `/workout-service/fbt-adaptive/{workoutUuid}` without the browser `/gc-api` prefix. + +What is still not proven: + +- Whether the Edge 1030 accepts every cloned target type after sync. +- Whether the endpoint remains stable as Garmin changes private APIs. +- Whether a summary-derived approximation is good enough for the user's training use case. + +The official Garmin Developer Training API is not a good fit for this use case. It is business/approval-gated, designed to publish third-party workouts/plans to users, and does not provide a documented way to read a personal Garmin Coach/adaptive plan workout from Garmin Connect. + +## Feasibility Matrix + +| Feature | Supported / Unsupported / Unknown | Tested / documented / guessed | API/package/source | Notes | +|---|---:|---:|---|---| +| Edge 1030 runs normal structured workouts from Garmin Connect | Supported | Officially documented; not tested here | Edge 1030 manual: Workouts, Starting a Workout | Manual says workouts can be created in Garmin Connect, transferred, scheduled, and run on device. | +| Edge 1030 shows scheduled workouts in calendar/training plan | Supported | Officially documented; not tested here | Edge 1030 manual: Training Calendar | Menu path documented as `Training > Training Plan > calendar icon`. | +| Official API can read Garmin Coach/adaptive workouts from personal account | Unsupported | Documented absence/inferred from API direction | Garmin Training API, Health API docs | Training API publishes workouts/plans to Garmin Connect; Health API reads health/activity metrics, not Coach workout definitions. | +| Official API can create/schedule workouts | Supported with approval | Officially documented | Garmin Training API | It can publish workouts/training plans to the Garmin Connect calendar. | +| Official API available for personal app | Unsupported for this use case | Officially documented | Garmin Developer Program FAQ | Program is for enterprise/business use and requires approval. | +| Authenticate unofficially with Garmin Connect | Supported/fragile | Tested with user's account | `python-garminconnect` login/tokenstore source | Login worked and wrote `.garmin-tokens`. Future Garmin SSO changes remain a risk. | +| `garth` as standalone auth library | Unsupported/fragile | Project README | `matin/garth` README | `garth` says it is deprecated and new logins may not work after Garmin auth changes. Prefer current `python-garminconnect` probe. | +| Store/reuse Garmin tokens | Supported in source | Source-supported; untested here | `python-garminconnect` login and demo | Token directory can be loaded/dumped. Probe defaults to `.garmin-tokens`. | +| List training plans | Supported | Tested with user's account | `Garmin.get_training_plans()` | Returned one `FBT_ADAPTIVE` plan: `2026 Fitness Plan test`. | +| Detect active Garmin Coach/adaptive plan | Supported/partial | Tested with user's account | `trainingPlanCategory == FBT_ADAPTIVE` | The active cycling Coach plan is detectable. | +| Fetch adaptive training-plan detail | Supported summary only | Tested with user's account | `get_adaptive_training_plan_by_id()` | Returns `taskList` with dated `taskWorkout` summaries, but no step arrays. | +| Fetch Coach workout step details for date | Supported/fragile private API | Tested with user's account | `/workout-service/fbt-adaptive/{workoutUuid}` | Returns full `workoutSegments`/`workoutSteps` via normal `python-garminconnect` token auth. Browser shows equivalent `/gc-api/workout-service/fbt-adaptive/{workoutUuid}`. | +| Read compact Coach workout summary | Supported | Tested with user's account | Adaptive plan `taskList[].taskWorkout` | Exposes fields such as `workoutName`, `estimatedDurationInSecs`, `workoutDescription`, `trainingEffectLabel`, `workoutPhrase`, `workoutUuid`. | +| List Garmin calendar entries | Supported | Tested with user's account | `get_scheduled_workouts(year, month)` | Calendar contains `fbtAdaptiveWorkout` entries with `workoutUuid` and `trainingPlanId`. | +| List normal workouts | Supported | Tested with user's account | `get_workouts()` | Returned normal cycling workouts. | +| Fetch normal workout by ID | Supported in source | Source-supported; untested here | `get_workout_by_id()` | Useful for schema inspection and clone comparisons. | +| Create/upload normal cycling workout | Supported in source | Source-supported; untested here | `upload_workout()`, `upload_cycling_workout()` | Probe includes a minimal dummy cycling workout upload test. | +| Schedule normal workout on date | Supported in source | Source-supported; untested here | `schedule_workout(workout_id, YYYY-MM-DD)` | This is the preferred path if upload works. | +| Delete generated workout | Supported in source | Implemented with guard; not account-tested | `delete_workout()` | Probe refuses to delete unless the ID is in the generated-prefix workout list and `--confirm` is passed. | +| Unschedule generated workout | Supported in source | Implemented with guard; not account-tested | `unschedule_workout()` | Probe refuses to unschedule unless the scheduled ID is in the generated-prefix calendar list and `--confirm` is passed. | +| Avoid duplicate clones | Partially supported | Implemented by name prefix/date | Probe code | Uses `GCClone YYYY-MM-DD` prefix. Full app should persist mappings in SQLite. | +| Generate local FIT workout file | Possible | Official SDK documented; not implemented in probe | Garmin FIT Python SDK | SDK has an encoder. Need workout-file-specific implementation and device testing. | +| Copy `.fit` to `GARMIN/NewFiles` | Unknown | Common Garmin behavior; not verified here | Not accepted as primary source | Treat only as manual fallback until tested on Edge 1030. | +| Use `GARMIN/Workouts/Schedule` for date scheduling | Unknown | Not proven | No reliable official source found | Garmin Connect calendar scheduling is safer. | +| Coach clone marks Garmin Coach workout complete | Unknown/likely unsupported | Guessed | Separate cloned workout | A clone is a normal workout, not the original Coach workout. Completion linkage may not happen. | + +## Official Garmin API Findings + +Garmin's official Training API is aimed at approved integrations that publish workouts and training plans into Garmin Connect. The Training API page says it can publish workouts/training plans to the Garmin Connect calendar, then users sync compatible devices. + +This is directionally opposite of the required first step: reading a personal Garmin Coach/adaptive workout from Garmin Connect. I found no official endpoint for reading Garmin Coach plan workout definitions from a user's account. + +The Developer Program FAQ states the Garmin Connect Developer Program is for enterprise/business use and applications are reviewed. That makes it unrealistic for a local personal tool unless the user already controls an approved business integration. + +## Unofficial Garmin Connect Findings + +The current `python-garminconnect` source exposes the important private endpoints: + +- Workouts: `/workout-service` +- Calendar: `/calendar-service` +- Training plans: `/trainingplan-service/trainingplan` + +Relevant methods in source: + +- `get_workouts(start=0, limit=100)` +- `get_workout_by_id(workout_id)` +- `upload_workout(workout_json)` +- `upload_cycling_workout(workout)` +- `schedule_workout(workout_id, date_str)` +- `get_scheduled_workouts(year, month)` +- `get_scheduled_workout_by_id(scheduled_workout_id)` +- `delete_workout(workout_id)` +- `unschedule_workout(scheduled_workout_id)` +- `get_training_plans()` +- `get_training_plan_by_id(plan_id)` +- `get_adaptive_training_plan_by_id(plan_id)` + +The package demo routes plans with `trainingPlanCategory == "FBT_ADAPTIVE"` to the adaptive endpoint. That is the strongest lead for Garmin Coach/adaptive plan extraction, but it does not prove that cycling Coach step details are present for this user's account. + +Authentication is a risk. The current `python-garminconnect` package has token storage and MFA support, while the older `garth` project now says new logins may not work because Garmin changed auth. The probe therefore treats login itself as a testable capability. + +The probe also includes a read-only metadata call to `/workout-service/workout/types` and a normal-workout-by-ID dump. Those are intended to compare Garmin's current accepted workout schema against any Coach/adaptive JSON found during account testing. + +For account testing, `probe_garmin.py report --dump-json` runs the read-only discovery checks in one pass and writes `debug/probe_report.json`. That report is the most compact evidence bundle for deciding whether the proof-of-concept can continue to clone testing. + +### Account Probe Evidence + +The user's June 16, 2026 probe found an active Garmin Cycling Coach adaptive plan: + +- Plan ID: `45692639` +- Category: `FBT_ADAPTIVE` +- Name: `2026 Fitness Plan test` +- Today's Coach workout: `Sprint` +- Workout UUID: `fe26ad37-69e8-495b-9d3d-2ab10fb64334` +- Estimated duration: 3120 seconds +- Compact description: `2x13x0:10@All Out` + +The adaptive plan detail endpoint returned `taskList[].taskWorkout` summaries, not `workoutSegments` or `workoutSteps`. The calendar endpoint returned `itemType=fbtAdaptiveWorkout` entries with matching `trainingPlanId` and `workoutUuid`. + +The missing endpoint was found in the browser network analyzer: + +```text +/gc-api/workout-service/fbt-adaptive/{workoutUuid} +``` + +For the June 16 `Sprint` workout, that response contains `workoutSegments` and nested repeat groups. Testing showed that the equivalent Connect API route also works: + +```text +/workout-service/fbt-adaptive/{workoutUuid} +``` + +The CLI now uses that Connect API route when a `workoutUuid` is available. The browser `/gc-api` route returned `403` with the package token session, so it remains a browser-specific diagnostic path. + +This means exact Coach-to-normal-workout cloning is feasible with the unofficial API. + +## Workout Schema Findings + +`python-garminconnect.workout` encodes the normal Garmin Connect workout schema enough for a dummy cycling workout: + +- Sport type: cycling has `sportTypeId=2`, `sportTypeKey="cycling"`. +- Step types include warmup, cooldown, interval, recovery, rest, repeat, other, and main. +- End conditions include lap button, time, distance, calories, power, heart rate, iterations, fixed rest, fixed repetition, and reps. +- Target types include no target, power zone, cadence, heart-rate zone, speed zone, pace zone, grade, heart-rate lap, power lap, and resistance. + +The typed helpers only create simple time-based no-target steps. More exact Coach cloning will require preserving or constructing `targetType`, target values, and repeat groups from the raw Coach/adaptive JSON after the probe shows what Garmin returns. + +Edge 1030 acceptance for target details is untested. The dummy workout intentionally starts with simple time-based no-target steps to separate "can upload/schedule/sync" from "can preserve all Coach targets". + +## Direct Device / FIT Fallback + +A local FIT export fallback is plausible but lower priority: + +- Garmin's official FIT Python SDK can encode FIT files. +- FIT workout files can represent structured workout concepts through FIT profile messages, but the probe does not implement this yet. +- `fitparse`/`fitdecode` are primarily decoders and are not enough by themselves for reliable workout generation. +- Copying files to Garmin device folders is not a good primary automation path because scheduling behavior on `GARMIN/Workouts/Schedule` is not reliably documented for the Edge 1030. + +The safer first proof is still Garmin Connect upload + calendar scheduling. A FIT export can become a manual fallback if Garmin Connect upload/schedule works but Coach extraction or automated scheduling remains blocked. + +## Stop / Continue Decision + +Do not build the web app yet. + +Continue to Phase 2 probe because: + +- Normal workout upload/scheduling is source-supported and can be tested with a dummy workout. +- Adaptive/Coach endpoint access is source-supported enough to probe. + +Stop before Phase 3 unless the user confirms at least: + +- The dummy scheduled workout appears on the Edge 1030. +- Garmin Coach/adaptive JSON contains enough step data to clone through the FBT adaptive endpoint, or the agreed fallback is summary-derived/manual template generation. + +If the FBT adaptive endpoint changes or becomes unavailable, the best fallback architecture is: + +- Keep Garmin Connect login/calendar/workout discovery. +- Fall back to parsing adaptive plan `taskWorkout` summaries and producing clearly labeled approximate normal workouts. +- Allow manual review/edit of generated steps before upload. +- Optionally export FIT workouts for manual device copy. +- Do not claim unattended exact Garmin Coach cloning unless browser endpoint authentication is stable. + +## Sources + +- Garmin Training API: +- Garmin Developer Program FAQ: +- Garmin Developer Program Overview: +- Garmin Health API: +- Edge 1030 Workouts manual: +- Edge 1030 Following a Workout From Garmin Connect: +- Edge 1030 Starting a Workout: +- Edge 1030 Training Calendar: +- `python-garminconnect`: +- `python-garminconnect` workout models: +- `garth` deprecation notice: +- Garmin FIT Python SDK: diff --git a/docs/limitations.md b/docs/limitations.md new file mode 100644 index 0000000..17ff62e --- /dev/null +++ b/docs/limitations.md @@ -0,0 +1,33 @@ +# Limitations + +## Unofficial Garmin APIs + +This project uses private Garmin Connect endpoints through `python-garminconnect`. Garmin can change authentication, endpoint paths, payload schemas, or rate limits without notice. + +## Garmin Coach Extraction + +Garmin Coach/adaptive cycling workout steps are exposed through the private `/workout-service/fbt-adaptive/{workoutUuid}` endpoint for the tested account. This is still an unofficial endpoint: Garmin can change it, remove fields, or change authentication without notice. + +## Garmin Coach Completion + +A cloned workout is a separate normal Garmin Connect workout. Completing it may not mark the original Garmin Coach workout complete. + +## Target Conversion + +The first real Coach JSON contains time steps, nested repeats, heart-rate range targets, and instruction targets. Power, cadence, distance, open-ended, and lap-button-until-press targets may still need more examples before conversion can be considered complete. + +The CLI performs conservative local validation before upload, but that does not prove Garmin Connect or the Edge 1030 will accept every target detail. It only catches obvious malformed payloads before account writes. + +Offline dump analysis is intentionally structural. It can show whether saved JSON contains dated workout-like objects and whether a clone payload can be built, but it cannot prove Garmin Connect upload or Edge behavior. + +## Device File Fallback + +Generating FIT workout files is plausible, but direct device folder scheduling is not yet proven for the Edge 1030. Garmin Connect scheduling remains the safer first path. + +## Authentication + +MFA/token behavior depends on Garmin account state, region, rate limits, and current Garmin SSO behavior. Token files should be treated as sensitive and kept out of git. + +## Redaction + +Debug dumps are redacted on a best-effort basis. The redactor preserves Garmin workout schema identifiers needed for conversion, such as `stepTypeId` and `workoutTargetTypeId`, while removing known account, owner, token, email, and device identifier fields. Treat all `debug/` files as private anyway. diff --git a/docs/phase3-gate.md b/docs/phase3-gate.md new file mode 100644 index 0000000..6e7e96b --- /dev/null +++ b/docs/phase3-gate.md @@ -0,0 +1,99 @@ +# Phase 3 Gate + +Do not build the Dockerized web app until these checks are complete. + +## Local Evidence + +Run: + +```sh +uv run python scripts/local_check.py +``` + +Required result: + +- All local checks pass. +- `debug/local_checks.json` is written. + +This proves only that the checkout is internally consistent. It does not prove Garmin account, Garmin Connect, or Edge 1030 behavior. + +## Garmin Account Evidence + +Run: + +```sh +uv run python scripts/probe_garmin.py report --dump-json +``` + +Required result: + +- Garmin login succeeds, including MFA if required. +- `debug/probe_report.json` is written. +- Normal workouts are visible or the report clearly records why they are not. +- Coach/adaptive plan discovery either finds a matched workout for today/tomorrow or clearly shows that step extraction is missing. + +Optional offline analysis: + +```sh +uv run python scripts/analyze_dump.py debug/coach_workout_today.json --date today --clone-dry-run +``` + +If Coach tasks are visible but no step arrays are found, run: + +```sh +uv run python scripts/probe_garmin.py coach-endpoints --date today --dump-json +``` + +Required result for exact cloning: + +- At least one response contains `workoutSegments` or `workoutSteps`. + +Current account evidence from June 16, 2026: + +- The active `FBT_ADAPTIVE` cycling Coach plan is visible. +- The `Sprint` workout is visible as a dated Coach task and calendar item. +- Browser network analysis found `/gc-api/workout-service/fbt-adaptive/`, which returns `workoutSegments` and `workoutSteps`. +- CLI access works through the equivalent Connect API route `/workout-service/fbt-adaptive/`. +- `clone_today_workout.py --dry-run` succeeds from the CLI and preserves the visible step structure. + +## Edge 1030 Evidence + +Run: + +```sh +uv run python scripts/probe_garmin.py dummy --date tomorrow --schedule +``` + +Then sync and test on the Edge 1030. + +Required result: + +- Dummy workout appears in Garmin Connect. +- Dummy workout appears under `Training > Workouts`. +- Dummy workout appears under `Training > Training Plan > calendar icon > scheduled date`. +- Dummy workout can start while normal Edge navigation is active. + +## Clone Evidence + +Only run if Coach/adaptive inspection shows plausible steps: + +```sh +uv run python scripts/clone_today_workout.py --date today --dry-run --dump-json +uv run python scripts/clone_today_workout.py --date today --schedule +``` + +Required result: + +- Dry-run payload passes local validation. +- Scheduled clone appears in Garmin Connect. +- Scheduled clone appears on the Edge 1030. +- Clone can start while normal Edge navigation is active. + +## Decision + +Build Phase 3 only if either: + +- Dummy scheduling and Coach clone scheduling both work, or +- Dummy scheduling works and we explicitly choose a fallback architecture because Coach extraction is unavailable. + +If dummy scheduling fails, fix or replace the Garmin Connect scheduling path before building the web app. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..a252248 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,275 @@ +# Phase 2 Probe Testing + +Install dependencies: + +```sh +uv sync +``` + +Optional environment setup: + +```sh +cp .env.example .env +``` + +Fill `GARMIN_EMAIL` and `GARMIN_PASSWORD` in `.env`, or leave them blank and the scripts will prompt. Tokens are stored in `.garmin-tokens` by default. + +Garmin Coach cycling detail currently comes from this Connect API endpoint: + +```text +/workout-service/fbt-adaptive/ +``` + +The browser network analyzer shows the same route under `/gc-api/...`, but the CLI should use the Connect API route with normal `python-garminconnect` tokens. If you are debugging the browser-only `/gc-api` route specifically, you can copy the `Cookie` request header into `.env`: + +```text +GARMIN_GC_API_COOKIE=JWT_WEB=...; ... +``` + +Treat that cookie like a password. Do not commit `.env`, screenshots containing the cookie, or debug logs with request headers. + +## Local Checkout Validation + +```sh +uv run python scripts/local_check.py +``` + +Expected: + +- `PASS: ruff check .` +- `PASS: ty check` +- `PASS: pytest` +- `PASS: ... scripts/probe_garmin.py dummy --date tomorrow --dry-run` +- `debug/local_checks.json` is written. + +This command does not log in to Garmin or upload anything. + +## Login + +```sh +uv run python scripts/probe_garmin.py login +``` + +Expected: + +- If cached tokens work, it prints account/device summary. +- If credentials are needed, it prompts for email/password. +- If MFA is required, it prompts for the MFA code. +- It must not print the Garmin password or MFA code. + +## Discovery + +Preferred one-pass read-only report: + +```sh +uv run python scripts/probe_garmin.py report --dump-json +``` + +This writes `debug/probe_report.json`. It logs in once and collects account summary, training plans, workout type metadata, normal workouts, today/tomorrow calendar entries, and today/tomorrow Coach/adaptive match summaries. It continues through partial API failures and records errors in the report. + +Individual discovery commands: + +```sh +uv run python scripts/probe_garmin.py plans +uv run python scripts/probe_garmin.py types +uv run python scripts/probe_garmin.py calendar --date today --dump-json +uv run python scripts/probe_garmin.py calendar --date tomorrow --dump-json +uv run python scripts/probe_garmin.py workouts --limit 25 --dump-json +uv run python scripts/probe_garmin.py generated --prefix GCClone +uv run python scripts/probe_garmin.py generated-calendar --date today --prefix GCClone +``` + +Check: + +- Training plans are listed. +- Any plan marked `FBT_ADAPTIVE`, Coach, or adaptive is shown as a candidate. +- Workout type metadata is written to `debug/workout_types.json`. +- Calendar JSON dumps are written under `debug/`. +- Normal workouts are visible. +- Generated probe/clone workout templates are listed separately by prefix, if any exist. +- Generated scheduled calendar entries are listed separately by prefix for the inspected month, if any exist. + +If you have a normal cycling workout that already syncs to the Edge 1030, inspect it: + +```sh +uv run python scripts/probe_garmin.py workout --dump-json +``` + +This writes `debug/workout_.json`, which is useful as a known-good schema reference. + +`debug/` is gitignored because dumps can contain private Garmin account data even after redaction. + +## Coach Workout Inspection + +```sh +uv run python scripts/probe_garmin.py coach --date today --dump-json +uv run python scripts/probe_garmin.py coach --date tomorrow --dump-json +``` + +Useful output: + +- `Workout-like objects with workoutSegments/workoutSteps: N` +- A readable step summary with warmup/interval/recovery/cooldown/repeat steps. +- `debug/coach_workout_YYYY-MM-DD.json` when a matched workout is found. +- `debug/coach_workout_today.json` when today's matched workout is found. +- The probe should fetch the full `fbt-adaptive` detail from `workoutUuid`. + +Blocking output: + +- `No parseable workoutSegments/workoutSteps found for this date.` + +If blocked, report the command output and keep the redacted JSON in `debug/` for inspection. + +If `coach` finds dated Coach task summaries but no step arrays, run the read-only endpoint sweep: + +```sh +uv run python scripts/probe_garmin.py coach-endpoints --date today --dump-json +``` + +Expected: + +- It prints the plan ID, workout name, `workoutUuid`, and calendar item ID. +- It tries candidate private endpoints and continues through 404/errors. +- It writes `debug/coach_endpoint_probe_YYYY-MM-DD.json`. + +Useful output: + +- `At least one candidate endpoint returned workout-like step data.` + +Blocking output: + +- `No candidate endpoint returned workoutSegments/workoutSteps.` + +For the June 16, 2026 account probe, `/workout-service/fbt-adaptive/` returned step arrays. `/workout-service/workout/uuid/` returned an empty list, and other candidate detail endpoints returned errors. + +## Offline Dump Analysis + +Analyze a saved dump without logging in to Garmin: + +```sh +uv run python scripts/analyze_dump.py debug/coach_workout_today.json --date today --clone-dry-run --dump-json +``` + +Expected, if the dump contains a dated workout: + +- Date-containing object count is shown. +- Workout-like object count is shown. +- A matched workout summary is printed. +- `Local clone payload passed validation.` is printed if conversion is structurally safe. + +If the dump does not contain a dated workout-like object, the command exits non-zero and prints: + +```text +No dated workout-like object was found. +``` + +## Dummy Upload And Schedule Test + +Dry run: + +```sh +uv run python scripts/probe_garmin.py dummy --date tomorrow --dry-run +``` + +Expected: + +- `Dummy workout payload passed local validation.` +- The generated JSON is printed. +- Nothing is uploaded. + +Upload and schedule: + +```sh +uv run python scripts/probe_garmin.py dummy --date tomorrow --schedule +``` + +After upload/schedule succeeds: + +1. Sync the Edge 1030 with Garmin Connect / Garmin Express. +2. Check `Training > Workouts`. +3. Check `Training > Training Plan > calendar icon > tomorrow's date`. +4. Start normal route/course navigation, then start the dummy workout. +5. Confirm whether workout prompts and navigation can run together. + +Report: + +- Did the dummy workout appear in Garmin Connect? +- Did it appear on the Edge 1030? +- Did it appear in the calendar/date view? +- Could it start while navigation was active? + +## Clone Test + +Dry run: + +```sh +uv run python scripts/clone_today_workout.py --date today --dry-run --dump-json +``` + +Expected: + +- `Converted clone payload passed local validation.` +- The converted JSON is printed. +- Nothing is uploaded. + +Upload and schedule only if the dry run shows plausible steps: + +```sh +uv run python scripts/clone_today_workout.py --date today --schedule +``` + +The clone is named: + +```text +GCClone YYYY-MM-DD +``` + +Repeated runs skip an existing clone for the same date unless `--force` is passed. + +Report: + +- Did the cloned workout appear in Garmin Connect? +- Did it appear on the Edge 1030 under workouts/calendar? +- Can it be started on the Edge? +- Can it run together with normal Edge navigation? +- After completion, did Garmin Coach mark the original Coach workout complete? + +## Generated Workout Cleanup + +List generated workout templates: + +```sh +uv run python scripts/probe_garmin.py generated --prefix GCClone +``` + +List generated scheduled calendar entries for the month containing `--date`: + +```sh +uv run python scripts/probe_garmin.py generated-calendar --date today --prefix GCClone +``` + +Unschedule a generated calendar entry only if you are sure the scheduled ID is from the generated-prefix calendar list: + +```sh +uv run python scripts/probe_garmin.py generated-calendar --date today --prefix GCClone --unschedule-id --confirm +``` + +Delete a generated workout template only if you are sure the ID is from the generated-prefix list: + +```sh +uv run python scripts/probe_garmin.py generated --prefix GCClone --delete-id --confirm +``` + +Expected: + +- Without `--delete-id` or `--unschedule-id`, these commands are read-only. +- With `--delete-id` or `--unschedule-id`, the command refuses to run unless `--confirm` is also passed. +- The commands refuse IDs that are not in the generated-prefix list. + +Unknown: + +- Whether deleting the workout template also removes any scheduled calendar entry. Prefer unscheduling first, then check Garmin Connect calendar afterward and report what happened. + +## Current Acceptance Gate + +Phase 3 should not start until the dummy scheduled workout works on the Edge 1030 and the Coach inspection either exposes parseable steps or we explicitly choose a fallback architecture. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..92b0ce9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,33 @@ +[project] +name = "garmin-coach-to-cal-sync" +version = "0.1.0" +description = "Probe Garmin Coach cycling workout cloning feasibility." +requires-python = ">=3.12" +dependencies = [ + "cryptography>=43.0.0", + "fastapi>=0.115.0", + "garminconnect[workout]>=0.2.28", + "jinja2>=3.1.4", + "python-multipart>=0.0.9", + "python-dotenv>=1.0.1", + "uvicorn[standard]>=0.30.0", +] + +[dependency-groups] +dev = [ + "httpx>=0.27.0", + "pytest>=8.2", + "ruff>=0.8.0", + "ty>=0.0.1a1", +] + +[tool.ruff] +line-length = 100 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM"] + +[tool.pytest.ini_options] +pythonpath = ["src"] +testpaths = ["tests"] diff --git a/scripts/analyze_dump.py b/scripts/analyze_dump.py new file mode 100755 index 0000000..108857d --- /dev/null +++ b/scripts/analyze_dump.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from garmin_coach_clone.coach import ( + best_workout_for_date, + find_date_matches, + find_workout_like_objects, +) +from garmin_coach_clone.dates import parse_date +from garmin_coach_clone.io import dump_redacted_json, print_json +from garmin_coach_clone.workouts import ( + clone_workout_payload, + summarize_workout, + validate_workout_payload, +) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Analyze saved Garmin JSON without logging in.") + parser.add_argument("json_file", type=Path, help="Path to a saved JSON dump.") + parser.add_argument("--date", default="today", help="today, tomorrow, or YYYY-MM-DD") + parser.add_argument( + "--clone-dry-run", + action="store_true", + help="Build a local clone payload from the matched workout.", + ) + parser.add_argument("--prefix", default="GCClone", help="Clone workout name prefix.") + parser.add_argument( + "--dump-json", action="store_true", help="Write analysis JSON under debug/." + ) + args = parser.parse_args() + + target_date = parse_date(args.date) + data = _load_json(args.json_file) + date_matches = find_date_matches(data, target_date) + workout_like = find_workout_like_objects(data) + matched = best_workout_for_date(data, target_date) + + analysis: dict[str, Any] = { + "source": str(args.json_file), + "target_date": target_date.isoformat(), + "date_match_count": len(date_matches), + "workout_like_count": len(workout_like), + "matched_workout": matched is not None, + } + + print(f"Source: {args.json_file}") + print(f"Target date: {target_date.isoformat()}") + print(f"Date-containing objects: {len(date_matches)}") + print(f"Workout-like objects with workoutSegments/workoutSteps: {len(workout_like)}") + + if matched is None: + print("No dated workout-like object was found.") + if args.dump_json: + out = _analysis_path(args.json_file) + dump_redacted_json(out, analysis) + print(f"Analysis JSON written to {out}") + return 1 + + print("\nMatched workout summary:") + print(summarize_workout(matched)) + + if args.clone_dry_run: + payload = clone_workout_payload(matched, target_date, args.prefix) + errors = validate_workout_payload(payload) + analysis["clone_validation_errors"] = errors + analysis["clone_payload"] = payload + if errors: + print("\nLocal clone payload failed validation:") + for error in errors: + print(f" - {error}") + return_code = 1 + else: + print("\nLocal clone payload passed validation.") + print_json(payload) + return_code = 0 + else: + return_code = 0 + + if args.dump_json: + out = _analysis_path(args.json_file) + dump_redacted_json(out, analysis) + print(f"Analysis JSON written to {out}") + return return_code + + +def _load_json(path: Path) -> Any: + try: + return json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + raise SystemExit(f"File not found: {path}") from None + except json.JSONDecodeError as exc: + raise SystemExit(f"Invalid JSON in {path}: {exc}") from None + + +def _analysis_path(source: Path) -> Path: + stem = source.stem.replace(".", "_") + return Path("debug") / f"analysis_{stem}.json" + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/clone_today_workout.py b/scripts/clone_today_workout.py new file mode 100755 index 0000000..b5f2c30 --- /dev/null +++ b/scripts/clone_today_workout.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from dotenv import load_dotenv + +from garmin_coach_clone.auth import login +from garmin_coach_clone.coach import ( + best_workout_for_date, + extract_training_plans, + get_fbt_adaptive_workout, + is_adaptive_plan, + plan_id, + task_workout_for_date, +) +from garmin_coach_clone.dates import parse_date +from garmin_coach_clone.io import dump_redacted_json, print_json +from garmin_coach_clone.workouts import ( + clone_workout_payload, + existing_clone_names, + summarize_workout, + validate_workout_payload, +) + +load_dotenv() + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Clone a Garmin Coach workout if step data is exposed." + ) + parser.add_argument("--date", default="today", help="today, tomorrow, or YYYY-MM-DD") + parser.add_argument("--dry-run", action="store_true", help="Convert and print JSON only.") + parser.add_argument("--schedule", action="store_true", help="Upload and schedule the clone.") + parser.add_argument( + "--force", action="store_true", help="Create even if a clone already exists." + ) + parser.add_argument( + "--dump-json", action="store_true", help="Dump redacted Coach/adaptive JSON." + ) + args = parser.parse_args() + + target_date = parse_date(args.date) + prefix = os.getenv("CLONE_PREFIX", "GCClone") + api = login() + + plans = extract_training_plans(api.get_training_plans()) + candidates = [plan for plan in plans if is_adaptive_plan(plan)] + if not candidates: + print("No Garmin Coach/adaptive plan was identifiable. Cannot clone.") + return 1 + + source = None + source_plan_name = None + for plan in candidates: + pid = plan_id(plan) + if pid is None: + continue + detail = ( + api.get_adaptive_training_plan_by_id(pid) + if str(plan.get("trainingPlanCategory", "")).upper() == "FBT_ADAPTIVE" + else api.get_training_plan_by_id(pid) + ) + if args.dump_json: + out = Path("debug") / f"coach_clone_source_{pid}_{target_date.isoformat()}.json" + dump_redacted_json(out, detail) + print(f"Redacted source JSON written to {out}") + source = best_workout_for_date(detail, target_date) + if source is None: + task_workout = task_workout_for_date(detail, target_date) + workout_uuid = task_workout.get("workoutUuid") if task_workout else None + if workout_uuid: + source = get_fbt_adaptive_workout(api, str(workout_uuid)) + if source is not None: + source_plan_name = plan.get("name") + break + + if source is None: + print( + "Coach/adaptive plan data was fetched, but no object with workoutSegments " + f"could be matched to {target_date.isoformat()}." + ) + print( + "This means the missing piece is Coach step extraction, " + "not normal workout scheduling." + ) + return 1 + + payload = clone_workout_payload(source, target_date, prefix) + print(f"Source plan: {source_plan_name}") + print(summarize_workout(payload)) + validation_errors = validate_workout_payload(payload) + if validation_errors: + print("\nConverted clone payload failed local validation. Nothing was uploaded.") + for error in validation_errors: + print(f" - {error}") + return 1 + print("\nConverted clone payload passed local validation.") + + existing = existing_clone_names(api.get_workouts(limit=100), target_date, prefix) + if existing and not args.force: + print("A clone already exists for this date; skipping upload:") + for name in existing: + print(f" - {name}") + return 0 + + if args.dry_run or not args.schedule: + print("\nDry run: converted clone JSON follows. Nothing was uploaded.") + print_json(payload) + if not args.schedule: + print("\nPass --schedule to upload and schedule this clone.") + return 0 + + result = api.upload_workout(payload) + workout_id = result.get("workoutId") or result.get("id") + if workout_id is None: + print("Upload returned no workoutId. Raw response:") + print_json(result) + return 1 + + schedule_result = api.schedule_workout(workout_id, target_date.isoformat()) + print(f"Uploaded clone workout ID: {workout_id}") + print("Schedule response:") + print_json(schedule_result) + print( + f""" +Next checks on the Edge 1030 after Garmin Connect sync: + 1. Check Training > Workouts for "{payload["workoutName"]}". + 2. Check Training > Training Plan > calendar icon > {target_date.isoformat()}. + 3. Start it with a normal route/course active and verify navigation still works. + 4. After completing, check whether Garmin Coach marks anything complete; this is not expected yet. +""" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/local_check.py b/scripts/local_check.py new file mode 100755 index 0000000..39fa222 --- /dev/null +++ b/scripts/local_check.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import subprocess +import sys +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +COMMANDS = [ + ["ruff", "check", "."], + ["ty", "check"], + ["pytest"], + [ + sys.executable, + "scripts/probe_garmin.py", + "dummy", + "--date", + "tomorrow", + "--dry-run", + ], +] + + +def main() -> int: + results = [_run(command) for command in COMMANDS] + passed = all(result["returncode"] == 0 for result in results) + report: dict[str, Any] = { + "timestamp": datetime.now(tz=UTC).isoformat(), + "passed": passed, + "results": results, + } + out = ROOT / "debug" / "local_checks.json" + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps(report, indent=2), encoding="utf-8") + + for result in results: + status = "PASS" if result["returncode"] == 0 else "FAIL" + print(f"{status}: {' '.join(result['command'])}") + print(f"Local check report written to {out.relative_to(ROOT)}") + return 0 if passed else 1 + + +def _run(command: list[str]) -> dict[str, Any]: + completed = subprocess.run( + command, + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + return { + "command": command, + "returncode": completed.returncode, + "stdout": completed.stdout, + "stderr": completed.stderr, + } + + +if __name__ == "__main__": + raise SystemExit(main()) + diff --git a/scripts/probe_garmin.py b/scripts/probe_garmin.py new file mode 100755 index 0000000..739b900 --- /dev/null +++ b/scripts/probe_garmin.py @@ -0,0 +1,706 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import os +import sys +from collections.abc import Callable +from datetime import date, timedelta +from pathlib import Path +from typing import Any + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from dotenv import load_dotenv + +from garmin_coach_clone.auth import account_summary, login +from garmin_coach_clone.coach import ( + best_workout_for_date, + describe_plans, + extract_training_plans, + find_date_matches, + find_workout_like_objects, + gc_api_get, + get_fbt_adaptive_workout, + is_adaptive_plan, + plan_id, + task_workout_for_date, +) +from garmin_coach_clone.dates import parse_date +from garmin_coach_clone.io import dump_redacted_json, print_json +from garmin_coach_clone.workouts import ( + build_dummy_cycling_workout, + calendar_entry_date, + calendar_entry_id, + calendar_entry_name, + find_generated_calendar_entry, + find_generated_workout, + generated_calendar_entries, + generated_workouts, + summarize_workout, + validate_workout_payload, +) + +load_dotenv() + + +def main() -> int: + parser = argparse.ArgumentParser(description="Probe Garmin Connect workout feasibility.") + sub = parser.add_subparsers(dest="command", required=True) + + sub.add_parser("login", help="Authenticate and print account/device summary.") + sub.add_parser("plans", help="List training plans and adaptive/Coach candidates.") + sub.add_parser("types", help="Dump Garmin Connect workout type metadata.") + + report = sub.add_parser("report", help="Run read-only feasibility checks in one pass.") + report.add_argument("--limit", type=int, default=25, help="Normal workout list limit.") + report.add_argument("--dump-json", action="store_true", help="Write debug/probe_report.json.") + + calendar = sub.add_parser("calendar", help="List calendar entries for a date.") + calendar.add_argument("--date", default="today", help="today, tomorrow, or YYYY-MM-DD") + calendar.add_argument("--dump-json", action="store_true") + + coach = sub.add_parser("coach", help="Inspect Coach/adaptive workout data for a date.") + coach.add_argument("--date", default="today", help="today, tomorrow, or YYYY-MM-DD") + coach.add_argument("--dump-json", action="store_true") + + coach_endpoints = sub.add_parser( + "coach-endpoints", + help="Try read-only candidate endpoints for hidden Coach workout details.", + ) + coach_endpoints.add_argument("--date", default="today", help="today, tomorrow, or YYYY-MM-DD") + coach_endpoints.add_argument( + "--dump-json", action="store_true", help="Write debug/coach_endpoint_probe_*.json." + ) + + workouts = sub.add_parser("workouts", help="List normal workouts visible via the API.") + workouts.add_argument("--limit", type=int, default=25) + workouts.add_argument("--dump-json", action="store_true") + + workout = sub.add_parser("workout", help="Fetch and summarize a normal workout by ID.") + workout.add_argument("workout_id", help="Garmin Connect workout ID") + workout.add_argument("--dump-json", action="store_true") + + generated = sub.add_parser("generated", help="List/delete generated probe or clone workouts.") + generated.add_argument("--prefix", default=os.getenv("CLONE_PREFIX", "GCClone")) + generated.add_argument("--limit", type=int, default=100) + generated.add_argument("--delete-id", help="Delete a generated workout template by ID.") + generated.add_argument("--confirm", action="store_true", help="Required with --delete-id.") + + generated_calendar = sub.add_parser( + "generated-calendar", help="List/unschedule generated calendar entries for a month." + ) + generated_calendar.add_argument( + "--date", default="today", help="Any date in the month to inspect." + ) + generated_calendar.add_argument("--prefix", default=os.getenv("CLONE_PREFIX", "GCClone")) + generated_calendar.add_argument( + "--unschedule-id", help="Unschedule a generated calendar entry by scheduled ID." + ) + generated_calendar.add_argument( + "--dump-json", action="store_true", help="Write the redacted month calendar JSON." + ) + generated_calendar.add_argument( + "--confirm", action="store_true", help="Required with --unschedule-id." + ) + + dummy = sub.add_parser("dummy", help="Create and optionally schedule a dummy cycling workout.") + dummy.add_argument("--date", default="tomorrow", help="today, tomorrow, or YYYY-MM-DD") + dummy.add_argument("--dry-run", action="store_true", help="Print JSON without uploading.") + dummy.add_argument("--schedule", action="store_true", help="Upload and schedule the workout.") + + args = parser.parse_args() + + match args.command: + case "login": + return command_login() + case "plans": + return command_plans() + case "types": + return command_types() + case "report": + return command_report(args.limit, args.dump_json) + case "calendar": + return command_calendar(parse_date(args.date), args.dump_json) + case "coach": + return command_coach(parse_date(args.date), args.dump_json) + case "coach-endpoints": + return command_coach_endpoints(parse_date(args.date), args.dump_json) + case "workouts": + return command_workouts(args.limit, args.dump_json) + case "workout": + return command_workout(args.workout_id, args.dump_json) + case "generated": + return command_generated(args.prefix, args.limit, args.delete_id, args.confirm) + case "generated-calendar": + return command_generated_calendar( + parse_date(args.date), + args.prefix, + args.unschedule_id, + args.dump_json, + args.confirm, + ) + case "dummy": + return command_dummy(parse_date(args.date), args.dry_run, args.schedule) + return 2 + + +def command_login() -> int: + api = login() + print("Authenticated.") + print_json(account_summary(api)) + return 0 + + +def command_plans() -> int: + api = login() + response = api.get_training_plans() + plans = extract_training_plans(response) + print(describe_plans(plans)) + adaptive = [plan for plan in plans if is_adaptive_plan(plan)] + if adaptive: + print("\nAdaptive/Coach-looking plans:") + print(describe_plans(adaptive)) + else: + print("\nNo adaptive/Coach-looking plans were identifiable from the plan list.") + return 0 + + +def command_types() -> int: + api = login() + data = api.connectapi("/workout-service/workout/types") + out = Path("debug") / "workout_types.json" + dump_redacted_json(out, data) + print(f"Redacted Garmin workout type metadata written to {out}") + print_json(data) + return 0 + + +def command_report(limit: int, dump_json: bool) -> int: + api = login() + today = date.today() + tomorrow = today + timedelta(days=1) + target_dates = [today, tomorrow] + report: dict[str, Any] = {"dates": [day.isoformat() for day in target_dates]} + + print("Authenticated.") + report["account"] = account_summary(api) + _print_section("Account") + print_json(report["account"]) + + plans_response = _capture("training_plans", report, api.get_training_plans) + plans = extract_training_plans(plans_response) + candidates = [plan for plan in plans if is_adaptive_plan(plan)] + report["training_plan_summary"] = { + "count": len(plans), + "adaptive_candidate_count": len(candidates), + "adaptive_candidates": [_plan_summary(plan) for plan in candidates], + } + _print_section("Training Plans") + print(f"Plans returned: {len(plans)}") + print(f"Adaptive/Coach-looking candidates: {len(candidates)}") + if candidates: + print(describe_plans(candidates)) + + types_response = _capture( + "workout_types", report, lambda: api.connectapi("/workout-service/workout/types") + ) + _print_section("Workout Type Metadata") + print("Fetched." if not _is_error(types_response) else types_response["error"]) + + workouts = _capture("workouts", report, lambda: api.get_workouts(limit=limit)) + workout_count = len(workouts) if isinstance(workouts, list) else 0 + _print_section("Normal Workouts") + print(f"Normal workouts returned: {workout_count}") + if isinstance(workouts, list): + for workout in workouts[:10]: + print( + " - id={id} sport={sport} name={name}".format( + id=workout.get("workoutId"), + sport=(workout.get("sportType") or {}).get("sportTypeKey"), + name=workout.get("workoutName"), + ) + ) + + report["calendar_summary"] = {} + _print_section("Calendar") + for target_date in target_dates: + key = target_date.isoformat() + calendar_data = _capture( + f"calendar_{key}", + report, + lambda target_date=target_date: api.get_scheduled_workouts( + target_date.year, target_date.month + ), + ) + matches = find_date_matches(calendar_data, target_date) + report["calendar_summary"][key] = {"date_match_count": len(matches)} + print(f"{key}: {len(matches)} date-containing entries") + + report["coach_summary"] = {} + _print_section("Coach / Adaptive") + if not candidates: + print("No adaptive/Coach-looking plans found.") + for plan in candidates: + pid = plan_id(plan) + if pid is None: + continue + category = str(plan.get("trainingPlanCategory", "")).upper() + detail = _capture( + f"coach_plan_{pid}", + report, + lambda pid=pid, category=category: api.get_adaptive_training_plan_by_id(pid) + if category == "FBT_ADAPTIVE" + else api.get_training_plan_by_id(pid), + ) + plan_summary: dict[str, Any] = { + "name": plan.get("name"), + "category": plan.get("trainingPlanCategory"), + "workout_like_count": len(find_workout_like_objects(detail)), + "dates": {}, + } + for target_date in target_dates: + workout = best_workout_for_date(detail, target_date) + plan_summary["dates"][target_date.isoformat()] = { + "date_match_count": len(find_date_matches(detail, target_date)), + "matched_workout": workout is not None, + } + report["coach_summary"][str(pid)] = plan_summary + print( + "Plan {pid} {name}: workout-like={count}, today={today_match}, " + "tomorrow={tomorrow_match}".format( + pid=pid, + name=plan.get("name"), + count=plan_summary["workout_like_count"], + today_match=plan_summary["dates"][today.isoformat()]["matched_workout"], + tomorrow_match=plan_summary["dates"][tomorrow.isoformat()]["matched_workout"], + ) + ) + + if dump_json: + out = Path("debug") / "probe_report.json" + dump_redacted_json(out, report) + print(f"\nRedacted probe report written to {out}") + + _print_section("Next Gate") + print( + "If dummy upload/schedule works on the Edge and Coach matched_workout is true, " + "run clone dry-run." + ) + print("If Coach matched_workout is false, Coach step extraction is still the missing piece.") + return 0 + + +def command_calendar(target_date: date, dump_json: bool) -> int: + api = login() + data = api.get_scheduled_workouts(target_date.year, target_date.month) + matches = find_date_matches(data, target_date) + print(f"Calendar entries containing {target_date.isoformat()}: {len(matches)}") + for idx, item in enumerate(matches, start=1): + name = item.get("workoutName") or item.get("name") or item.get("title") or "" + item_id = item.get("workoutId") or item.get("scheduledWorkoutId") or item.get("id") + print(f" {idx}. id={item_id} name={name}") + if dump_json: + out = Path("debug") / f"calendar_{target_date.isoformat()}.json" + dump_redacted_json(out, data) + print(f"Redacted calendar JSON written to {out}") + return 0 + + +def command_coach(target_date: date, dump_json: bool) -> int: + api = login() + plans_response = api.get_training_plans() + plans = extract_training_plans(plans_response) + candidates = [plan for plan in plans if is_adaptive_plan(plan)] + if not candidates: + print("No Garmin Coach/adaptive plan was identifiable in get_training_plans().") + if dump_json: + out = Path("debug") / "training_plans.json" + dump_redacted_json(out, plans_response) + print(f"Redacted training plan list written to {out}") + return 1 + + any_workout = False + for plan in candidates: + pid = plan_id(plan) + if pid is None: + print(f"Skipping plan without numeric ID: {plan.get('name')}") + continue + category = str(plan.get("trainingPlanCategory", "")).upper() + if category == "FBT_ADAPTIVE": + detail = api.get_adaptive_training_plan_by_id(pid) + else: + detail = api.get_training_plan_by_id(pid) + if dump_json: + out = Path("debug") / f"coach_plan_{pid}_{target_date.isoformat()}.json" + dump_redacted_json(out, detail) + print(f"Redacted plan detail written to {out}") + + workout = best_workout_for_date(detail, target_date) + if workout is None: + workout = _fetch_fbt_workout_for_date(api, detail, target_date) + date_matches = find_date_matches(detail, target_date) + workout_like = find_workout_like_objects(detail) + print(f"\nPlan {pid}: {plan.get('name')}") + print(f"Date matches for {target_date.isoformat()}: {len(date_matches)}") + print(f"Workout-like objects with workoutSegments/workoutSteps: {len(workout_like)}") + if workout: + any_workout = True + print(summarize_workout(workout)) + if dump_json: + out = Path("debug") / f"coach_workout_{target_date.isoformat()}.json" + dump_redacted_json(out, workout) + print(f"Redacted matched workout JSON written to {out}") + if target_date == date.today(): + today_out = Path("debug") / "coach_workout_today.json" + dump_redacted_json(today_out, workout) + print(f"Redacted matched workout JSON also written to {today_out}") + else: + print("No parseable workoutSegments/workoutSteps found for this date.") + + return 0 if any_workout else 1 + + +def command_coach_endpoints(target_date: date, dump_json: bool) -> int: + api = login() + plans = extract_training_plans(api.get_training_plans()) + candidates = [plan for plan in plans if is_adaptive_plan(plan)] + if not candidates: + print("No Garmin Coach/adaptive plan was identifiable in get_training_plans().") + return 1 + + report: dict[str, Any] = {"date": target_date.isoformat(), "plans": []} + found_workout_like = False + for plan in candidates: + pid = plan_id(plan) + if pid is None: + continue + detail = ( + api.get_adaptive_training_plan_by_id(pid) + if str(plan.get("trainingPlanCategory", "")).upper() == "FBT_ADAPTIVE" + else api.get_training_plan_by_id(pid) + ) + workout = task_workout_for_date(detail, target_date) + if workout is None: + print(f"Plan {pid}: no taskWorkout found for {target_date.isoformat()}.") + continue + + uuid = workout.get("workoutUuid") + calendar_id = _calendar_item_id_for_workout(api, target_date, pid, uuid) + endpoints = _coach_endpoint_candidates(pid, uuid, calendar_id, target_date) + plan_report: dict[str, Any] = { + "plan_id": pid, + "plan_name": plan.get("name"), + "workout_name": workout.get("workoutName"), + "workout_uuid": uuid, + "calendar_item_id": calendar_id, + "endpoints": [], + } + report["plans"].append(plan_report) + + print(f"\nPlan {pid}: {plan.get('name')}") + print(f"Workout: {workout.get('workoutName')} uuid={uuid} calendar_id={calendar_id}") + for endpoint in endpoints: + result = _capture_endpoint(api, endpoint) + workout_like_count = len(find_workout_like_objects(result)) + if workout_like_count: + found_workout_like = True + plan_report["endpoints"].append( + { + "endpoint": endpoint, + "result": result, + "workout_like_count": workout_like_count, + } + ) + status = "error" if _is_error(result) else "ok" + keys = ", ".join(result.keys()) if isinstance(result, dict) else type(result).__name__ + print(f" - {status}: {endpoint} workout_like={workout_like_count} keys={keys}") + + if dump_json: + out = Path("debug") / f"coach_endpoint_probe_{target_date.isoformat()}.json" + dump_redacted_json(out, report) + print(f"\nRedacted Coach endpoint probe written to {out}") + + if found_workout_like: + print("\nAt least one candidate endpoint returned workout-like step data.") + return 0 + print("\nNo candidate endpoint returned workoutSegments/workoutSteps.") + return 1 + + +def command_workouts(limit: int, dump_json: bool) -> int: + api = login() + workouts = api.get_workouts(limit=limit) + print(f"Normal workouts returned: {len(workouts)}") + for workout in workouts: + print( + " - id={id} sport={sport} name={name}".format( + id=workout.get("workoutId"), + sport=(workout.get("sportType") or {}).get("sportTypeKey"), + name=workout.get("workoutName"), + ) + ) + if dump_json: + out = Path("debug") / "workouts.json" + dump_redacted_json(out, workouts) + print(f"Redacted workout list written to {out}") + return 0 + + +def command_workout(workout_id: str, dump_json: bool) -> int: + api = login() + workout = api.get_workout_by_id(workout_id) + print(summarize_workout(workout)) + if dump_json: + out = Path("debug") / f"workout_{workout_id}.json" + dump_redacted_json(out, workout) + print(f"Redacted workout JSON written to {out}") + return 0 + + +def command_generated(prefix: str, limit: int, delete_id: str | None, confirm: bool) -> int: + api = login() + workouts = api.get_workouts(limit=limit) + generated = generated_workouts(workouts, prefix) + print(f"Generated workouts with prefix {prefix!r}: {len(generated)}") + for workout in generated: + print( + " - id={id} sport={sport} name={name}".format( + id=workout.get("workoutId") or workout.get("id"), + sport=(workout.get("sportType") or {}).get("sportTypeKey"), + name=workout.get("workoutName"), + ) + ) + + if delete_id is None: + return 0 + if not confirm: + print("\nRefusing to delete without --confirm.") + return 1 + + selected = find_generated_workout(workouts, delete_id, prefix) + if selected is None: + print(f"\nRefusing to delete {delete_id}: not found among generated workouts.") + return 1 + + name = selected.get("workoutName") + api.delete_workout(delete_id) + print(f"\nDeleted generated workout template {delete_id}: {name}") + return 0 + + +def command_generated_calendar( + target_date: date, + prefix: str, + unschedule_id: str | None, + dump_json: bool, + confirm: bool, +) -> int: + api = login() + calendar_data = api.get_scheduled_workouts(target_date.year, target_date.month) + generated = generated_calendar_entries(calendar_data, prefix) + print( + f"Generated calendar entries with prefix {prefix!r} in " + f"{target_date.year:04d}-{target_date.month:02d}: {len(generated)}" + ) + for entry in generated: + print( + " - scheduled_id={id} date={date} name={name}".format( + id=calendar_entry_id(entry) or "", + date=calendar_entry_date(entry) or "", + name=calendar_entry_name(entry) or "", + ) + ) + + if dump_json: + out = Path("debug") / f"generated_calendar_{target_date:%Y_%m}.json" + dump_redacted_json(out, calendar_data) + print(f"Redacted calendar JSON written to {out}") + + if unschedule_id is None: + return 0 + if not confirm: + print("\nRefusing to unschedule without --confirm.") + return 1 + + selected = find_generated_calendar_entry(calendar_data, unschedule_id, prefix) + if selected is None: + print( + f"\nRefusing to unschedule {unschedule_id}: " + "not found among generated calendar entries." + ) + return 1 + + api.unschedule_workout(unschedule_id) + print( + "\nUnscheduled generated calendar entry {id}: {name}".format( + id=unschedule_id, + name=calendar_entry_name(selected) or "", + ) + ) + return 0 + + +def command_dummy(target_date: date, dry_run: bool, schedule: bool) -> int: + payload = build_dummy_cycling_workout() + validation_errors = validate_workout_payload(payload) + if validation_errors: + print("Dummy workout payload failed local validation:") + for error in validation_errors: + print(f" - {error}") + return 1 + print("Dummy workout payload passed local validation.") + if dry_run or not schedule: + print("Dry run: dummy cycling workout JSON follows. Nothing was uploaded.") + print_json(payload) + if not schedule: + print("\nPass --schedule to upload and schedule this dummy workout.") + return 0 + + api = login() + result: dict[str, Any] = api.upload_workout(payload) + workout_id = result.get("workoutId") or result.get("id") + if workout_id is None: + print("Upload returned no workoutId. Raw response:") + print_json(result) + return 1 + schedule_result = api.schedule_workout(workout_id, target_date.isoformat()) + print(f"Uploaded dummy workout ID: {workout_id}") + print("Schedule response:") + print_json(schedule_result) + print_edge_check_instructions(target_date) + return 0 + + +def print_edge_check_instructions(target_date: date) -> None: + print( + f""" +Next checks on the Edge 1030 after Garmin Connect sync: + 1. Sync the Edge 1030 with Garmin Connect / Garmin Express. + 2. Check Training > Workouts for the dummy cycling workout. + 3. Check Training > Training Plan > calendar icon > {target_date.isoformat()}. + 4. Start a normal course/navigation route, then start the workout and verify both can run. +""" + ) + + +def _capture(label: str, report: dict[str, Any], func: Callable[[], Any]) -> Any: + try: + result = func() + except Exception as exc: # noqa: BLE001 - report should continue through partial failures + result = {"error": f"{type(exc).__name__}: {exc}"} + report[label] = result + return result + + +def _capture_endpoint(api: Any, endpoint: str) -> Any: + try: + if endpoint.startswith("/gc-api/"): + return gc_api_get(api, endpoint) + return api.connectapi(endpoint) + except Exception as exc: # noqa: BLE001 - endpoint probing should continue + return {"error": f"{type(exc).__name__}: {exc}"} + + +def _calendar_item_id_for_workout( + api: Any, target_date: date, plan_id_value: int, workout_uuid: Any +) -> str | None: + if workout_uuid is None: + return None + calendar_data = _capture_endpoint( + api, f"/calendar-service/year/{target_date.year}/month/{target_date.month - 1}" + ) + if _is_error(calendar_data): + return None + for match in find_date_matches(calendar_data, target_date): + if ( + match.get("itemType") == "fbtAdaptiveWorkout" + and str(match.get("trainingPlanId")) == str(plan_id_value) + and str(match.get("workoutUuid")) == str(workout_uuid) + ): + item_id = match.get("id") + return str(item_id) if item_id is not None else None + return None + + +def _coach_endpoint_candidates( + plan_id_value: int, workout_uuid: Any, calendar_item_id: str | None, target_date: date +) -> list[str]: + endpoints = [ + f"/gc-api/workout-service/fbt-adaptive/{workout_uuid}" + if workout_uuid is not None + else "", + f"/trainingplan-service/trainingplan/fbt-adaptive/{plan_id_value}", + f"/trainingplan-service/trainingplan/fbt-adaptive/{plan_id_value}/workouts", + f"/trainingplan-service/trainingplan/fbt-adaptive/{plan_id_value}/workouts/{target_date.isoformat()}", + f"/trainingplan-service/trainingplan/fbt-adaptive/{plan_id_value}/tasks", + f"/trainingplan-service/trainingplan/fbt-adaptive/{plan_id_value}/tasks/{target_date.isoformat()}", + ] + endpoints = [endpoint for endpoint in endpoints if endpoint] + if workout_uuid is not None: + uuid = str(workout_uuid) + endpoints.extend( + [ + f"/workout-service/fbt-adaptive/{uuid}", + f"/trainingplan-service/trainingplan/fbt-adaptive/{plan_id_value}/workout/{uuid}", + f"/trainingplan-service/trainingplan/fbt-adaptive/{plan_id_value}/workouts/{uuid}", + f"/trainingplan-service/trainingplan/fbt-adaptive/{plan_id_value}/task/{uuid}", + f"/trainingplan-service/trainingplan/fbt-adaptive/{plan_id_value}/tasks/{uuid}", + f"/trainingplan-service/trainingplan/fbt-adaptive/workout/{uuid}", + f"/trainingplan-service/trainingplan/fbt-adaptive/workouts/{uuid}", + f"/trainingplan-service/trainingplan/workout/{uuid}", + f"/workout-service/workout/{uuid}", + f"/workout-service/workouts/{uuid}", + f"/workout-service/workout/uuid/{uuid}", + f"/workout-service/workouts/uuid/{uuid}", + ] + ) + if calendar_item_id is not None: + endpoints.extend( + [ + f"/workout-service/schedule/{calendar_item_id}", + f"/calendar-service/{calendar_item_id}", + f"/calendar-service/calendar/{calendar_item_id}", + f"/calendar-service/event/{calendar_item_id}", + ] + ) + return list(dict.fromkeys(endpoints)) + + +def _fetch_fbt_workout_for_date( + api: Any, plan_detail: Any, target_date: date +) -> dict[str, Any] | None: + workout = task_workout_for_date(plan_detail, target_date) + if workout is None: + return None + workout_uuid = workout.get("workoutUuid") + if not workout_uuid: + return None + try: + return get_fbt_adaptive_workout(api, str(workout_uuid)) + except Exception as exc: # noqa: BLE001 - shown as probe output + print( + f"FBT adaptive workout detail fetch failed for {workout_uuid}: " + f"{type(exc).__name__}: {exc}" + ) + return None + + +def _is_error(value: Any) -> bool: + return isinstance(value, dict) and isinstance(value.get("error"), str) + + +def _print_section(title: str) -> None: + print(f"\n== {title} ==") + + +def _plan_summary(plan: dict[str, Any]) -> dict[str, Any]: + return { + "id": plan.get("trainingPlanId") or plan.get("planId") or plan.get("id"), + "name": plan.get("name"), + "category": plan.get("trainingPlanCategory"), + } + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/garmin_coach_clone/__init__.py b/src/garmin_coach_clone/__init__.py new file mode 100644 index 0000000..13faaf2 --- /dev/null +++ b/src/garmin_coach_clone/__init__.py @@ -0,0 +1,6 @@ +"""Garmin Coach clone probe package.""" + +__all__ = ["__version__"] + +__version__ = "0.1.0" + diff --git a/src/garmin_coach_clone/app.py b/src/garmin_coach_clone/app.py new file mode 100644 index 0000000..64817c1 --- /dev/null +++ b/src/garmin_coach_clone/app.py @@ -0,0 +1,599 @@ +from __future__ import annotations + +import asyncio +import json +from contextlib import asynccontextmanager +from datetime import date, datetime, timedelta +from typing import Any +from zoneinfo import ZoneInfo + +from fastapi import Depends, FastAPI, Form, HTTPException, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.responses import Response as FastAPIResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from garminconnect import ( + GarminConnectAuthenticationError, + GarminConnectConnectionError, + GarminConnectTooManyRequestsError, +) + +from .coach import extract_training_plans, is_adaptive_plan, task_for_date +from .config import load_settings +from .crypto import Crypto +from .db import Database +from .garmin_service import GarminService +from .repository import Repository, ScheduleConfig, validate_schedule_config +from .sync_service import SyncService, next_run_after +from .web_auth import clear_session, create_session, current_user, require_auth +from .workouts import calendar_entry_date, calendar_entry_name, summarize_workout + + +def create_app() -> FastAPI: + settings = load_settings() + for path in (settings.data_dir, settings.token_dir, settings.log_dir, settings.debug_dir): + path.mkdir(parents=True, exist_ok=True) + db = Database(settings.db_path) + db.initialize() + repo = Repository(db, settings) + crypto = Crypto(settings.app_secret_key) + _bootstrap_env_user(repo, crypto, settings) + garmin = GarminService(settings, repo, crypto) + sync = SyncService(settings, repo, garmin) + templates = Jinja2Templates(directory=settings.template_dir) + + @asynccontextmanager + async def lifespan(_app: FastAPI): + sync.start() + try: + yield + finally: + await sync.stop() + + app = FastAPI(title="Garmin Coach Clone", lifespan=lifespan) + app.state.settings = settings + app.state.repo = repo + app.state.crypto = crypto + app.state.garmin = garmin + app.state.sync = sync + app.state.templates = templates + + if settings.static_dir.exists(): + app.mount("/static", StaticFiles(directory=settings.static_dir), name="static") + + @app.get("/healthz") + def healthz() -> dict[str, Any]: + return { + "ok": True, + "app_configured": repo.app_configured(), + "garmin_configured": repo.garmin_configured(), + "schedule": repo.schedule_config().__dict__, + } + + @app.get("/", response_class=HTMLResponse) + def index(request: Request) -> Any: + if not repo.app_configured(): + return RedirectResponse("/setup", status_code=303) + if current_user(request) is None: + return RedirectResponse("/login", status_code=303) + status = repo.sync_status() + schedule = repo.schedule_config() + next_run = next_run_after(datetime.now(ZoneInfo(settings.timezone)), schedule) + return templates.TemplateResponse( + request, + "dashboard.html", + { + "request": request, + "user": current_user(request), + "status": status, + "garmin_configured": repo.garmin_configured(), + "schedule": schedule, + "next_run": next_run, + "today": date.today().isoformat(), + "tomorrow": (date.today() + timedelta(days=1)).isoformat(), + }, + ) + + @app.get("/setup", response_class=HTMLResponse) + def setup_page(request: Request) -> Any: + if repo.app_configured(): + return RedirectResponse("/login", status_code=303) + return templates.TemplateResponse( + request, "setup.html", {"request": request, "error": None} + ) + + @app.post("/setup") + def setup_submit( + request: Request, + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), + ) -> Any: + if repo.app_configured(): + return RedirectResponse("/login", status_code=303) + username = username.strip() + if len(username) < 2: + return _render_setup_error(request, "Username must be at least 2 characters.") + if len(password) < 8: + return _render_setup_error(request, "Password must be at least 8 characters.") + if password != confirm_password: + return _render_setup_error(request, "Passwords do not match.") + repo.bootstrap_app_user(username, crypto.hash_password(password)) + redirect = RedirectResponse("/", status_code=303) + create_session(redirect, settings, crypto, username) + return redirect + + @app.get("/login", response_class=HTMLResponse) + def login_page(request: Request) -> Any: + if not repo.app_configured(): + return RedirectResponse("/setup", status_code=303) + return templates.TemplateResponse( + request, "login.html", {"request": request, "error": None} + ) + + @app.post("/login") + def login_submit( + request: Request, + username: str = Form(...), + password: str = Form(...), + ) -> Any: + stored_user = repo.app_username() + stored_hash = repo.app_password_hash() + if ( + stored_user is None + or stored_hash is None + or username != stored_user + or not crypto.verify_password(password, stored_hash) + ): + return templates.TemplateResponse( + request, + "login.html", + {"request": request, "error": "Invalid username or password."}, + status_code=401, + ) + redirect = RedirectResponse("/", status_code=303) + create_session(redirect, settings, crypto, username) + return redirect + + @app.post("/logout") + def logout() -> Any: + redirect = RedirectResponse("/login", status_code=303) + clear_session(redirect, settings) + return redirect + + @app.get("/garmin", response_class=HTMLResponse, dependencies=[Depends(require_auth)]) + def garmin_page(request: Request) -> Any: + return templates.TemplateResponse( + request, + "garmin.html", + { + "request": request, + "configured": garmin.configured(), + "mfa_pending": bool(repo.get_setting("garmin_mfa_state")), + "error": None, + "message": None, + }, + ) + + @app.post("/garmin/setup", dependencies=[Depends(require_auth)]) + async def garmin_setup( + request: Request, email: str = Form(...), password: str = Form(...) + ) -> Any: + garmin.save_credentials(email.strip(), password) + try: + result = await asyncio.to_thread(garmin.setup_login) + return templates.TemplateResponse( + request, + "garmin.html", + { + "request": request, + "configured": result["status"] == "ok", + "mfa_pending": result["status"] == "mfa_required", + "error": None, + "message": ( + "MFA required." + if result["status"] == "mfa_required" + else "Garmin login OK." + ), + }, + ) + except (GarminConnectAuthenticationError, GarminConnectTooManyRequestsError) as exc: + return _garmin_error(request, str(exc), 401) + except GarminConnectConnectionError as exc: + return _garmin_error(request, str(exc), 502) + + @app.post("/garmin/mfa", dependencies=[Depends(require_auth)]) + async def garmin_mfa(request: Request, code: str = Form(...)) -> Any: + try: + await asyncio.to_thread(garmin.resume_mfa, code.strip()) + return templates.TemplateResponse( + request, + "garmin.html", + { + "request": request, + "configured": True, + "mfa_pending": False, + "error": None, + "message": "Garmin MFA completed.", + }, + ) + except (ValueError, GarminConnectAuthenticationError) as exc: + return _garmin_error(request, str(exc), 400) + + @app.post("/sync/run", dependencies=[Depends(require_auth)]) + async def sync_run(date_value: str = Form(""), dry_run: str | None = Form(None)) -> Any: + dates = None + if date_value: + dates = [date.fromisoformat(date_value)] + await sync.run_change_detection("manual", target_dates=dates, dry_run=dry_run == "on") + return RedirectResponse("/", status_code=303) + + @app.get("/schedule", response_class=HTMLResponse, dependencies=[Depends(require_auth)]) + def schedule_page(request: Request) -> Any: + return templates.TemplateResponse( + request, + "schedule.html", + { + "request": request, + "schedule": repo.schedule_config(), + "error": None, + "message": None, + }, + ) + + @app.post("/schedule", dependencies=[Depends(require_auth)]) + def schedule_save( + request: Request, + enabled: str | None = Form(None), + interval_minutes: int = Form(...), + active_window: str = Form(...), + fixed_times: str = Form(...), + days_ahead: int = Form(...), + ) -> Any: + config = ScheduleConfig( + enabled=enabled == "on", + interval_minutes=interval_minutes, + active_window=active_window.strip(), + fixed_times=[item.strip() for item in fixed_times.split(",") if item.strip()], + days_ahead=days_ahead, + ) + try: + validate_schedule_config(config) + repo.save_schedule_config(config) + message = "Schedule saved." + error = None + except ValueError as exc: + message = None + error = str(exc) + return templates.TemplateResponse( + request, + "schedule.html", + {"request": request, "schedule": config, "error": error, "message": message}, + status_code=400 if error else 200, + ) + + @app.post("/schedule/restore", dependencies=[Depends(require_auth)]) + def schedule_restore() -> Any: + repo.restore_default_schedule() + return RedirectResponse("/schedule", status_code=303) + + @app.get("/search", response_class=HTMLResponse, dependencies=[Depends(require_auth)]) + async def search_page( + request: Request, + start: str = "", + end: str = "", + sport: str = "", + q: str = "", + source: str = "all", + ) -> Any: + today = date.today() + start_date = date.fromisoformat(start) if start else today + end_date = date.fromisoformat(end) if end else today + timedelta(days=1) + items: list[dict[str, Any]] = [] + error = None + try: + items = await asyncio.to_thread( + _search_items, garmin, repo, start_date, end_date, sport, q, source + ) + except Exception as exc: + error = str(exc) + return templates.TemplateResponse( + request, + "search.html", + { + "request": request, + "items": items, + "error": error, + "filters": { + "start": start_date.isoformat(), + "end": end_date.isoformat(), + "sport": sport, + "q": q, + "source": source, + }, + }, + ) + + @app.get("/logs", response_class=HTMLResponse, dependencies=[Depends(require_auth)]) + def logs_page(request: Request) -> Any: + return templates.TemplateResponse( + request, "logs.html", {"request": request, "events": repo.list_events()} + ) + + @app.get("/traces", response_class=HTMLResponse, dependencies=[Depends(require_auth)]) + def traces_page(request: Request) -> Any: + return templates.TemplateResponse( + request, "traces.html", {"request": request, "traces": repo.list_traces()} + ) + + @app.get( + "/traces/{trace_id}", + response_class=HTMLResponse, + dependencies=[Depends(require_auth)], + ) + def trace_detail(request: Request, trace_id: int) -> Any: + trace = repo.get_trace(trace_id) + if trace is None: + raise HTTPException(status_code=404, detail="Trace not found") + return templates.TemplateResponse( + request, + "trace.html", + {"request": request, "trace": trace, "json": _pretty_trace_json(trace)}, + ) + + @app.get("/traces/{trace_id}/{kind}.json", dependencies=[Depends(require_auth)]) + def trace_json(trace_id: int, kind: str) -> FastAPIResponse: + trace = repo.get_trace(trace_id) + if trace is None or kind not in {"source", "payload", "result"}: + raise HTTPException(status_code=404, detail="Trace JSON not found") + value = trace.get(f"{kind}_json") + if not value: + raise HTTPException(status_code=404, detail="Trace JSON not found") + return FastAPIResponse(value, media_type="application/json") + + def _render_setup_error(request: Request, error: str) -> Any: + return templates.TemplateResponse( + request, + "setup.html", + {"request": request, "error": error}, + status_code=400, + ) + + def _garmin_error(request: Request, error: str, status_code: int) -> Any: + return templates.TemplateResponse( + request, + "garmin.html", + { + "request": request, + "configured": garmin.configured(), + "mfa_pending": bool(repo.get_setting("garmin_mfa_state")), + "error": error, + "message": None, + }, + status_code=status_code, + ) + + return app + + +def _bootstrap_env_user(repo: Repository, crypto: Crypto, settings: Any) -> None: + if repo.app_configured(): + return + if settings.app_username and settings.app_password and settings.app_password != "change-me": + repo.bootstrap_app_user(settings.app_username, crypto.hash_password(settings.app_password)) + + +def _pretty_trace_json(trace: dict[str, Any]) -> dict[str, str]: + result = {} + for key in ("source_json", "payload_json", "result_json"): + raw = trace.get(key) + if raw: + result[key] = json.dumps(json.loads(raw), indent=2, sort_keys=True) + return result + + +def _search_items( + garmin: GarminService, + repo: Repository, + start: date, + end: date, + sport: str, + q: str, + source: str, +) -> list[dict[str, Any]]: + if end < start: + raise ValueError("End date must be on or after start date") + sport_filter = sport.strip().lower() + text_filter = q.strip().lower() + allowed_sources = {"all", "calendar", "workouts", "plans", "coach", "cloned"} + if source not in allowed_sources: + source = "all" + + items: list[dict[str, Any]] = [] + client = garmin.authenticated_client() + if source in {"all", "calendar"}: + items.extend(_calendar_search_items(client, start, end)) + if source in {"all", "workouts"}: + items.extend(_workout_search_items(client)) + if source in {"all", "plans", "coach"}: + plans = extract_training_plans(client.get_training_plans()) + if source in {"all", "plans"}: + items.extend(_plan_search_items(plans)) + if source in {"all", "coach"}: + items.extend(_coach_search_items(garmin, client, plans, start, end)) + if source in {"all", "cloned"}: + items.extend(_clone_search_items(repo)) + + return [ + item + for item in items + if _matches_search_item(item, sport_filter, text_filter, start, end) + ] + + +def _calendar_search_items(client: Any, start: date, end: date) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + for month in _months_between(start, end): + data = client.get_scheduled_workouts(month.year, month.month) + _walk_calendar(data, items) + return items + + +def _walk_calendar(node: Any, items: list[dict[str, Any]]) -> None: + if isinstance(node, dict): + name = calendar_entry_name(node) + item_date = calendar_entry_date(node) + if name and item_date: + items.append( + { + "source": "calendar", + "date": item_date, + "sport": _sport_key(node), + "name": name, + "status": "scheduled", + "summary": "", + } + ) + for child in node.values(): + _walk_calendar(child, items) + elif isinstance(node, list): + for child in node: + _walk_calendar(child, items) + + +def _workout_search_items(client: Any) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + for workout in client.get_workouts(limit=100): + if not isinstance(workout, dict): + continue + items.append( + { + "source": "workouts", + "date": "", + "sport": _sport_key(workout), + "name": workout.get("workoutName") or workout.get("name") or "", + "status": str(workout.get("workoutId") or workout.get("id") or ""), + "summary": summarize_workout(workout) if workout.get("workoutSegments") else "", + } + ) + return items + + +def _plan_search_items(plans: list[dict[str, Any]]) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + for plan in plans: + items.append( + { + "source": "plans", + "date": "", + "sport": str((plan.get("trainingType") or {}).get("typeKey") or ""), + "name": plan.get("name") or "", + "status": plan.get("trainingPlanCategory") or "", + "summary": f"Plan ID: {plan.get('trainingPlanId') or plan.get('id') or '-'}", + } + ) + return items + + +def _coach_search_items( + garmin: GarminService, + client: Any, + plans: list[dict[str, Any]], + start: date, + end: date, +) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + for plan in plans: + if not is_adaptive_plan(plan): + continue + detail = garmin.adaptive_plan_detail(client, plan) + for target_date in _dates_between(start, end): + task = task_for_date(detail, target_date) + if task is None: + continue + workout = task.get("taskWorkout") + name = "" + status = "rest" + uuid = "" + if isinstance(workout, dict): + name = str(workout.get("workoutName") or workout.get("name") or "Coach Workout") + uuid = str(workout.get("workoutUuid") or "") + status = "workout" if uuid else "task" + items.append( + { + "source": "coach", + "date": target_date.isoformat(), + "sport": "cycling", + "name": name, + "status": status, + "summary": uuid, + } + ) + return items + + +def _clone_search_items(repo: Repository) -> list[dict[str, Any]]: + return [ + { + "source": "cloned", + "date": str(clone.get("scheduled_date") or ""), + "sport": "cycling", + "name": clone.get("clone_workout_name") or clone.get("source_name") or "", + "status": clone.get("status") or "", + "summary": clone.get("message") or "", + } + for clone in repo.list_clone_mappings(limit=100) + ] + + +def _matches_search_item( + item: dict[str, Any], + sport_filter: str, + text_filter: str, + start: date, + end: date, +) -> bool: + item_date = str(item.get("date") or "") + if item_date: + parsed = date.fromisoformat(item_date[:10]) + if parsed < start or parsed > end: + return False + if sport_filter and sport_filter not in str(item.get("sport") or "").lower(): + return False + haystack = " ".join( + str(item.get(key) or "") for key in ("source", "name", "status", "summary") + ).lower() + return not text_filter or text_filter in haystack + + +def _months_between(start: date, end: date) -> list[date]: + cursor = date(start.year, start.month, 1) + last = date(end.year, end.month, 1) + months = [] + while cursor <= last: + months.append(cursor) + if cursor.month == 12: + cursor = date(cursor.year + 1, 1, 1) + else: + cursor = date(cursor.year, cursor.month + 1, 1) + return months + + +def _dates_between(start: date, end: date) -> list[date]: + return [start + timedelta(days=offset) for offset in range((end - start).days + 1)] + + +def _sport_key(value: dict[str, Any]) -> str: + sport = value.get("sportType") + if isinstance(sport, dict): + return str(sport.get("sportTypeKey") or sport.get("typeKey") or "") + for nested_key in ("workout", "workoutDTO", "workoutSummary"): + nested = value.get(nested_key) + if isinstance(nested, dict): + found = _sport_key(nested) + if found: + return found + return str(value.get("sport") or "") + + +app = create_app() diff --git a/src/garmin_coach_clone/auth.py b/src/garmin_coach_clone/auth.py new file mode 100644 index 0000000..df84679 --- /dev/null +++ b/src/garmin_coach_clone/auth.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import os +from getpass import getpass +from pathlib import Path +from typing import Any + +from garminconnect import Garmin, GarminConnectAuthenticationError, GarminConnectConnectionError + + +def credentials_from_env_or_prompt() -> tuple[str, str]: + email = os.getenv("GARMIN_EMAIL") or input("Garmin email: ").strip() + password = os.getenv("GARMIN_PASSWORD") or getpass("Garmin password: ") + if not email or not password: + raise GarminConnectAuthenticationError("Garmin email/password are required") + return email, password + + +def token_store_path() -> Path: + return Path(os.getenv("GARMIN_TOKEN_STORE", ".garmin-tokens")).expanduser() + + +def _dump_tokens(api: Garmin, tokenstore: Path) -> None: + tokenstore.mkdir(parents=True, exist_ok=True) + dump = getattr(getattr(api, "client", None), "dump", None) + if callable(dump): + dump(str(tokenstore)) + + +def login(interactive: bool = True) -> Garmin: + tokenstore = token_store_path() + + try: + api = Garmin() + api.login(str(tokenstore)) + return api + except (FileNotFoundError, GarminConnectAuthenticationError, GarminConnectConnectionError): + pass + + email, password = credentials_from_env_or_prompt() + api = Garmin(email=email, password=password, is_cn=False, return_on_mfa=True) + result, state = api.login() + if result == "needs_mfa": + if not interactive: + raise GarminConnectAuthenticationError("MFA required but interactive input is disabled") + code = getpass("Garmin MFA code: ").strip() + if not code: + raise GarminConnectAuthenticationError("MFA code is required") + api.resume_login(state if isinstance(state, dict) else {}, code) + + _dump_tokens(api, tokenstore) + + # Re-open through the normal path so profile/settings loading follows the package path. + verified = Garmin(email=email, password=password, is_cn=False) + verified.login(str(tokenstore)) + return verified + + +def account_summary(api: Garmin) -> dict[str, Any]: + summary: dict[str, Any] = { + "full_name": _safe_call(api.get_full_name), + "unit_system": _safe_call(api.get_unit_system), + } + devices = _safe_call(api.get_devices) + if isinstance(devices, list): + summary["devices"] = [ + { + "displayName": device.get("displayName"), + "deviceType": device.get("deviceType"), + "softwareVersion": device.get("softwareVersion"), + "unitId": device.get("unitId"), + } + for device in devices + if isinstance(device, dict) + ] + else: + summary["devices_error"] = devices + summary["last_used_device"] = _safe_call(api.get_device_last_used) + return summary + + +def _safe_call(func: Any) -> Any: + try: + return func() + except Exception as exc: # noqa: BLE001 - displayed as probe output + return f"" + diff --git a/src/garmin_coach_clone/coach.py b/src/garmin_coach_clone/coach.py new file mode 100644 index 0000000..b90f696 --- /dev/null +++ b/src/garmin_coach_clone/coach.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +import os +from collections.abc import Iterable +from datetime import date +from typing import Any + +from garminconnect import GarminConnectConnectionError + + +def extract_training_plans(response: Any) -> list[dict[str, Any]]: + if isinstance(response, dict): + plans = response.get("trainingPlanList") + if isinstance(plans, list): + return [plan for plan in plans if isinstance(plan, dict)] + if isinstance(response, list): + return [plan for plan in response if isinstance(plan, dict)] + return [] + + +def is_adaptive_plan(plan: dict[str, Any]) -> bool: + category = str(plan.get("trainingPlanCategory", "")).upper() + name = str(plan.get("name", "")).lower() + return category == "FBT_ADAPTIVE" or "coach" in name or "adaptive" in name + + +def plan_id(plan: dict[str, Any]) -> int | None: + for key in ("trainingPlanId", "planId", "id"): + value = plan.get(key) + if value is not None: + try: + return int(value) + except (TypeError, ValueError): + pass + return None + + +def find_date_matches(value: Any, target_date: date) -> list[dict[str, Any]]: + needle = target_date.isoformat() + matches: list[dict[str, Any]] = [] + + def walk(node: Any, ancestors: list[dict[str, Any]]) -> None: + if isinstance(node, dict): + next_ancestors = [*ancestors, node] + if _contains_date(node, needle): + matches.append(node) + for child in node.values(): + walk(child, next_ancestors) + elif isinstance(node, list): + for child in node: + walk(child, ancestors) + + walk(value, []) + return _dedupe_dicts(matches) + + +def find_workout_like_objects(value: Any) -> list[dict[str, Any]]: + found: list[dict[str, Any]] = [] + + def walk(node: Any) -> None: + if isinstance(node, dict): + if isinstance(node.get("workoutSegments"), list) or isinstance( + node.get("workoutSteps"), list + ): + found.append(node) + for child in node.values(): + walk(child) + elif isinstance(node, list): + for child in node: + walk(child) + + walk(value) + return _dedupe_dicts(found) + + +def best_workout_for_date(value: Any, target_date: date) -> dict[str, Any] | None: + date_matches = find_date_matches(value, target_date) + for match in date_matches: + if isinstance(match.get("workoutSegments"), list): + return match + nested = find_workout_like_objects(match) + if nested: + return nested[0] + return None + + +def task_for_date(plan_detail: Any, target_date: date) -> dict[str, Any] | None: + if not isinstance(plan_detail, dict): + return None + tasks = plan_detail.get("taskList") + if not isinstance(tasks, list): + return None + for task in tasks: + if not isinstance(task, dict): + continue + if str(task.get("calendarDate", ""))[:10] == target_date.isoformat(): + return task + return None + + +def task_workout_for_date(plan_detail: Any, target_date: date) -> dict[str, Any] | None: + task = task_for_date(plan_detail, target_date) + if task is None: + return None + workout = task.get("taskWorkout") + return workout if isinstance(workout, dict) else None + + +def coach_workout_uuid_for_date(plan_detail: Any, target_date: date) -> str | None: + workout = task_workout_for_date(plan_detail, target_date) + if workout is None: + return None + uuid = workout.get("workoutUuid") + return str(uuid) if uuid else None + + +def get_fbt_adaptive_workout(api: Any, workout_uuid: str) -> dict[str, Any]: + response = api.connectapi(f"/workout-service/fbt-adaptive/{workout_uuid}") + if not isinstance(response, dict): + raise GarminConnectConnectionError( + f"FBT adaptive workout endpoint returned {type(response).__name__}, expected object" + ) + return response + + +def describe_plans(plans: Iterable[dict[str, Any]]) -> str: + lines: list[str] = [] + for plan in plans: + lines.append( + " - id={id} category={category} name={name}".format( + id=plan.get("trainingPlanId") or plan.get("planId") or plan.get("id"), + category=plan.get("trainingPlanCategory"), + name=plan.get("name"), + ) + ) + return "\n".join(lines) if lines else "No training plans returned." + + +def _contains_date(node: dict[str, Any], needle: str) -> bool: + return any(needle in str(key) or needle in str(value) for key, value in node.items()) + + +def _dedupe_dicts(items: list[dict[str, Any]]) -> list[dict[str, Any]]: + seen: set[int] = set() + deduped: list[dict[str, Any]] = [] + for item in items: + marker = id(item) + if marker not in seen: + seen.add(marker) + deduped.append(item) + return deduped + + +def gc_api_get(api: Any, path: str) -> Any: + client = getattr(api, "client", None) + session = getattr(client, "_api_session", None) + if client is None or session is None: + raise GarminConnectConnectionError("Garmin client does not expose an API session") + + url = f"https://connect.garmin.com/{path.lstrip('/')}" + env_cookie = os.getenv("GARMIN_GC_API_COOKIE") + if env_cookie: + env_headers = { + "Accept": "application/json", + "NK": "NT", + "Origin": "https://connect.garmin.com", + "Referer": "https://connect.garmin.com/modern/", + "Cookie": env_cookie, + } + env_csrf = os.getenv("GARMIN_GC_API_CSRF") + if env_csrf: + env_headers["connect-csrf-token"] = env_csrf + response = session.get(url, headers=env_headers, timeout=15) + if response.status_code < 400: + return response.json() + + headers = dict(client.get_api_headers()) + response = session.get(url, headers=headers, timeout=15) + if response.status_code == 403 and getattr(client, "jwt_web", None): + web_headers = { + "Accept": "application/json", + "NK": "NT", + "Origin": "https://connect.garmin.com", + "Referer": "https://connect.garmin.com/modern/", + "Cookie": f"JWT_WEB={client.jwt_web}", + } + csrf_token = getattr(client, "csrf_token", None) + if csrf_token: + web_headers["connect-csrf-token"] = str(csrf_token) + response = session.get(url, headers=web_headers, timeout=15) + + if response.status_code >= 400: + message = f"GC API Error {response.status_code}" + if len(response.text) < 500: + message += f" - {response.text}" + raise GarminConnectConnectionError(message) + return response.json() diff --git a/src/garmin_coach_clone/config.py b/src/garmin_coach_clone/config.py new file mode 100644 index 0000000..8668cff --- /dev/null +++ b/src/garmin_coach_clone/config.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + +DEFAULT_FIXED_TIMES = [ + "05:15", + "06:15", + "07:15", + "08:15", + "09:15", + "10:15", + "11:15", + "12:15", + "13:15", +] +DEFAULT_ACTIVE_WINDOW = "05:00-22:00" +DEFAULT_INTERVAL_MINUTES = 30 + + +def _project_root() -> Path: + return Path(__file__).resolve().parents[2] + + +@dataclass(frozen=True) +class Settings: + data_dir: Path + db_path: Path + token_dir: Path + log_dir: Path + debug_dir: Path + static_dir: Path + template_dir: Path + app_username: str + app_password: str + app_secret_key: str + timezone: str + clone_prefix: str + sync_enabled: bool + sync_days_ahead: int + overwrite_existing: bool + delete_old_clones: bool + change_interval_minutes: int + change_active_window: str + change_fixed_times: list[str] + log_level: str + debug_dump_json: bool + session_cookie: str = "gcc_session" + session_ttl_seconds: int = 60 * 60 * 24 * 30 + + +def load_settings() -> Settings: + data_dir = Path(os.getenv("DATA_DIR", "/data")).expanduser() + if not os.access(str(data_dir.parent), os.W_OK): + data_dir = _project_root() / "data" + + fixed = os.getenv("CHANGE_DETECTION_FIXED_TIMES", ",".join(DEFAULT_FIXED_TIMES)) + return Settings( + data_dir=data_dir, + db_path=Path(os.getenv("DB_PATH", str(data_dir / "app.db"))).expanduser(), + token_dir=Path(os.getenv("GARMIN_TOKEN_DIR", str(data_dir / "garmin-tokens"))).expanduser(), + log_dir=Path(os.getenv("LOG_DIR", str(data_dir / "logs"))).expanduser(), + debug_dir=Path(os.getenv("DEBUG_DIR", str(data_dir / "debug"))).expanduser(), + static_dir=Path(os.getenv("STATIC_DIR", str(_project_root() / "static"))).expanduser(), + template_dir=Path( + os.getenv("TEMPLATE_DIR", str(_project_root() / "templates")) + ).expanduser(), + app_username=os.getenv("APP_USERNAME", "admin"), + app_password=os.getenv("APP_PASSWORD", "change-me"), + app_secret_key=os.getenv("APP_SECRET_KEY", "replace-with-long-random-secret"), + timezone=os.getenv("TZ", "Europe/Berlin"), + clone_prefix=os.getenv("CLONE_PREFIX", "GCClone"), + sync_enabled=_bool_env("SYNC_ENABLED", True), + sync_days_ahead=max(0, int(os.getenv("SYNC_DAYS_AHEAD", "1"))), + overwrite_existing=_bool_env("OVERWRITE_EXISTING", True), + delete_old_clones=_bool_env("DELETE_OLD_CLONES", False), + change_interval_minutes=max( + 5, int(os.getenv("CHANGE_DETECTION_INTERVAL_MINUTES", str(DEFAULT_INTERVAL_MINUTES))) + ), + change_active_window=os.getenv("CHANGE_DETECTION_ACTIVE_WINDOW", DEFAULT_ACTIVE_WINDOW), + change_fixed_times=[item.strip() for item in fixed.split(",") if item.strip()], + log_level=os.getenv("LOG_LEVEL", "INFO"), + debug_dump_json=_bool_env("DEBUG_DUMP_JSON", False), + ) + + +def _bool_env(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} diff --git a/src/garmin_coach_clone/crypto.py b/src/garmin_coach_clone/crypto.py new file mode 100644 index 0000000..d9345bf --- /dev/null +++ b/src/garmin_coach_clone/crypto.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import os +import time +from typing import Any + +from cryptography.fernet import Fernet + + +def _fernet_key(secret: str) -> bytes: + digest = hashlib.sha256(secret.encode("utf-8")).digest() + return base64.urlsafe_b64encode(digest) + + +class Crypto: + def __init__(self, secret: str) -> None: + self._secret = secret.encode("utf-8") + self._fernet = Fernet(_fernet_key(secret)) + + def encrypt(self, value: str) -> str: + return self._fernet.encrypt(value.encode("utf-8")).decode("ascii") + + def decrypt(self, value: str) -> str: + return self._fernet.decrypt(value.encode("ascii")).decode("utf-8") + + def hash_password(self, password: str) -> str: + salt = os.urandom(16) + digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 210_000) + return "pbkdf2_sha256${}${}".format( + base64.urlsafe_b64encode(salt).decode("ascii"), + base64.urlsafe_b64encode(digest).decode("ascii"), + ) + + def verify_password(self, password: str, encoded: str) -> bool: + try: + scheme, salt_s, digest_s = encoded.split("$", 2) + except ValueError: + return False + if scheme != "pbkdf2_sha256": + return False + salt = base64.urlsafe_b64decode(salt_s.encode("ascii")) + expected = base64.urlsafe_b64decode(digest_s.encode("ascii")) + actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 210_000) + return hmac.compare_digest(expected, actual) + + def sign_session(self, payload: dict[str, Any], ttl_seconds: int) -> str: + body = dict(payload) + body["exp"] = int(time.time()) + ttl_seconds + encoded = base64.urlsafe_b64encode( + json.dumps(body, separators=(",", ":"), sort_keys=True).encode("utf-8") + ).decode("ascii") + sig = hmac.new(self._secret, encoded.encode("ascii"), hashlib.sha256).hexdigest() + return f"{encoded}.{sig}" + + def verify_session(self, token: str) -> dict[str, Any] | None: + try: + encoded, sig = token.split(".", 1) + except ValueError: + return None + expected = hmac.new(self._secret, encoded.encode("ascii"), hashlib.sha256).hexdigest() + if not hmac.compare_digest(expected, sig): + return None + try: + payload = json.loads(base64.urlsafe_b64decode(encoded.encode("ascii"))) + except (ValueError, json.JSONDecodeError): + return None + if int(payload.get("exp", 0)) < int(time.time()): + return None + return payload if isinstance(payload, dict) else None diff --git a/src/garmin_coach_clone/dates.py b/src/garmin_coach_clone/dates.py new file mode 100644 index 0000000..f93c04b --- /dev/null +++ b/src/garmin_coach_clone/dates.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from datetime import date, timedelta + + +def parse_date(value: str | None) -> date: + if value in (None, "", "today"): + return date.today() + if value == "tomorrow": + return date.today() + timedelta(days=1) + return date.fromisoformat(value) + diff --git a/src/garmin_coach_clone/db.py b/src/garmin_coach_clone/db.py new file mode 100644 index 0000000..ddde3ee --- /dev/null +++ b/src/garmin_coach_clone/db.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import sqlite3 +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path +from typing import Any + +SCHEMA = """ +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS clone_mappings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scheduled_date TEXT NOT NULL, + source_uuid TEXT, + source_hash TEXT, + source_name TEXT, + clone_workout_id TEXT, + clone_workout_name TEXT, + scheduled_workout_id TEXT, + status TEXT NOT NULL, + message TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(scheduled_date) +); + +CREATE TABLE IF NOT EXISTS sync_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL, + trigger TEXT NOT NULL, + status TEXT NOT NULL, + started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + completed_at TEXT, + created_count INTEGER NOT NULL DEFAULT 0, + skipped_count INTEGER NOT NULL DEFAULT 0, + replaced_count INTEGER NOT NULL DEFAULT 0, + warning_count INTEGER NOT NULL DEFAULT 0, + error TEXT +); + +CREATE TABLE IF NOT EXISTS sync_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id INTEGER REFERENCES sync_runs(id) ON DELETE CASCADE, + level TEXT NOT NULL, + event_type TEXT NOT NULL, + scheduled_date TEXT, + message TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + details_json TEXT +); + +CREATE INDEX IF NOT EXISTS idx_sync_events_run_id ON sync_events(run_id); +CREATE INDEX IF NOT EXISTS idx_sync_events_date ON sync_events(scheduled_date); + +CREATE TABLE IF NOT EXISTS workout_traces ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id INTEGER REFERENCES sync_runs(id) ON DELETE SET NULL, + scheduled_date TEXT NOT NULL, + source_uuid TEXT, + source_hash TEXT, + source_name TEXT, + clone_workout_name TEXT, + action TEXT NOT NULL, + status TEXT NOT NULL, + message TEXT, + source_json TEXT, + payload_json TEXT, + result_json TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_workout_traces_date ON workout_traces(scheduled_date); +""" + + +class Database: + def __init__(self, path: Path) -> None: + self.path = path + + def initialize(self) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + with self.connect() as conn: + conn.executescript(SCHEMA) + + @contextmanager + def connect(self) -> Iterator[sqlite3.Connection]: + conn = sqlite3.connect(self.path) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def row_to_dict(row: sqlite3.Row | None) -> dict[str, Any] | None: + return None if row is None else dict(row) + + +def rows_to_dicts(rows: list[sqlite3.Row]) -> list[dict[str, Any]]: + return [dict(row) for row in rows] diff --git a/src/garmin_coach_clone/garmin_service.py b/src/garmin_coach_clone/garmin_service.py new file mode 100644 index 0000000..8c7adcd --- /dev/null +++ b/src/garmin_coach_clone/garmin_service.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import contextlib +from dataclasses import dataclass +from datetime import date +from typing import Any + +from garminconnect import Garmin + +from .coach import ( + extract_training_plans, + get_fbt_adaptive_workout, + is_adaptive_plan, + plan_id, + task_workout_for_date, +) +from .config import Settings +from .crypto import Crypto +from .repository import Repository + + +@dataclass(frozen=True) +class GarminCredentials: + email: str + password: str + + +class GarminService: + def __init__(self, settings: Settings, repo: Repository, crypto: Crypto) -> None: + self.settings = settings + self.repo = repo + self.crypto = crypto + self.settings.token_dir.mkdir(parents=True, exist_ok=True) + self._pending_mfa_client: Garmin | None = None + self._pending_mfa_state: dict[str, Any] = {} + + def save_credentials(self, email: str, password: str) -> None: + self.repo.save_garmin_credentials(self.crypto.encrypt(email), self.crypto.encrypt(password)) + + def credentials(self) -> GarminCredentials | None: + encrypted = self.repo.encrypted_garmin_credentials() + if encrypted is None: + return None + return GarminCredentials( + email=self.crypto.decrypt(encrypted[0]), + password=self.crypto.decrypt(encrypted[1]), + ) + + def configured(self) -> bool: + return self.credentials() is not None + + def setup_login(self) -> dict[str, Any]: + creds = self.credentials() + if creds is None: + raise ValueError("Garmin credentials are not configured") + client = Garmin(email=creds.email, password=creds.password, is_cn=False, return_on_mfa=True) + result, state = client.login() + if result == "needs_mfa": + self._pending_mfa_client = client + self._pending_mfa_state = state if isinstance(state, dict) else {} + self.repo.set_setting("garmin_mfa_state", "pending") + return {"status": "mfa_required"} + self._dump_tokens(client) + self.repo.set_setting("garmin_mfa_state", "") + return {"status": "ok"} + + def resume_mfa(self, code: str) -> dict[str, Any]: + if self._pending_mfa_client is None: + raise ValueError("No Garmin MFA challenge is pending") + self._pending_mfa_client.resume_login(self._pending_mfa_state, code) + self._dump_tokens(self._pending_mfa_client) + self._pending_mfa_client = None + self._pending_mfa_state = {} + self.repo.set_setting("garmin_mfa_state", "") + return {"status": "ok"} + + def authenticated_client(self) -> Garmin: + creds = self.credentials() + if creds is None: + raise ValueError("Garmin credentials are not configured") + client = Garmin(email=creds.email, password=creds.password, is_cn=False) + client.login(str(self.settings.token_dir)) + return client + + def active_adaptive_plan(self, client: Garmin) -> dict[str, Any] | None: + plans = extract_training_plans(client.get_training_plans()) + for plan in plans: + sport = str((plan.get("trainingType") or {}).get("typeKey", "")).lower() + if is_adaptive_plan(plan) and (not sport or sport == "cycling"): + return plan + return None + + def adaptive_plan_detail(self, client: Garmin, plan: dict[str, Any]) -> dict[str, Any]: + pid = plan_id(plan) + if pid is None: + raise ValueError("adaptive plan has no numeric ID") + return client.get_adaptive_training_plan_by_id(pid) + + def coach_workout_for_date( + self, client: Garmin, plan_detail: dict[str, Any], target_date: date + ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: + task_workout = task_workout_for_date(plan_detail, target_date) + if task_workout is None: + return None, None + if task_workout.get("restDay"): + return task_workout, None + uuid = task_workout.get("workoutUuid") + if not uuid: + return task_workout, None + return task_workout, get_fbt_adaptive_workout(client, str(uuid)) + + def _dump_tokens(self, client: Garmin) -> None: + token_client = getattr(client, "client", None) + if token_client is None: + return + token_client._tokenstore_path = str(self.settings.token_dir) + dump = getattr(token_client, "dump", None) + if callable(dump): + with contextlib.suppress(Exception): + dump(str(self.settings.token_dir)) diff --git a/src/garmin_coach_clone/io.py b/src/garmin_coach_clone/io.py new file mode 100644 index 0000000..01f962a --- /dev/null +++ b/src/garmin_coach_clone/io.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from .redact import redact + + +def dump_redacted_json(path: Path, data: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(redact(data), indent=2, sort_keys=True), encoding="utf-8") + + +def print_json(data: Any) -> None: + print(json.dumps(redact(data), indent=2, sort_keys=True)) + diff --git a/src/garmin_coach_clone/redact.py b/src/garmin_coach_clone/redact.py new file mode 100644 index 0000000..d8632fb --- /dev/null +++ b/src/garmin_coach_clone/redact.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import re +from collections.abc import Mapping +from typing import Any + +EMAIL_RE = re.compile(r"[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}") +BEARER_RE = re.compile(r"\bBearer\s+[A-Za-z0-9._~+/=-]+", re.IGNORECASE) + +PRIVATE_EXACT_KEYS = { + "address", + "birthdate", + "dateofbirth", + "deviceid", + "displayname", + "email", + "firstname", + "fullname", + "garminuserid", + "lastname", + "mfacode", + "password", + "phonenumber", + "serialnumber", + "unitid", + "userid", + "username", +} + +PRIVATE_KEY_PARTS = { + "access_token", + "authorization", + "cookie", + "homeaddress", + "oauth", + "owner", + "profile", + "refresh_token", + "secret", + "session", + "token", +} + + +def redact(value: Any) -> Any: + if isinstance(value, Mapping): + redacted: dict[str, Any] = {} + for key, item in value.items(): + key_text = str(key).lower() + normalized = _normalize_key(key_text) + if normalized in PRIVATE_EXACT_KEYS or any( + part in key_text for part in PRIVATE_KEY_PARTS + ): + redacted[str(key)] = "" + else: + redacted[str(key)] = redact(item) + return redacted + if isinstance(value, list): + return [redact(item) for item in value] + if isinstance(value, str): + return _redact_string(value) + return value + + +def _normalize_key(value: str) -> str: + return re.sub(r"[^a-z0-9]", "", value) + + +def _redact_string(value: str) -> str: + redacted = EMAIL_RE.sub("", value) + return BEARER_RE.sub("Bearer ", redacted) diff --git a/src/garmin_coach_clone/repository.py b/src/garmin_coach_clone/repository.py new file mode 100644 index 0000000..658da03 --- /dev/null +++ b/src/garmin_coach_clone/repository.py @@ -0,0 +1,351 @@ +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from typing import Any + +from .config import ( + DEFAULT_ACTIVE_WINDOW, + DEFAULT_FIXED_TIMES, + DEFAULT_INTERVAL_MINUTES, + Settings, +) +from .db import Database, row_to_dict, rows_to_dicts + +TIME_RE = re.compile(r"^([01]\d|2[0-3]):[0-5]\d$") + + +@dataclass(frozen=True) +class ScheduleConfig: + enabled: bool + interval_minutes: int + active_window: str + fixed_times: list[str] + days_ahead: int + + +class Repository: + def __init__(self, db: Database, settings: Settings) -> None: + self.db = db + self.settings = settings + + def get_setting(self, key: str) -> str | None: + with self.db.connect() as conn: + row = conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone() + return None if row is None else str(row["value"]) + + def set_setting(self, key: str, value: str) -> None: + with self.db.connect() as conn: + conn.execute( + """ + INSERT INTO settings(key, value, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + updated_at = CURRENT_TIMESTAMP + """, + (key, value), + ) + + def app_configured(self) -> bool: + return bool(self.get_setting("app_username") and self.get_setting("app_password_hash")) + + def bootstrap_app_user(self, username: str, password_hash: str) -> None: + self.set_setting("app_username", username) + self.set_setting("app_password_hash", password_hash) + + def app_username(self) -> str | None: + return self.get_setting("app_username") + + def app_password_hash(self) -> str | None: + return self.get_setting("app_password_hash") + + def garmin_configured(self) -> bool: + return bool(self.get_setting("garmin_email") and self.get_setting("garmin_password")) + + def save_garmin_credentials(self, encrypted_email: str, encrypted_password: str) -> None: + self.set_setting("garmin_email", encrypted_email) + self.set_setting("garmin_password", encrypted_password) + + def encrypted_garmin_credentials(self) -> tuple[str, str] | None: + email = self.get_setting("garmin_email") + password = self.get_setting("garmin_password") + if not email or not password: + return None + return email, password + + def schedule_config(self) -> ScheduleConfig: + return ScheduleConfig( + enabled=_bool_setting(self.get_setting("sync_enabled"), self.settings.sync_enabled), + interval_minutes=int( + self.get_setting("change_interval_minutes") + or self.settings.change_interval_minutes + ), + active_window=self.get_setting("change_active_window") + or self.settings.change_active_window, + fixed_times=_parse_fixed_times( + self.get_setting("change_fixed_times"), self.settings.change_fixed_times + ), + days_ahead=int(self.get_setting("sync_days_ahead") or self.settings.sync_days_ahead), + ) + + def save_schedule_config(self, config: ScheduleConfig) -> None: + validate_schedule_config(config) + self.set_setting("sync_enabled", "true" if config.enabled else "false") + self.set_setting("change_interval_minutes", str(config.interval_minutes)) + self.set_setting("change_active_window", config.active_window) + self.set_setting("change_fixed_times", ",".join(config.fixed_times)) + self.set_setting("sync_days_ahead", str(config.days_ahead)) + + def restore_default_schedule(self) -> ScheduleConfig: + config = ScheduleConfig( + enabled=self.settings.sync_enabled, + interval_minutes=DEFAULT_INTERVAL_MINUTES, + active_window=DEFAULT_ACTIVE_WINDOW, + fixed_times=DEFAULT_FIXED_TIMES.copy(), + days_ahead=self.settings.sync_days_ahead, + ) + self.save_schedule_config(config) + return config + + def start_sync_run(self, kind: str, trigger: str) -> int: + with self.db.connect() as conn: + cur = conn.execute( + """ + INSERT INTO sync_runs(kind, trigger, status) + VALUES (?, ?, 'running') + """, + (kind, trigger), + ) + if cur.lastrowid is None: + raise RuntimeError("SQLite did not return a sync run id") + return int(cur.lastrowid) + + def finish_sync_run( + self, + run_id: int, + status: str, + counts: dict[str, int], + error: str | None = None, + ) -> None: + with self.db.connect() as conn: + conn.execute( + """ + UPDATE sync_runs + SET status = ?, completed_at = CURRENT_TIMESTAMP, + created_count = ?, skipped_count = ?, replaced_count = ?, + warning_count = ?, error = ? + WHERE id = ? + """, + ( + status, + counts.get("created", 0), + counts.get("skipped", 0), + counts.get("replaced", 0), + counts.get("warnings", 0), + error, + run_id, + ), + ) + + def add_event( + self, + run_id: int | None, + level: str, + event_type: str, + message: str, + scheduled_date: str | None = None, + details: dict[str, Any] | None = None, + ) -> None: + with self.db.connect() as conn: + conn.execute( + """ + INSERT INTO sync_events( + run_id, level, event_type, scheduled_date, message, details_json + ) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + run_id, + level, + event_type, + scheduled_date, + message, + None if details is None else _json(details), + ), + ) + + def get_clone_mapping(self, scheduled_date: str) -> dict[str, Any] | None: + with self.db.connect() as conn: + row = conn.execute( + "SELECT * FROM clone_mappings WHERE scheduled_date = ?", + (scheduled_date,), + ).fetchone() + return row_to_dict(row) + + def upsert_clone_mapping(self, mapping: dict[str, Any]) -> None: + with self.db.connect() as conn: + conn.execute( + """ + INSERT INTO clone_mappings( + scheduled_date, source_uuid, source_hash, source_name, + clone_workout_id, clone_workout_name, scheduled_workout_id, + status, message, updated_at + ) + VALUES ( + :scheduled_date, :source_uuid, :source_hash, :source_name, + :clone_workout_id, :clone_workout_name, :scheduled_workout_id, + :status, :message, CURRENT_TIMESTAMP + ) + ON CONFLICT(scheduled_date) DO UPDATE SET + source_uuid = excluded.source_uuid, + source_hash = excluded.source_hash, + source_name = excluded.source_name, + clone_workout_id = excluded.clone_workout_id, + clone_workout_name = excluded.clone_workout_name, + scheduled_workout_id = excluded.scheduled_workout_id, + status = excluded.status, + message = excluded.message, + updated_at = CURRENT_TIMESTAMP + """, + mapping, + ) + + def list_clone_mappings(self, limit: int = 50) -> list[dict[str, Any]]: + with self.db.connect() as conn: + return rows_to_dicts( + conn.execute( + """ + SELECT * FROM clone_mappings + ORDER BY scheduled_date DESC + LIMIT ? + """, + (limit,), + ).fetchall() + ) + + def add_trace(self, trace: dict[str, Any]) -> int: + values = { + **trace, + "source_json": _json_or_none(trace.get("source_json")), + "payload_json": _json_or_none(trace.get("payload_json")), + "result_json": _json_or_none(trace.get("result_json")), + } + with self.db.connect() as conn: + cur = conn.execute( + """ + INSERT INTO workout_traces( + run_id, scheduled_date, source_uuid, source_hash, source_name, + clone_workout_name, action, status, message, + source_json, payload_json, result_json + ) + VALUES ( + :run_id, :scheduled_date, :source_uuid, :source_hash, :source_name, + :clone_workout_name, :action, :status, :message, + :source_json, :payload_json, :result_json + ) + """, + values, + ) + if cur.lastrowid is None: + raise RuntimeError("SQLite did not return a trace id") + return int(cur.lastrowid) + + def list_traces(self, limit: int = 50) -> list[dict[str, Any]]: + with self.db.connect() as conn: + return rows_to_dicts( + conn.execute( + """ + SELECT id, run_id, scheduled_date, source_uuid, source_hash, source_name, + clone_workout_name, action, status, message, created_at + FROM workout_traces + ORDER BY created_at DESC, id DESC + LIMIT ? + """, + (limit,), + ).fetchall() + ) + + def get_trace(self, trace_id: int) -> dict[str, Any] | None: + with self.db.connect() as conn: + row = conn.execute("SELECT * FROM workout_traces WHERE id = ?", (trace_id,)).fetchone() + return row_to_dict(row) + + def list_events(self, limit: int = 100) -> list[dict[str, Any]]: + with self.db.connect() as conn: + return rows_to_dicts( + conn.execute( + """ + SELECT * FROM sync_events + ORDER BY created_at DESC, id DESC + LIMIT ? + """, + (limit,), + ).fetchall() + ) + + def sync_status(self) -> dict[str, Any]: + with self.db.connect() as conn: + last = row_to_dict( + conn.execute( + "SELECT * FROM sync_runs ORDER BY started_at DESC, id DESC LIMIT 1" + ).fetchone() + ) + clones = rows_to_dicts( + conn.execute( + """ + SELECT * FROM clone_mappings + ORDER BY scheduled_date ASC + LIMIT 20 + """ + ).fetchall() + ) + return {"last_run": last, "clones": clones, "schedule": self.schedule_config().__dict__} + + +def validate_schedule_config(config: ScheduleConfig) -> None: + if config.interval_minutes < 5 or config.interval_minutes > 24 * 60: + raise ValueError("interval must be between 5 and 1440 minutes") + start, end = _split_window(config.active_window) + if start >= end: + raise ValueError("active window start must be before end") + for value in config.fixed_times: + if not TIME_RE.match(value): + raise ValueError(f"invalid fixed time: {value}") + if config.days_ahead < 0 or config.days_ahead > 14: + raise ValueError("days ahead must be between 0 and 14") + + +def _split_window(value: str) -> tuple[str, str]: + parts = value.split("-", 1) + if len(parts) != 2 or not TIME_RE.match(parts[0]) or not TIME_RE.match(parts[1]): + raise ValueError("active window must be HH:MM-HH:MM") + return parts[0], parts[1] + + +def _parse_fixed_times(value: str | None, default: list[str]) -> list[str]: + if not value: + return default.copy() + cleaned = [] + for item in value.split(","): + item = item.strip() + if item and item not in cleaned: + cleaned.append(item) + return cleaned + + +def _bool_setting(value: str | None, default: bool) -> bool: + if value is None: + return default + return value.lower() in {"1", "true", "yes", "on"} + + +def _json(value: dict[str, Any]) -> str: + return json.dumps(value, separators=(",", ":"), sort_keys=True) + + +def _json_or_none(value: Any) -> str | None: + if value is None: + return None + return json.dumps(value, separators=(",", ":"), sort_keys=True) diff --git a/src/garmin_coach_clone/sync_service.py b/src/garmin_coach_clone/sync_service.py new file mode 100644 index 0000000..15c8a28 --- /dev/null +++ b/src/garmin_coach_clone/sync_service.py @@ -0,0 +1,385 @@ +from __future__ import annotations + +import asyncio +from contextlib import suppress +from datetime import date, datetime, time, timedelta +from typing import Any, Protocol +from zoneinfo import ZoneInfo + +from .config import Settings +from .io import redact +from .repository import Repository, ScheduleConfig +from .workouts import ( + calendar_entry_id, + clone_workout_payload, + existing_clone_names, + generated_calendar_entries, + summarize_workout, + validate_workout_payload, + workout_source_hash, +) + + +class GarminLike(Protocol): + def authenticated_client(self) -> Any: ... + + def active_adaptive_plan(self, client: Any) -> dict[str, Any] | None: ... + + def adaptive_plan_detail(self, client: Any, plan: dict[str, Any]) -> dict[str, Any]: ... + + def coach_workout_for_date( + self, client: Any, plan_detail: dict[str, Any], target_date: date + ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: ... + + +class SyncService: + def __init__(self, settings: Settings, repo: Repository, garmin: GarminLike) -> None: + self.settings = settings + self.repo = repo + self.garmin = garmin + self._lock = asyncio.Lock() + self._stop = asyncio.Event() + self._task: asyncio.Task[Any] | None = None + self._last_run_key: str | None = None + + def start(self) -> None: + self._task = asyncio.create_task(self._scheduler()) + + async def stop(self) -> None: + self._stop.set() + if self._task is not None: + self._task.cancel() + with suppress(asyncio.CancelledError): + await self._task + + async def _scheduler(self) -> None: + await asyncio.sleep(10) + if self.repo.garmin_configured() and self.repo.schedule_config().enabled: + with _suppress_to_event(self.repo): + await self.run_change_detection("startup") + while not self._stop.is_set(): + with suppress(TimeoutError): + await asyncio.wait_for(self._stop.wait(), timeout=60) + if self._stop.is_set(): + break + config = self.repo.schedule_config() + if not config.enabled or not self.repo.garmin_configured(): + continue + now = datetime.now(ZoneInfo(self.settings.timezone)) + run_key = now.strftime("%Y-%m-%d %H:%M") + if run_key == self._last_run_key: + continue + if should_run_now(now, config): + self._last_run_key = run_key + with _suppress_to_event(self.repo): + await self.run_change_detection("schedule") + + async def run_change_detection( + self, + trigger: str = "manual", + target_dates: list[date] | None = None, + dry_run: bool = False, + ) -> dict[str, Any]: + async with self._lock: + return await asyncio.to_thread(self._run_sync, trigger, target_dates, dry_run) + + def _run_sync( + self, trigger: str, target_dates: list[date] | None, dry_run: bool + ) -> dict[str, Any]: + run_id = self.repo.start_sync_run("dry_run" if dry_run else "change_detection", trigger) + counts = {"created": 0, "skipped": 0, "replaced": 0, "warnings": 0} + status = "completed" + try: + client = self.garmin.authenticated_client() + plan = self.garmin.active_adaptive_plan(client) + if plan is None: + self.repo.add_event( + run_id, + "warning", + "no_plan", + "No active adaptive cycling plan found", + ) + counts["warnings"] += 1 + self.repo.finish_sync_run(run_id, status, counts) + return {"run_id": run_id, "status": status, **counts} + + detail = self.garmin.adaptive_plan_detail(client, plan) + dates = target_dates or _default_dates(self.repo.schedule_config().days_ahead) + for target_date in dates: + decision = self._sync_date(run_id, client, detail, target_date, dry_run) + counts[decision] = counts.get(decision, 0) + 1 + self.repo.finish_sync_run(run_id, status, counts) + return {"run_id": run_id, "status": status, **counts} + except Exception as exc: + status = "failed" + self.repo.add_event(run_id, "error", "sync_failed", str(exc)) + self.repo.finish_sync_run(run_id, status, counts, str(exc)) + raise + + def _sync_date( + self, + run_id: int, + client: Any, + plan_detail: dict[str, Any], + target_date: date, + dry_run: bool, + ) -> str: + date_s = target_date.isoformat() + task_workout, source = self.garmin.coach_workout_for_date(client, plan_detail, target_date) + mapping = self.repo.get_clone_mapping(date_s) + if task_workout is None: + return self._trace( + run_id, date_s, None, None, "missing", "warning", "No Coach task found" + ) + if source is None: + if mapping and mapping.get("clone_workout_id"): + return self._trace( + run_id, + date_s, + task_workout, + None, + "rest_day_existing_clone", + "warning", + "Coach task is now rest/empty; existing clone was left untouched", + ) + return self._trace( + run_id, date_s, task_workout, None, "rest_day", "skipped", "Rest day" + ) + + source_hash = workout_source_hash(source) + source_uuid = str(source.get("workoutUuid") or task_workout.get("workoutUuid") or "") + source_name = str( + source.get("workoutName") or task_workout.get("workoutName") or "Coach Workout" + ) + payload = clone_workout_payload(source, target_date, self.settings.clone_prefix) + errors = validate_workout_payload(payload) + if errors: + return self._trace( + run_id, + date_s, + source, + payload, + "validate", + "warning", + "; ".join(errors), + source_hash=source_hash, + ) + + if ( + mapping + and mapping.get("source_hash") == source_hash + and mapping.get("clone_workout_id") + ): + return self._trace( + run_id, + date_s, + source, + payload, + "skip_unchanged", + "skipped", + "Generated clone is current", + source_hash=source_hash, + ) + + existing = existing_clone_names( + client.get_workouts(limit=100), target_date, self.settings.clone_prefix + ) + if not mapping and existing: + self.repo.upsert_clone_mapping( + { + "scheduled_date": date_s, + "source_uuid": source_uuid, + "source_hash": source_hash, + "source_name": source_name, + "clone_workout_id": None, + "clone_workout_name": existing[0], + "scheduled_workout_id": None, + "status": "external_existing", + "message": "Generated clone exists in Garmin but is not mapped locally", + } + ) + return self._trace( + run_id, + date_s, + source, + payload, + "external_existing", + "warning", + "Generated clone already exists in Garmin; skipped to avoid duplicate", + source_hash=source_hash, + ) + + action = "create" if mapping is None else "replace_changed" + if dry_run: + return self._trace( + run_id, + date_s, + source, + payload, + action, + "skipped", + f"Dry run would {action}", + source_hash=source_hash, + ) + + if mapping and mapping.get("clone_workout_id"): + self._replace_existing(client, mapping) + + upload_result = client.upload_workout(payload) + workout_id = upload_result.get("workoutId") or upload_result.get("id") + if workout_id is None: + raise ValueError(f"Upload returned no workoutId: {upload_result}") + schedule_result = client.schedule_workout(workout_id, date_s) + scheduled_id = _scheduled_id(schedule_result) or self._find_scheduled_id( + client, payload, target_date + ) + self.repo.upsert_clone_mapping( + { + "scheduled_date": date_s, + "source_uuid": source_uuid, + "source_hash": source_hash, + "source_name": source_name, + "clone_workout_id": str(workout_id), + "clone_workout_name": str(payload["workoutName"]), + "scheduled_workout_id": scheduled_id, + "status": "scheduled", + "message": summarize_workout(payload), + } + ) + status_key = self._trace( + run_id, + date_s, + source, + payload, + action, + "completed", + f"Uploaded workout {workout_id} and scheduled it for {date_s}", + source_hash=source_hash, + result_json={"upload": upload_result, "schedule": schedule_result}, + ) + if action == "replace_changed": + return "replaced" + if status_key == "created": + return "created" + return status_key + + def _replace_existing(self, client: Any, mapping: dict[str, Any]) -> None: + scheduled_id = mapping.get("scheduled_workout_id") + if scheduled_id: + client.unschedule_workout(str(scheduled_id)) + if self.settings.delete_old_clones and mapping.get("clone_workout_id"): + client.delete_workout(str(mapping["clone_workout_id"])) + + def _find_scheduled_id( + self, client: Any, payload: dict[str, Any], target_date: date + ) -> str | None: + calendar = client.get_scheduled_workouts(target_date.year, target_date.month) + for entry in generated_calendar_entries(calendar, self.settings.clone_prefix): + if ( + entry.get("workoutName") == payload["workoutName"] + or entry.get("title") == payload["workoutName"] + ): + return calendar_entry_id(entry) + return None + + def _trace( + self, + run_id: int, + scheduled_date: str, + source_json: dict[str, Any] | None, + payload_json: dict[str, Any] | None, + action: str, + status: str, + message: str, + source_hash: str | None = None, + result_json: dict[str, Any] | None = None, + ) -> str: + source_name = None + source_uuid = None + if source_json: + source_name = source_json.get("workoutName") or source_json.get("name") + source_uuid = source_json.get("workoutUuid") + clone_name = payload_json.get("workoutName") if payload_json else None + self.repo.add_trace( + { + "run_id": run_id, + "scheduled_date": scheduled_date, + "source_uuid": source_uuid, + "source_hash": source_hash, + "source_name": source_name, + "clone_workout_name": clone_name, + "action": action, + "status": status, + "message": message, + "source_json": redact(source_json) if source_json else None, + "payload_json": redact(payload_json) if payload_json else None, + "result_json": redact(result_json) if result_json else None, + } + ) + level = "warning" if status == "warning" else "info" + self.repo.add_event(run_id, level, action, message, scheduled_date) + if status == "warning": + return "warnings" + if action.startswith("skip") or status == "skipped": + return "skipped" + if action == "replace_changed": + return "replaced" + if action == "create": + return "created" + return "skipped" + + +def should_run_now(now: datetime, config: ScheduleConfig) -> bool: + current = now.strftime("%H:%M") + if current in config.fixed_times: + return True + start_s, end_s = config.active_window.split("-", 1) + start = _parse_time(start_s) + end = _parse_time(end_s) + current_t = now.time().replace(second=0, microsecond=0) + if not (start <= current_t <= end): + return False + minutes = now.hour * 60 + now.minute + return minutes % config.interval_minutes == 0 + + +def next_run_after(now: datetime, config: ScheduleConfig) -> datetime | None: + if not config.enabled: + return None + probe = now.replace(second=0, microsecond=0) + timedelta(minutes=1) + for _ in range(60 * 48): + if should_run_now(probe, config): + return probe + probe += timedelta(minutes=1) + return None + + +def _default_dates(days_ahead: int) -> list[date]: + today = date.today() + return [today + timedelta(days=offset) for offset in range(days_ahead + 1)] + + +def _parse_time(value: str) -> time: + hour, minute = value.split(":", 1) + return time(int(hour), int(minute)) + + +def _scheduled_id(value: Any) -> str | None: + if isinstance(value, dict): + for key in ("scheduledWorkoutId", "calendarItemId", "id"): + if value.get(key) is not None: + return str(value[key]) + return None + + +class _suppress_to_event: + def __init__(self, repo: Repository) -> None: + self.repo = repo + + def __enter__(self) -> None: + return None + + def __exit__(self, _exc_type: Any, exc: BaseException | None, _tb: Any) -> bool: + if exc is not None: + self.repo.add_event(None, "error", "scheduler_error", str(exc)) + return True diff --git a/src/garmin_coach_clone/web_auth.py b/src/garmin_coach_clone/web_auth.py new file mode 100644 index 0000000..7dda3b5 --- /dev/null +++ b/src/garmin_coach_clone/web_auth.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from fastapi import HTTPException, Request, Response + +from .config import Settings +from .crypto import Crypto + + +def create_session(response: Response, settings: Settings, crypto: Crypto, username: str) -> None: + token = crypto.sign_session({"sub": username}, settings.session_ttl_seconds) + response.set_cookie( + settings.session_cookie, + token, + httponly=True, + samesite="lax", + secure=False, + max_age=settings.session_ttl_seconds, + path="/", + ) + + +def clear_session(response: Response, settings: Settings) -> None: + response.delete_cookie(settings.session_cookie, path="/") + + +def current_user(request: Request) -> str | None: + settings: Settings = request.app.state.settings + crypto: Crypto = request.app.state.crypto + token = request.cookies.get(settings.session_cookie) + if not token: + return None + payload = crypto.verify_session(token) + if payload is None: + return None + subject = payload.get("sub") + return str(subject) if subject else None + + +def require_auth(request: Request) -> str: + user = current_user(request) + if user is None: + raise HTTPException(status_code=401, detail="Not authenticated") + return user diff --git a/src/garmin_coach_clone/workouts.py b/src/garmin_coach_clone/workouts.py new file mode 100644 index 0000000..afa68c3 --- /dev/null +++ b/src/garmin_coach_clone/workouts.py @@ -0,0 +1,354 @@ +from __future__ import annotations + +import hashlib +import json +from copy import deepcopy +from datetime import date, datetime +from typing import Any + +from garminconnect.workout import ( + CyclingWorkout, + WorkoutSegment, + create_cooldown_step, + create_interval_step, + create_recovery_step, + create_repeat_group, + create_warmup_step, +) + +CYCLING_SPORT = {"sportTypeId": 2, "sportTypeKey": "cycling", "displayOrder": 2} +CLONE_ID_FIELDS = { + "author", + "createdDate", + "ownerId", + "shared", + "stepId", + "updatedDate", + "workoutId", +} +VALID_STEP_TYPES = { + "warmup", + "cooldown", + "interval", + "recovery", + "rest", + "repeat", + "other", + "main", +} + + +def build_dummy_cycling_workout(name: str | None = None) -> dict[str, Any]: + workout = CyclingWorkout( + workoutName=name or f"GCClone Probe Dummy {datetime.now():%Y-%m-%d %H:%M}", + description="Minimal cycling workout uploaded by the Garmin Coach clone probe.", + estimatedDurationInSecs=14 * 60, + workoutSegments=[ + WorkoutSegment( + segmentOrder=1, + sportType=CYCLING_SPORT, + workoutSteps=[ + create_warmup_step(5 * 60, step_order=1), + create_repeat_group( + iterations=1, + step_order=2, + workout_steps=[ + create_interval_step(2 * 60, step_order=3), + create_recovery_step(2 * 60, step_order=4), + ], + ), + create_cooldown_step(5 * 60, step_order=5), + ], + ) + ], + ) + return workout.to_dict() + + +def clone_workout_payload( + source: dict[str, Any], scheduled_date: date, prefix: str +) -> dict[str, Any]: + if "workoutSegments" not in source: + raise ValueError("source object does not contain workoutSegments") + + payload = deepcopy(source) + _strip_ids(payload) + original_name = str( + source.get("workoutName") + or source.get("name") + or source.get("title") + or "Garmin Coach Workout" + ) + payload["workoutName"] = f"{prefix} {scheduled_date.isoformat()} {original_name}"[:120] + payload["description"] = ( + "Generated by garmin-coach-to-cal-sync probe from a Garmin Coach/adaptive " + "workout-like object. Verify targets on the Edge before relying on it." + ) + payload["sportType"] = CYCLING_SPORT + for idx, segment in enumerate(payload.get("workoutSegments", []), start=1): + if isinstance(segment, dict): + segment["segmentOrder"] = segment.get("segmentOrder") or idx + segment["sportType"] = CYCLING_SPORT + payload.setdefault("estimatedDurationInSecs", estimate_duration(payload)) + return payload + + +def validate_workout_payload(workout: dict[str, Any]) -> list[str]: + errors: list[str] = [] + name = workout.get("workoutName") + if not isinstance(name, str) or not name.strip(): + errors.append("workoutName must be a non-empty string") + + sport_key = _key(workout.get("sportType")) + if sport_key != "cycling": + errors.append(f"sportType must be cycling, got {sport_key or ''}") + + duration = workout.get("estimatedDurationInSecs") + if duration is not None and (not isinstance(duration, int) or duration < 0): + errors.append("estimatedDurationInSecs must be a non-negative integer when present") + + segments = workout.get("workoutSegments") + if not isinstance(segments, list) or not segments: + errors.append("workoutSegments must be a non-empty list") + return errors + + for segment_idx, segment in enumerate(segments, start=1): + path = f"workoutSegments[{segment_idx}]" + if not isinstance(segment, dict): + errors.append(f"{path} must be an object") + continue + if _key(segment.get("sportType")) != "cycling": + errors.append(f"{path}.sportType must be cycling") + steps = segment.get("workoutSteps") + if not isinstance(steps, list) or not steps: + errors.append(f"{path}.workoutSteps must be a non-empty list") + continue + _validate_steps(steps, f"{path}.workoutSteps", errors) + return errors + + +def estimate_duration(workout: dict[str, Any]) -> int: + total = 0 + for segment in workout.get("workoutSegments", []): + if isinstance(segment, dict): + total += _steps_duration(segment.get("workoutSteps", []), multiplier=1) + return total + + +def workout_source_hash(workout: dict[str, Any]) -> str: + value = deepcopy(workout) + _strip_ids(value) + for key in ( + "uploadTimestamp", + "workoutThumbnailUrl", + "sharedWithUsers", + "consumer", + "consumerImageURL", + "consumerName", + "consumerWebsiteURL", + ): + value.pop(key, None) + encoded = json.dumps(value, separators=(",", ":"), sort_keys=True) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest() + + +def existing_clone_names( + workouts: list[dict[str, Any]], scheduled_date: date, prefix: str +) -> list[str]: + marker = f"{prefix} {scheduled_date.isoformat()}" + return [ + str(workout.get("workoutName")) + for workout in workouts + if str(workout.get("workoutName", "")).startswith(marker) + ] + + +def generated_workouts(workouts: list[dict[str, Any]], prefix: str) -> list[dict[str, Any]]: + return [ + workout + for workout in workouts + if str(workout.get("workoutName", "")).startswith(prefix) + ] + + +def find_generated_workout( + workouts: list[dict[str, Any]], workout_id: str, prefix: str +) -> dict[str, Any] | None: + for workout in generated_workouts(workouts, prefix): + if str(workout.get("workoutId") or workout.get("id")) == str(workout_id): + return workout + return None + + +def generated_calendar_entries(calendar_data: Any, prefix: str) -> list[dict[str, Any]]: + entries: list[dict[str, Any]] = [] + + def walk(node: Any) -> None: + if isinstance(node, dict): + name = calendar_entry_name(node) + if name is not None and name.startswith(prefix) and calendar_entry_id(node) is not None: + entries.append(node) + for child in node.values(): + walk(child) + elif isinstance(node, list): + for child in node: + walk(child) + + walk(calendar_data) + return _dedupe_calendar_entries(entries) + + +def find_generated_calendar_entry( + calendar_data: Any, scheduled_id: str, prefix: str +) -> dict[str, Any] | None: + for entry in generated_calendar_entries(calendar_data, prefix): + if calendar_entry_id(entry) == str(scheduled_id): + return entry + return None + + +def calendar_entry_id(entry: dict[str, Any]) -> str | None: + for key in ("scheduledWorkoutId", "calendarItemId"): + value = entry.get(key) + if value is not None: + return str(value) + return None + + +def calendar_entry_name(entry: dict[str, Any]) -> str | None: + for key in ("workoutName", "name", "title"): + value = entry.get(key) + if isinstance(value, str) and value: + return value + for key in ("workout", "workoutDTO", "workoutSummary"): + nested = entry.get(key) + if isinstance(nested, dict): + name = calendar_entry_name(nested) + if name is not None: + return name + return None + + +def calendar_entry_date(entry: dict[str, Any]) -> str | None: + for key in ("date", "startDate", "scheduledDate", "calendarDate"): + value = entry.get(key) + if isinstance(value, str) and value: + return value[:10] + return None + + +def summarize_workout(workout: dict[str, Any]) -> str: + lines: list[str] = [] + name = workout.get("workoutName") or workout.get("name") or workout.get("title") or "" + lines.append(f"Workout: {name}") + for segment in workout.get("workoutSegments", []): + if not isinstance(segment, dict): + continue + segment_order = segment.get("segmentOrder", "?") + lines.append(f" Segment {segment_order}:") + _summarize_steps(lines, segment.get("workoutSteps", []), indent=" ") + if len(lines) == 1: + lines.append(" No workoutSegments/workoutSteps found.") + return "\n".join(lines) + + +def _summarize_steps(lines: list[str], steps: Any, indent: str) -> None: + if not isinstance(steps, list): + return + for step in steps: + if not isinstance(step, dict): + continue + step_type = _key(step.get("stepType")) or step.get("type") or "step" + if str(step.get("type")) == "RepeatGroupDTO" or step_type == "repeat": + iterations = step.get("numberOfIterations") or step.get("endConditionValue") or "?" + lines.append(f"{indent}repeat x{iterations}") + _summarize_steps(lines, step.get("workoutSteps", []), indent=f"{indent} ") + continue + end = _key(step.get("endCondition")) or "unknown-end" + end_value = step.get("endConditionValue") + target = _key(step.get("targetType")) or "no target" + lines.append(f"{indent}{step_type}: {end} {end_value}; target {target}") + + +def _key(value: Any) -> str | None: + if isinstance(value, dict): + for key in ("stepTypeKey", "conditionTypeKey", "workoutTargetTypeKey", "sportTypeKey"): + if value.get(key): + return str(value[key]) + return None + + +def _steps_duration(steps: Any, multiplier: int) -> int: + if not isinstance(steps, list): + return 0 + total = 0 + for step in steps: + if not isinstance(step, dict): + continue + step_type = _key(step.get("stepType")) + if str(step.get("type")) == "RepeatGroupDTO" or step_type == "repeat": + iterations = int(step.get("numberOfIterations") or step.get("endConditionValue") or 1) + total += _steps_duration(step.get("workoutSteps", []), multiplier=iterations) + continue + end = _key(step.get("endCondition")) + if end == "time" and isinstance(step.get("endConditionValue"), int | float): + total += int(step["endConditionValue"]) * multiplier + return total + + +def _validate_steps(steps: list[Any], path: str, errors: list[str]) -> None: + for step_idx, step in enumerate(steps, start=1): + step_path = f"{path}[{step_idx}]" + if not isinstance(step, dict): + errors.append(f"{step_path} must be an object") + continue + + step_type = _key(step.get("stepType")) + if step_type not in VALID_STEP_TYPES: + errors.append(f"{step_path}.stepType is invalid or missing: {step_type}") + continue + + if step_type == "repeat" or str(step.get("type")) == "RepeatGroupDTO": + iterations = step.get("numberOfIterations") or step.get("endConditionValue") + if not isinstance(iterations, int | float) or iterations <= 0: + errors.append(f"{step_path}.numberOfIterations must be positive") + nested = step.get("workoutSteps") + if not isinstance(nested, list) or not nested: + errors.append(f"{step_path}.workoutSteps must be a non-empty list") + else: + _validate_steps(nested, f"{step_path}.workoutSteps", errors) + continue + + end_condition = _key(step.get("endCondition")) + if end_condition is None: + errors.append(f"{step_path}.endCondition is missing") + elif end_condition != "lap.button": + end_value = step.get("endConditionValue") + if not isinstance(end_value, int | float) or end_value <= 0: + errors.append(f"{step_path}.endConditionValue must be positive") + + if _key(step.get("targetType")) is None: + errors.append(f"{step_path}.targetType is missing") + + +def _strip_ids(value: Any) -> None: + if isinstance(value, dict): + for key in list(value.keys()): + if key in CLONE_ID_FIELDS: + value.pop(key, None) + else: + _strip_ids(value[key]) + elif isinstance(value, list): + for item in value: + _strip_ids(item) + + +def _dedupe_calendar_entries(items: list[dict[str, Any]]) -> list[dict[str, Any]]: + seen: set[str | int] = set() + deduped: list[dict[str, Any]] = [] + for item in items: + marker: str | int = calendar_entry_id(item) or id(item) + if marker not in seen: + seen.add(marker) + deduped.append(item) + return deduped diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..82d6164 --- /dev/null +++ b/static/style.css @@ -0,0 +1,373 @@ +:root { + --bg: #101312; + --panel: #191e1c; + --panel-2: #202823; + --line: #334139; + --text: #edf3ed; + --muted: #a5b4aa; + --accent: #d4f56a; + --accent-2: #49c6a2; + --danger: #ff6b57; + --warn: #e9b949; + --ok: #4ade80; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: "Aptos", "Segoe UI", sans-serif; + line-height: 1.45; +} + +a { + color: var(--accent); + text-decoration: none; +} + +.topbar { + align-items: center; + background: #0c0f0e; + border-bottom: 1px solid var(--line); + display: grid; + grid-template-columns: auto 1fr auto; + min-height: 64px; + padding: 0 24px; + position: sticky; + top: 0; + z-index: 3; +} + +.brand { + align-items: center; + color: var(--text); + display: inline-flex; + font-weight: 800; + gap: 10px; + letter-spacing: .02em; +} + +.brand-mark { + background: linear-gradient(135deg, var(--accent), var(--accent-2)); + display: inline-block; + height: 18px; + transform: skew(-18deg); + width: 28px; +} + +nav { + display: flex; + flex-wrap: wrap; + gap: 4px; + justify-content: center; +} + +nav a, +.icon-button, +.button, +button { + background: #253028; + border: 1px solid #3d4c42; + color: var(--text); + cursor: pointer; + display: inline-block; + font: inherit; + font-weight: 700; + padding: 10px 14px; +} + +nav a:hover, +.icon-button:hover, +.button:hover, +button:hover { + border-color: var(--accent); + color: var(--accent); +} + +.secondary { + background: transparent; +} + +.shell { + margin: 0 auto; + max-width: 1180px; + padding: 32px 20px 64px; +} + +.page-head { + align-items: end; + display: flex; + gap: 18px; + justify-content: space-between; + margin-bottom: 24px; +} + +.eyebrow { + color: var(--accent-2); + font-size: .78rem; + font-weight: 800; + letter-spacing: .14em; + margin: 0 0 4px; + text-transform: uppercase; +} + +h1, +h2 { + line-height: 1.05; + margin: 0; +} + +h1 { + font-size: clamp(2rem, 5vw, 4.4rem); +} + +h2 { + font-size: 1.1rem; +} + +.metrics { + display: grid; + gap: 12px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin-bottom: 18px; +} + +.metrics article, +.panel, +.auth-panel { + background: var(--panel); + border: 1px solid var(--line); +} + +.metrics article { + min-height: 98px; + padding: 16px; +} + +.metrics span { + color: var(--muted); + display: block; + font-size: .78rem; + font-weight: 800; + text-transform: uppercase; +} + +.metrics strong { + display: block; + font-size: 1.2rem; + margin-top: 12px; + overflow-wrap: anywhere; +} + +.hash { + font-size: .78rem !important; +} + +.panel { + margin-top: 18px; + overflow: hidden; +} + +.panel > header { + align-items: center; + background: var(--panel-2); + border-bottom: 1px solid var(--line); + display: flex; + justify-content: space-between; + padding: 14px 16px; +} + +.panel > p, +.form-panel form { + padding: 16px; +} + +.muted, +.empty { + color: var(--muted); +} + +table { + border-collapse: collapse; + width: 100%; +} + +th, +td { + border-bottom: 1px solid var(--line); + padding: 12px 14px; + text-align: left; + vertical-align: top; +} + +th { + color: var(--muted); + font-size: .76rem; + text-transform: uppercase; +} + +.badge, +.state { + border: 1px solid var(--line); + color: var(--accent); + display: inline-block; + font-size: .78rem; + font-weight: 800; + padding: 4px 8px; + text-transform: uppercase; +} + +.badge.warning, +.state.warn { + color: var(--warn); +} + +.badge.error { + color: var(--danger); +} + +.state.ok { + color: var(--ok); +} + +.stack { + display: grid; + gap: 14px; +} + +.grid-form { + display: grid; + gap: 14px; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +label { + color: var(--muted); + display: grid; + font-size: .82rem; + font-weight: 800; + gap: 6px; + text-transform: uppercase; +} + +.checkbox { + align-items: center; + display: flex; + gap: 8px; +} + +input, +select, +textarea { + background: #0e1211; + border: 1px solid var(--line); + color: var(--text); + font: inherit; + padding: 11px 12px; + width: 100%; +} + +textarea { + resize: vertical; +} + +.button-row, +.inline-actions { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.button-row { + padding: 16px; +} + +.alert, +.notice { + border: 1px solid; + font-weight: 700; + padding: 12px 14px; +} + +.alert { + background: #2d1713; + border-color: #7f3529; + color: #ffb0a5; +} + +.notice { + background: #15271e; + border-color: #2f6d48; + color: #a7f3c8; +} + +pre { + background: #0b0e0d; + color: #d9e8da; + margin: 0; + max-height: 560px; + overflow: auto; + padding: 16px; +} + +.inline-pre { + background: transparent; + max-height: 120px; + padding: 0; + white-space: pre-wrap; +} + +.auth-page { + align-items: center; + display: grid; + min-height: 100vh; + padding: 20px; +} + +.auth-panel { + margin: 0 auto; + max-width: 440px; + padding: 28px; + width: 100%; +} + +.auth-brand { + margin-bottom: 28px; +} + +.auth-panel h1 { + font-size: 2.6rem; +} + +@media (max-width: 860px) { + .topbar, + .page-head { + align-items: stretch; + grid-template-columns: 1fr; + } + + .topbar { + display: grid; + gap: 12px; + padding: 16px; + } + + nav { + justify-content: flex-start; + } + + .metrics, + .grid-form { + grid-template-columns: 1fr; + } + + table { + display: block; + overflow-x: auto; + } +} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..9026ded --- /dev/null +++ b/templates/base.html @@ -0,0 +1,31 @@ + + + + + + {{ title or "Garmin Coach Clone" }} + + + +
+ + + Coach Clone + + +
+ +
+
+
+ {% block content %}{% endblock %} +
+ + diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..75ac0d0 --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,85 @@ +{% extends "base.html" %} +{% block content %} +
+
+

Edge 1030 structured workout bridge

+

Dashboard

+
+
+ + +
+
+ +
+
+ Garmin + {{ "Configured" if garmin_configured else "Needs setup" }} +
+
+ Last run + {{ status.last_run.status if status.last_run else "Never" }} +
+
+ Next check + {{ next_run.strftime("%Y-%m-%d %H:%M") if next_run else "Disabled" }} +
+
+ Window + {{ schedule.active_window }} +
+
+ +
+
+

Manual Checks

+
+
+
+ + +
+
+ + +
+
+ + + +
+ Search workouts + Edit schedule +
+
+ +
+
+

Clone State

+
+ + + + + + + + + + + + {% for clone in status.clones %} + + + + + + + + {% else %} + + {% endfor %} + +
DateSourceCloneStatusUpdated
{{ clone.scheduled_date }}{{ clone.source_name or "-" }}{{ clone.clone_workout_name or "-" }}{{ clone.status }}{{ clone.updated_at }}
No generated clones recorded yet.
+
+{% endblock %} diff --git a/templates/garmin.html b/templates/garmin.html new file mode 100644 index 0000000..e8a467b --- /dev/null +++ b/templates/garmin.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% block content %} +
+
+

Garmin Connect

+

Authentication

+
+ {{ "Configured" if configured else "Not configured" }} +
+ +{% if error %}

{{ error }}

{% endif %} +{% if message %}

{{ message }}

{% endif %} + +
+

Garmin Login

+
+ + + +
+
+ +{% if mfa_pending %} +
+

MFA Challenge

+
+ + +
+
+{% endif %} +{% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..3e75f88 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,22 @@ + + + + + + Log In + + + +
+
Coach Clone
+

Sign In

+

Local single-user access for Garmin Coach cloning.

+ {% if error %}

{{ error }}

{% endif %} +
+ + + +
+
+ + diff --git a/templates/logs.html b/templates/logs.html new file mode 100644 index 0000000..fcf3628 --- /dev/null +++ b/templates/logs.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} +{% block content %} +
+
+

Runtime trail

+

Logs

+
+
+ +
+ + + + + + + + + + + + {% for event in events %} + + + + + + + + {% else %} + + {% endfor %} + +
TimeLevelEventDateMessage
{{ event.created_at }}{{ event.level }}{{ event.event_type }}{{ event.scheduled_date or "-" }}{{ event.message }}
No log events yet.
+
+{% endblock %} diff --git a/templates/schedule.html b/templates/schedule.html new file mode 100644 index 0000000..a008efa --- /dev/null +++ b/templates/schedule.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} +{% block content %} +
+
+

Change detection

+

Schedule

+
+
+ +
+
+ +{% if error %}

{{ error }}

{% endif %} +{% if message %}

{{ message }}

{% endif %} + +
+
+

Sync Cadence

+
+
+ +
+ + + +
+ + +
+
+ +
+

Default Behavior

+

All scheduled runs are change-detection runs. They create a missing clone, skip a current clone, and replace only when the Coach source hash changes.

+
+{% endblock %} diff --git a/templates/search.html b/templates/search.html new file mode 100644 index 0000000..a05ba12 --- /dev/null +++ b/templates/search.html @@ -0,0 +1,74 @@ +{% extends "base.html" %} +{% block content %} +
+
+

Garmin Connect

+

Search

+
+
+ +{% if error %}

{{ error }}

{% endif %} + +
+

Filters

+
+ + + + + + +
+
+ +
+

Results

+ + + + + + + + + + + + + {% for item in items %} + + + + + + + + + {% else %} + + {% endfor %} + +
SourceDateSportNameStatusSummary
{{ item.source }}{{ item.date or "-" }}{{ item.sport or "-" }}{{ item.name }}{{ item.status or "-" }}
{{ item.summary or "" }}
No matching Garmin items found.
+
+{% endblock %} diff --git a/templates/setup.html b/templates/setup.html new file mode 100644 index 0000000..6cca7c5 --- /dev/null +++ b/templates/setup.html @@ -0,0 +1,23 @@ + + + + + + Set Up Coach Clone + + + +
+
Coach Clone
+

First Launch

+

Create the local app login. Garmin credentials are configured after this.

+ {% if error %}

{{ error }}

{% endif %} +
+ + + + +
+
+ + diff --git a/templates/trace.html b/templates/trace.html new file mode 100644 index 0000000..d88fb0f --- /dev/null +++ b/templates/trace.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% block content %} +
+
+

Trace #{{ trace.id }}

+

{{ trace.action }} / {{ trace.status }}

+
+ Back to traces +
+ +
+
Date{{ trace.scheduled_date }}
+
Source{{ trace.source_name or "-" }}
+
Clone{{ trace.clone_workout_name or "-" }}
+
Hash{{ trace.source_hash or "-" }}
+
+ +
+

Message

+

{{ trace.message }}

+
+ +{% for key, value in json.items() %} +
+
+

{{ key.replace("_json", "").title() }} JSON

+ Download +
+
{{ value }}
+
+{% endfor %} +{% endblock %} diff --git a/templates/traces.html b/templates/traces.html new file mode 100644 index 0000000..ee46c48 --- /dev/null +++ b/templates/traces.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} +{% block content %} +
+
+

Workout tracing

+

Traces

+
+
+ +
+ + + + + + + + + + + + + {% for trace in traces %} + + + + + + + + + {% else %} + + {% endfor %} + +
TimeDateActionStatusSourceClone
{{ trace.created_at }}{{ trace.scheduled_date }}{{ trace.action }}{{ trace.status }}{{ trace.source_name or "-" }}{{ trace.clone_workout_name or "-" }}
No workout traces yet.
+
+{% endblock %} diff --git a/tests/test_analyze_dump.py b/tests/test_analyze_dump.py new file mode 100644 index 0000000..ac0292c --- /dev/null +++ b/tests/test_analyze_dump.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + + +def test_analyze_dump_finds_and_clones_fixture(tmp_path: Path) -> None: + fixture = tmp_path / "coach.json" + fixture.write_text( + json.dumps( + { + "date": "2026-06-16", + "workout": { + "workoutName": "Coach Ride", + "sportType": {"sportTypeKey": "cycling"}, + "estimatedDurationInSecs": 60, + "workoutSegments": [ + { + "sportType": {"sportTypeKey": "cycling"}, + "workoutSteps": [ + { + "type": "ExecutableStepDTO", + "stepType": {"stepTypeKey": "warmup"}, + "endCondition": {"conditionTypeKey": "time"}, + "endConditionValue": 60, + "targetType": {"workoutTargetTypeKey": "no.target"}, + } + ], + } + ], + }, + } + ), + encoding="utf-8", + ) + + result = subprocess.run( + [ + sys.executable, + "scripts/analyze_dump.py", + str(fixture), + "--date", + "2026-06-16", + "--clone-dry-run", + ], + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0 + assert "Local clone payload passed validation." in result.stdout + assert "GCClone 2026-06-16 Coach Ride" in result.stdout + diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..886ba8a --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import importlib + +from fastapi.testclient import TestClient + + +def test_first_launch_setup_login_and_health(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + monkeypatch.setenv("APP_PASSWORD", "change-me") + monkeypatch.setenv("APP_SECRET_KEY", "test-secret") + module = importlib.import_module("garmin_coach_clone.app") + app = module.create_app() + + with TestClient(app) as client: + health = client.get("/healthz") + assert health.status_code == 200 + assert health.json()["app_configured"] is False + + response = client.post( + "/setup", + data={ + "username": "owner", + "password": "very-secret", + "confirm_password": "very-secret", + }, + follow_redirects=False, + ) + assert response.status_code == 303 + assert "gcc_session" in response.headers["set-cookie"] + + dashboard = client.get("/") + assert dashboard.status_code == 200 + assert "Dashboard" in dashboard.text + + search = client.get("/search") + assert search.status_code == 200 + assert "Garmin credentials are not configured" in search.text + + +def test_env_bootstrap_login(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + monkeypatch.setenv("APP_USERNAME", "admin") + monkeypatch.setenv("APP_PASSWORD", "boot-secret") + monkeypatch.setenv("APP_SECRET_KEY", "test-secret") + module = importlib.import_module("garmin_coach_clone.app") + app = module.create_app() + + with TestClient(app) as client: + health = client.get("/healthz") + assert health.json()["app_configured"] is True + response = client.post( + "/login", + data={"username": "admin", "password": "boot-secret"}, + follow_redirects=False, + ) + assert response.status_code == 303 diff --git a/tests/test_coach.py b/tests/test_coach.py new file mode 100644 index 0000000..c1106f7 --- /dev/null +++ b/tests/test_coach.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from datetime import date + +from garmin_coach_clone.coach import ( + best_workout_for_date, + find_workout_like_objects, + task_workout_for_date, +) + + +def test_best_workout_for_date_requires_date_match() -> None: + plan = { + "phases": [ + { + "scheduledDate": "2026-06-17", + "workout": {"workoutSegments": [{"workoutSteps": []}]}, + } + ] + } + + assert best_workout_for_date(plan, date(2026, 6, 16)) is None + + +def test_best_workout_for_date_returns_nested_matched_workout() -> None: + workout = {"workoutSegments": [{"workoutSteps": []}]} + plan = {"items": [{"date": "2026-06-16", "workout": workout}]} + + assert best_workout_for_date(plan, date(2026, 6, 16)) == workout + + +def test_find_workout_like_objects_finds_segments_and_steps() -> None: + data = { + "a": {"workoutSegments": []}, + "b": [{"workoutSteps": []}], + } + + assert len(find_workout_like_objects(data)) == 2 + + +def test_task_workout_for_date_returns_adaptive_task_workout() -> None: + workout = {"workoutName": "Sprint", "workoutUuid": "abc"} + plan = { + "taskList": [ + {"calendarDate": "2026-06-15", "taskWorkout": {"workoutName": "Base"}}, + {"calendarDate": "2026-06-16", "taskWorkout": workout}, + ] + } + + assert task_workout_for_date(plan, date(2026, 6, 16)) == workout + assert task_workout_for_date(plan, date(2026, 6, 17)) is None diff --git a/tests/test_redact.py b/tests/test_redact.py new file mode 100644 index 0000000..1a73093 --- /dev/null +++ b/tests/test_redact.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from garmin_coach_clone.redact import redact + + +def test_redact_preserves_workout_schema_ids() -> None: + data = { + "stepType": {"stepTypeId": 1, "stepTypeKey": "warmup"}, + "targetType": {"workoutTargetTypeId": 1, "workoutTargetTypeKey": "no.target"}, + "endCondition": {"conditionTypeId": 2, "conditionTypeKey": "time"}, + } + + assert redact(data) == data + + +def test_redact_private_account_and_device_fields() -> None: + data = { + "email": "rider@example.com", + "displayName": "Rider", + "unitId": 123456, + "ownerId": 999, + "deviceId": 888, + } + + redacted = redact(data) + + assert redacted["email"] == "" + assert redacted["displayName"] == "" + assert redacted["unitId"] == "" + assert redacted["ownerId"] == "" + assert redacted["deviceId"] == "" + + +def test_redact_sensitive_values_inside_strings() -> None: + data = { + "message": "Contact rider@example.com with Authorization: Bearer abc.def.ghi", + } + + redacted = redact(data) + + assert "rider@example.com" not in redacted["message"] + assert "abc.def.ghi" not in redacted["message"] + assert "" in redacted["message"] + assert "Bearer " in redacted["message"] + diff --git a/tests/test_repository.py b/tests/test_repository.py new file mode 100644 index 0000000..0ac6256 --- /dev/null +++ b/tests/test_repository.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import pytest + +from garmin_coach_clone.config import DEFAULT_FIXED_TIMES, load_settings +from garmin_coach_clone.db import Database +from garmin_coach_clone.repository import Repository, ScheduleConfig, validate_schedule_config + + +def test_schedule_restore_defaults(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + settings = load_settings() + db = Database(tmp_path / "app.db") + db.initialize() + repo = Repository(db, settings) + + restored = repo.restore_default_schedule() + + assert restored.fixed_times == DEFAULT_FIXED_TIMES + assert repo.schedule_config().active_window == "05:00-22:00" + + +def test_schedule_validation_rejects_bad_window() -> None: + with pytest.raises(ValueError, match="active window"): + validate_schedule_config( + ScheduleConfig( + enabled=True, + interval_minutes=30, + active_window="22:00-05:00", + fixed_times=["05:15"], + days_ahead=1, + ) + ) diff --git a/tests/test_sync_service.py b/tests/test_sync_service.py new file mode 100644 index 0000000..cd07f43 --- /dev/null +++ b/tests/test_sync_service.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +from datetime import date, datetime + +from garmin_coach_clone.config import load_settings +from garmin_coach_clone.db import Database +from garmin_coach_clone.repository import Repository, ScheduleConfig +from garmin_coach_clone.sync_service import SyncService, should_run_now +from garmin_coach_clone.workouts import build_dummy_cycling_workout, workout_source_hash + + +class FakeClient: + def __init__(self) -> None: + self.uploads: list[dict] = [] + self.scheduled: list[tuple[str, str]] = [] + self.unscheduled: list[str] = [] + self.deleted: list[str] = [] + + def get_workouts(self, limit: int = 100) -> list[dict]: + return [] + + def upload_workout(self, payload: dict) -> dict: + self.uploads.append(payload) + return {"workoutId": f"w{len(self.uploads)}"} + + def schedule_workout(self, workout_id: str, date_s: str) -> dict: + self.scheduled.append((workout_id, date_s)) + return {"scheduledWorkoutId": f"s{len(self.scheduled)}"} + + def unschedule_workout(self, scheduled_id: str) -> None: + self.unscheduled.append(scheduled_id) + + def delete_workout(self, workout_id: str) -> None: + self.deleted.append(workout_id) + + +class FakeGarmin: + def __init__(self, source: dict | None) -> None: + self.client = FakeClient() + self.source = source + + def authenticated_client(self) -> FakeClient: + return self.client + + def active_adaptive_plan(self, client: FakeClient) -> dict: + _ = client + return {"trainingPlanId": 1} + + def adaptive_plan_detail(self, client: FakeClient, plan: dict) -> dict: + _ = client, plan + return {} + + def coach_workout_for_date( + self, client: FakeClient, plan_detail: dict, target_date: date + ) -> tuple[dict | None, dict | None]: + _ = client, plan_detail, target_date + if self.source is None: + return {"restDay": True, "workoutUuid": "rest"}, None + return {"restDay": False, "workoutUuid": self.source["workoutUuid"]}, self.source + + +def test_should_run_now_for_interval_and_fixed_times() -> None: + config = ScheduleConfig( + enabled=True, + interval_minutes=30, + active_window="05:00-22:00", + fixed_times=["06:15"], + days_ahead=1, + ) + + assert should_run_now(datetime(2026, 6, 16, 6, 0), config) + assert should_run_now(datetime(2026, 6, 16, 6, 15), config) + assert not should_run_now(datetime(2026, 6, 16, 4, 30), config) + + +def test_sync_creates_missing_clone(tmp_path, monkeypatch) -> None: + service, repo, garmin = _service(tmp_path, monkeypatch, _source("Sprint")) + + result = service._run_sync("test", [date(2026, 6, 16)], dry_run=False) + + assert result["created"] == 1 + assert garmin.client.uploads + mapping = repo.get_clone_mapping("2026-06-16") + assert mapping is not None + assert mapping["status"] == "scheduled" + + +def test_sync_skips_unchanged_clone(tmp_path, monkeypatch) -> None: + source = _source("Sprint") + service, repo, garmin = _service(tmp_path, monkeypatch, source) + repo.upsert_clone_mapping( + { + "scheduled_date": "2026-06-16", + "source_uuid": source["workoutUuid"], + "source_hash": workout_source_hash(source), + "source_name": "Sprint", + "clone_workout_id": "w1", + "clone_workout_name": "GCClone 2026-06-16 Sprint", + "scheduled_workout_id": "s1", + "status": "scheduled", + "message": "current", + } + ) + + result = service._run_sync("test", [date(2026, 6, 16)], dry_run=False) + + assert result["skipped"] == 1 + assert garmin.client.uploads == [] + + +def test_rest_day_with_existing_clone_warns_without_delete(tmp_path, monkeypatch) -> None: + service, repo, garmin = _service(tmp_path, monkeypatch, None) + repo.upsert_clone_mapping( + { + "scheduled_date": "2026-06-16", + "source_uuid": "old", + "source_hash": "old", + "source_name": "Old", + "clone_workout_id": "w1", + "clone_workout_name": "GCClone 2026-06-16 Old", + "scheduled_workout_id": "s1", + "status": "scheduled", + "message": "old", + } + ) + + result = service._run_sync("test", [date(2026, 6, 16)], dry_run=False) + + assert result["warnings"] == 1 + assert garmin.client.unscheduled == [] + assert garmin.client.deleted == [] + + +def _service(tmp_path, monkeypatch, source: dict | None): + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + settings = load_settings() + db = Database(tmp_path / "app.db") + db.initialize() + repo = Repository(db, settings) + garmin = FakeGarmin(source) + return SyncService(settings, repo, garmin), repo, garmin + + +def _source(name: str) -> dict: + workout = build_dummy_cycling_workout(name) + workout["workoutUuid"] = f"uuid-{name}" + return workout diff --git a/tests/test_workouts.py b/tests/test_workouts.py new file mode 100644 index 0000000..2c7b7e8 --- /dev/null +++ b/tests/test_workouts.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from datetime import date + +from garmin_coach_clone.workouts import ( + build_dummy_cycling_workout, + calendar_entry_date, + calendar_entry_id, + calendar_entry_name, + clone_workout_payload, + estimate_duration, + existing_clone_names, + find_generated_calendar_entry, + find_generated_workout, + generated_calendar_entries, + generated_workouts, + validate_workout_payload, +) + + +def test_dummy_workout_has_expected_duration_and_steps() -> None: + workout = build_dummy_cycling_workout("Dummy") + + assert workout["workoutName"] == "Dummy" + assert workout["sportType"]["sportTypeKey"] == "cycling" + assert estimate_duration(workout) == 14 * 60 + assert validate_workout_payload(workout) == [] + + +def test_clone_payload_strips_ids_and_sets_prefix() -> None: + source = build_dummy_cycling_workout("Coach Original") + source["workoutId"] = 123 + source["ownerId"] = 456 + source["workoutSegments"][0]["workoutSteps"][0]["stepId"] = 789 + + cloned = clone_workout_payload(source, date(2026, 6, 16), "GCClone") + + assert cloned["workoutName"] == "GCClone 2026-06-16 Coach Original" + assert "workoutId" not in cloned + assert "ownerId" not in cloned + assert "stepId" not in cloned["workoutSegments"][0]["workoutSteps"][0] + assert validate_workout_payload(cloned) == [] + + +def test_existing_clone_names_filters_by_prefix_and_date() -> None: + names = existing_clone_names( + [ + {"workoutName": "GCClone 2026-06-16 Ride"}, + {"workoutName": "GCClone 2026-06-17 Ride"}, + {"workoutName": "Other 2026-06-16 Ride"}, + ], + date(2026, 6, 16), + "GCClone", + ) + + assert names == ["GCClone 2026-06-16 Ride"] + + +def test_generated_workouts_filters_by_prefix() -> None: + workouts = [ + {"workoutId": 1, "workoutName": "GCClone 2026-06-16 Ride"}, + {"workoutId": 2, "workoutName": "GCClone Probe Dummy 2026-06-16"}, + {"workoutId": 3, "workoutName": "Real Workout"}, + ] + + assert generated_workouts(workouts, "GCClone") == workouts[:2] + assert find_generated_workout(workouts, "2", "GCClone") == workouts[1] + assert find_generated_workout(workouts, "3", "GCClone") is None + + +def test_generated_calendar_entries_handle_nested_workout_names() -> None: + generated_entry = { + "scheduledWorkoutId": 99, + "scheduledDate": "2026-06-16", + "workout": {"workoutName": "GCClone 2026-06-16 Ride"}, + } + data = { + "calendarItems": [ + generated_entry, + { + "scheduledWorkoutId": 100, + "scheduledDate": "2026-06-16", + "workout": {"workoutName": "Real Workout"}, + }, + ] + } + + entries = generated_calendar_entries(data, "GCClone") + + assert entries == [generated_entry] + assert find_generated_calendar_entry(data, "99", "GCClone") == generated_entry + assert find_generated_calendar_entry(data, "100", "GCClone") is None + assert calendar_entry_id(generated_entry) == "99" + assert calendar_entry_date(generated_entry) == "2026-06-16" + assert calendar_entry_name(generated_entry) == "GCClone 2026-06-16 Ride" + + +def test_validate_workout_payload_rejects_missing_segments() -> None: + errors = validate_workout_payload( + { + "workoutName": "Broken", + "sportType": {"sportTypeKey": "cycling"}, + "workoutSegments": [], + } + ) + + assert "workoutSegments must be a non-empty list" in errors + + +def test_validate_workout_payload_rejects_non_cycling() -> None: + workout = build_dummy_cycling_workout("Broken") + workout["sportType"] = {"sportTypeKey": "running"} + + errors = validate_workout_payload(workout) + + assert "sportType must be cycling, got running" in errors + + +def test_validate_workout_payload_rejects_missing_step_target() -> None: + workout = build_dummy_cycling_workout("Broken") + workout["workoutSegments"][0]["workoutSteps"][0].pop("targetType") + + errors = validate_workout_payload(workout) + + assert "workoutSegments[1].workoutSteps[1].targetType is missing" in errors diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..e5d8b21 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1063 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[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 = "anyio" +version = "4.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/b5/001890774a9552aff22502b8da382593109ce0c95314abaebbb116567545/anyio-4.14.0.tar.gz", hash = "sha256:b47c1f9ccf73e67021df785332508f99379c68fa7d0684e8e3492cb1d4b23f89", size = 253586, upload-time = "2026-06-15T22:00:49.021Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/16/9826f089383c593cdfc4a6e5aca94d9e91ae1692c57af82c3b2aa5e810f7/anyio-4.14.0-py3-none-any.whl", hash = "sha256:dd9b7a2a9799ed6552fde617b2c5df02b7fdd7d88392fc48101e51bae46164d9", size = 123506, upload-time = "2026-06-15T22:00:47.595Z" }, +] + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, +] + +[[package]] +name = "curl-cffi" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "cffi" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/5b/89fcfebd3e5e85134147ac99e9f2b2271165fd4d71984fc65da5f17819b7/curl_cffi-0.15.0.tar.gz", hash = "sha256:ea0c67652bf6893d34ee0f82c944f37e488f6147e9421bef1771cc6545b02ded", size = 196437, upload-time = "2026-04-03T11:12:31.525Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/42/54ddd442c795f30ce5dd4e49f87ce77505958d3777cd96a91567a3975d2a/curl_cffi-0.15.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:bda66404010e9ed743b1b83c20c86f24fe21a9a6873e17479d6e67e29d8ded28", size = 2795267, upload-time = "2026-04-03T11:11:46.48Z" }, + { url = "https://files.pythonhosted.org/packages/83/2d/3915e238579b3c5a92cead5c79130c3b8d20caaba7616cc4d894650e1d6b/curl_cffi-0.15.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a25620d9bf989c9c029a7d1642999c4c265abb0bad811deb2f77b0b5b2b12e5b", size = 2573544, upload-time = "2026-04-03T11:11:47.951Z" }, + { url = "https://files.pythonhosted.org/packages/2a/b3/9d2f1057749a1b07ba1989db3c1503ce8bed998310bae9aea2c43aa64f20/curl_cffi-0.15.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:582e570aa2586b96ed47cf4a17586b9a3c462cbe43f780487c3dc245c6ef1527", size = 10515369, upload-time = "2026-04-03T11:11:50.126Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1d/6d10dded5ce3fd8157e558ebd97d09e551b77a62cdc1c31e93d0a633cee5/curl_cffi-0.15.0-cp310-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:838e48212447d9c81364b04707a5c861daf08f8320f9ecb3406a8919d1d5c3b3", size = 10160045, upload-time = "2026-04-03T11:11:52.664Z" }, + { url = "https://files.pythonhosted.org/packages/5c/12/c70b835487ace3b9ba1502631912e3440082b8ae3a162f60b59cb0b6444d/curl_cffi-0.15.0-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b6c847d86283b07ae69bb72c82eb8a59242277142aa35b89850f89e792a02fc", size = 11090433, upload-time = "2026-04-03T11:11:55.049Z" }, + { url = "https://files.pythonhosted.org/packages/ea/0d/78edcc4f71934225db99df68197a107386d59080742fc7bf6bb4d007924f/curl_cffi-0.15.0-cp310-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e5e69eee735f659287e2c84444319d68a1fa68dd37abf228943a4074864283a", size = 10479178, upload-time = "2026-04-03T11:11:57.685Z" }, + { url = "https://files.pythonhosted.org/packages/5b/84/1e101c1acb1ea2f0b4992f5c3024f596d8e21db0d53540b9d583f673c4e7/curl_cffi-0.15.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa1323950224db24f4c510d010b3affa02196ca853fb424191fa917a513d3f4b", size = 10317051, upload-time = "2026-04-03T11:12:00.295Z" }, + { url = "https://files.pythonhosted.org/packages/28/42/8ef236b22a6c23d096c85a1dc507efe37bfdfc7a2f8a4b34efb590197369/curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:41f80170ba844009273b2660da1964ec31e99e5719d16b3422ada87177e32e13", size = 11299660, upload-time = "2026-04-03T11:12:02.791Z" }, + { url = "https://files.pythonhosted.org/packages/1d/01/56aeb055d962da87a1be0d74c6c644e251c7e88129b5471dc44ac724e678/curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1977e1e12cfb5c11352cbb74acef1bed24eb7d226dab61ca57c168c21acd4d61", size = 11945049, upload-time = "2026-04-03T11:12:05.912Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8c/2abf99a38d6340d66cf0557e0c750ef3f8883dfc5d450087e01c85861343/curl_cffi-0.15.0-cp310-abi3-win_amd64.whl", hash = "sha256:5a0c1896a0d5a5ac1eb89cd24b008d2b718dd1df6fd2f75451b59ca66e49e572", size = 1661649, upload-time = "2026-04-03T11:12:07.948Z" }, + { url = "https://files.pythonhosted.org/packages/3d/39/dfd54f2240d3a9b96d77bacc62b97813b35e2aa8ecf5cd5013c683f1ba96/curl_cffi-0.15.0-cp310-abi3-win_arm64.whl", hash = "sha256:a6d57f8389273a3a1f94370473c74897467bcc36af0a17336989780c507fa43d", size = 1410741, upload-time = "2026-04-03T11:12:10.073Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/c24df8a4fc22fa84070dcd94abeba43c15e08cc09e35869565c0bad196fd/curl_cffi-0.15.0-cp313-abi3-android_24_arm64_v8a.whl", hash = "sha256:4682dc38d4336e0eb0b185374db90a760efde63cbea994b4e63f3521d44c4c92", size = 7190427, upload-time = "2026-04-03T11:12:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/11/56/132225cb3491d07cc6adcce5fe395e059bde87c68cff1ef87a31c88c7819/curl_cffi-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:967ad7355bd8e9586f8c2d02eaa99953747549e7ea4a9b25cd53353e6b67fe6d", size = 2795723, upload-time = "2026-04-03T11:12:13.668Z" }, + { url = "https://files.pythonhosted.org/packages/07/8f/f4f83cd303bef7e8f1749512e5dd157e7e5d08b0a36c8211f9640a2757bf/curl_cffi-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7e63539d0d839d0a8c5eacf86229bc68c57803547f35e0db7ee0986328b478c3", size = 2573739, upload-time = "2026-04-03T11:12:15.08Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5c/643d65c7fc9acd742876aa55c2d7823c438cb7665810acd2e66c9976c4d9/curl_cffi-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08c799b89740b9bc49c09fbc3d5907f13ac1f845ca52620507ef9466d4639dd5", size = 10521046, upload-time = "2026-04-03T11:12:17.034Z" }, + { url = "https://files.pythonhosted.org/packages/7f/0b/9b8037113c93f4c5323096163471fa7c35c7676c3f608eeaf1287cd99d58/curl_cffi-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b7a92767a888ee90147e18964b396d8435ff42737030d6fb00824ffd6094805", size = 11096115, upload-time = "2026-04-03T11:12:19.694Z" }, + { url = "https://files.pythonhosted.org/packages/5f/96/fff2fcbd924ef4042e0d67379f751a8a4e3186a91e75e35a4cf218b306ee/curl_cffi-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:829cc357061ecb99cc2d406301f609a039e05665322f5c025ec67c38b0dc49ce", size = 11305346, upload-time = "2026-04-03T11:12:22.151Z" }, + { url = "https://files.pythonhosted.org/packages/53/1b/304b253a45ab28691c8c5e8cca1e6cbb9cf8e46dfceae4648dd536f75e73/curl_cffi-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:408d6f14e346841cd889c2e0962832bb235ba3b6749ebf609f347f747da5e60f", size = 11949834, upload-time = "2026-04-03T11:12:24.986Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ff/4723d92f08259c707a974aba27a08d0a822b9555e35ca581bf18d055a364/curl_cffi-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b624c7ce087bfda967a013ed0a64702a525444e5b6e97d23534d567ccc6525aa", size = 1702771, upload-time = "2026-04-03T11:12:28.201Z" }, + { url = "https://files.pythonhosted.org/packages/59/8c/36bbe06d66fa2b765e4a07199f643a59a9cd1a754207a96335402a9520f4/curl_cffi-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0b6c0543b993996670e9e4b78e305a2d60809d5681903ffb5568e21a387434d3", size = 1466312, upload-time = "2026-04-03T11:12:30.054Z" }, +] + +[[package]] +name = "fastapi" +version = "0.137.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/b1/e5b92c59d2c37817e77c1a8c2fc1f79cdcc04c68253e5406b43e3204cba7/fastapi-0.137.1.tar.gz", hash = "sha256:822360704230d9533d8d9475399613525968aa2f0b5bd2a3ccc9f18c88fd541c", size = 408293, upload-time = "2026-06-15T11:28:20.79Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/380b9a5922f4340e51c309cde09e5bd32e62f02302971bee30dc15aa0624/fastapi-0.137.1-py3-none-any.whl", hash = "sha256:64f6983c59e45c4b9fdc44e57cb8035c2451ee91ea8e8ec042aca37de7cf6b69", size = 121877, upload-time = "2026-06-15T11:28:19.523Z" }, +] + +[[package]] +name = "garmin-coach-to-cal-sync" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "cryptography" }, + { name = "fastapi" }, + { name = "garminconnect", extra = ["workout"] }, + { name = "jinja2" }, + { name = "python-dotenv" }, + { name = "python-multipart" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "pytest" }, + { name = "ruff" }, + { name = "ty" }, +] + +[package.metadata] +requires-dist = [ + { name = "cryptography", specifier = ">=43.0.0" }, + { name = "fastapi", specifier = ">=0.115.0" }, + { name = "garminconnect", extras = ["workout"], specifier = ">=0.2.28" }, + { name = "jinja2", specifier = ">=3.1.4" }, + { name = "python-dotenv", specifier = ">=1.0.1" }, + { name = "python-multipart", specifier = ">=0.0.9" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx", specifier = ">=0.27.0" }, + { name = "pytest", specifier = ">=8.2" }, + { name = "ruff", specifier = ">=0.8.0" }, + { name = "ty", specifier = ">=0.0.1a1" }, +] + +[[package]] +name = "garminconnect" +version = "0.3.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "curl-cffi" }, + { name = "requests" }, + { name = "ua-generator" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/29/f8e3609f22693020dec65d6285adf12daac459461a09e59dffe12ec7fc08/garminconnect-0.3.6.tar.gz", hash = "sha256:6f90aa282976f7dff880cf53ef4f3ea0d63701fd2a1488c628a66a2a027c2143", size = 61911, upload-time = "2026-06-14T08:05:37.349Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/f9/6d69eb2eab9f62ed7e415a606c5ef63e19f27e561d15d7ac7d651997cfad/garminconnect-0.3.6-py3-none-any.whl", hash = "sha256:e05782ab90e63cb9c023407899e2d12dd18132316fd84394ebecd393809d4ffe", size = 57555, upload-time = "2026-06-14T08:05:36.027Z" }, +] + +[package.optional-dependencies] +workout = [ + { name = "pydantic" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.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/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[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/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { 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.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/a9/3abdf488f1bf3d24c699415e454ed554a6350d5d89ce183be1ee0a3361ac/ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219", size = 4743346, upload-time = "2026-06-11T17:54:47.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/4d/e11259f5da07cb6afb2d074c31bf09da9671993f7329d4f15d2fdc458301/ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f", size = 10856677, upload-time = "2026-06-11T17:54:49.533Z" }, + { url = "https://files.pythonhosted.org/packages/29/3e/772d679e1a0dc058e58875bd2c0cb713a0530877b4a76fee3c7966df0d49/ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7", size = 11223443, upload-time = "2026-06-11T17:55:00.573Z" }, + { url = "https://files.pythonhosted.org/packages/68/58/bd41f7688b2fd5623012605130ed70e60aa7f2244baa3d5066bdd61530c8/ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d", size = 10566458, upload-time = "2026-06-11T17:55:07.52Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5b/733371013fcf1ec339e477ece6ab42bfe10bdd9bba8ee88a9516aa56bfc0/ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed", size = 10914483, upload-time = "2026-06-11T17:55:05.501Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cc/6f24251cc0252f7239391ccb85833f320efad14ebe5b443943f37ced6332/ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e", size = 10647497, upload-time = "2026-06-11T17:54:57.733Z" }, + { url = "https://files.pythonhosted.org/packages/68/dd/0d10c17ce1a1624d6fc3156309c3f834fdb5dfaad026ec90c85684f3990e/ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d", size = 11416967, upload-time = "2026-06-11T17:54:51.461Z" }, + { url = "https://files.pythonhosted.org/packages/2f/91/556bfb156f6144f355e831c23db00b2fc4120f86b3ce81cc5f7fd2df51f3/ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c", size = 12335770, upload-time = "2026-06-11T17:54:45.793Z" }, + { url = "https://files.pythonhosted.org/packages/88/82/8b5999aa13355e926f06d9f42a32dcca862f623bf0363785ff89d607dffd/ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978", size = 11575441, upload-time = "2026-06-11T17:54:32.661Z" }, + { url = "https://files.pythonhosted.org/packages/11/93/f10377bb04109ca0e8cbc483ff1982c54b6d418210041776f93e8cdc7fa9/ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719", size = 11557614, upload-time = "2026-06-11T17:54:34.698Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a6/eeeae7f7d5493df41649ab3db92f086b2d0a30199e4efdf8e3dd7a033f24/ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4", size = 11544450, upload-time = "2026-06-11T17:54:39.042Z" }, + { url = "https://files.pythonhosted.org/packages/32/88/5991ce565129a24dd4a00db1254b3b5db2e53018cbe4018ea5a89738e727/ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7", size = 10892524, upload-time = "2026-06-11T17:55:09.432Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1d/0fdd248313425f55223968af04b0a42125466a8d88d21c1d99c6af0a51e8/ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f", size = 10659573, upload-time = "2026-06-11T17:54:36.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/0e/072e8260deb9461062ce9311ced27a8e541229a6ffd483013dd37661e43e/ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f", size = 11127818, upload-time = "2026-06-11T17:55:03.124Z" }, + { url = "https://files.pythonhosted.org/packages/ab/b4/55060a34163121498014696b5f656db5b8c6963768f227dbf0d76b311073/ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8", size = 11655901, upload-time = "2026-06-11T17:54:53.562Z" }, + { url = "https://files.pythonhosted.org/packages/49/71/9b29d6b87cef468d697f43c6a91e3fae4a80185779d7d5a4ef27d173439f/ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392", size = 10925574, upload-time = "2026-06-11T17:54:55.723Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b2/8fc77f3723228836fa5d12497eb71c808f83782e10d058d2b15cfa14640b/ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084", size = 12058788, upload-time = "2026-06-11T17:54:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "ty" +version = "0.0.49" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/8d/37cb91808069509d43a2a11743e12f1e854fd808dbef2203309d256718cd/ty-0.0.49.tar.gz", hash = "sha256:0a027bd0c9c75d035641a365d087ad883446057f9be0b9826251c2aecafbf145", size = 5884753, upload-time = "2026-06-12T03:08:20.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/de/9237c6a96356612dd0393db1e94cf21f903616adf3a3701bf3da6e4adc92/ty-0.0.49-py3-none-linux_armv6l.whl", hash = "sha256:12c0c4310b936d762a8586c210b53d4fa4bb361a04429afa89bf84b922e5e065", size = 11834671, upload-time = "2026-06-12T03:07:53.062Z" }, + { url = "https://files.pythonhosted.org/packages/8f/15/daf5a14a5e07012277d450c75325c94614e2acfec4c620c881486118c410/ty-0.0.49-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:737bfdc2caf9712a8580944dcdc80a450a37a4f2bc83c8fa9b7433b374f9e471", size = 11589570, upload-time = "2026-06-12T03:08:25.779Z" }, + { url = "https://files.pythonhosted.org/packages/7d/58/30bdf98436488aca25f0763bf7f92a061528d42461b686453029e845e4c5/ty-0.0.49-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ab90c1baf3b1701d282fce4b02fa552a962d109f8972c46ef6b22429503bfea4", size = 10985236, upload-time = "2026-06-12T03:08:36.664Z" }, + { url = "https://files.pythonhosted.org/packages/22/45/ece503e4a1396e13a1a9a0cde51afe476a6506a1d557eeadf8ad45c83bc0/ty-0.0.49-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4ce8ecf6ba6fc79bd137cc0557a754f7e5f2dfe9436412551d480d680e248ad", size = 11504302, upload-time = "2026-06-12T03:08:01.664Z" }, + { url = "https://files.pythonhosted.org/packages/17/dc/5d09333d289dfbca1804eaade125c9e8a1a992a2a592a8b80c5e9b589ca9/ty-0.0.49-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:10d85c6865c984e78661e0bd20b180514b4a289739224e84816e342bdf381e04", size = 11626629, upload-time = "2026-06-12T03:08:06.844Z" }, + { url = "https://files.pythonhosted.org/packages/f2/36/155f41c9dd7237c4b609211f29f77755a139ee6218605dadc7fe21d5e3c8/ty-0.0.49-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d96a67a206619e01fa92f35a22267ec634bba62be24b1d0e947020cc179995b", size = 12074481, upload-time = "2026-06-12T03:08:09.643Z" }, + { url = "https://files.pythonhosted.org/packages/96/4c/998ee13cd5045f1f8b36982de7343163832ac53f27debe01b0de0e8bd968/ty-0.0.49-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3de9f648564e0a66344ef397770387cb0d093735f8679d2c5a08a4741e79814d", size = 12678042, upload-time = "2026-06-12T03:08:39.319Z" }, + { url = "https://files.pythonhosted.org/packages/85/c9/9a505aba85c41ce54cbcaa14f8d79aa084b86151d2d70df11c4655b92898/ty-0.0.49-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5779179ab397d15f8c9dbb8f506ec1b1745f54eac639982f76ef3ce538943b50", size = 12316194, upload-time = "2026-06-12T03:08:18.023Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b8/ded37fb93503294abbc83c36470bb1413bea05048b745881d4470b518a06/ty-0.0.49-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:792d4974e93cc09bd32f934586080bbbe21b8e777099cb521cb2de18b68a49f0", size = 12145507, upload-time = "2026-06-12T03:07:56.505Z" }, + { url = "https://files.pythonhosted.org/packages/2f/07/392e80d78f02445f695b815bb9eb0fffacda68b03faee38c900f7b990815/ty-0.0.49-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:727bda86deb136073e525c2e78d60e38aedcce5d80579170844a52bbf7c1440d", size = 12365967, upload-time = "2026-06-12T03:08:12.553Z" }, + { url = "https://files.pythonhosted.org/packages/50/d3/31b0c2a7fbedd3373e389cb1d81b8d2128f6f868fafb46557736a6f9aca8/ty-0.0.49-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4f2fc2bc4a8d2ff1cca59fd94772cabdfec4062d47a0b3a0784be46d94d0540b", size = 11475283, upload-time = "2026-06-12T03:08:28.334Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5b/329e101638920b468a3bb63059c9f66ef99b44aac501222c44832a507321/ty-0.0.49-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:3724bd9badef333321578b6a941fbc571ebf49141ec2356a8590fbe4c9aa588d", size = 11645343, upload-time = "2026-06-12T03:08:15.246Z" }, + { url = "https://files.pythonhosted.org/packages/a9/76/c897e615e32f80ca81c8c1bc49b9a1f72ff9e3cfea0f8345ba505fe28472/ty-0.0.49-py3-none-musllinux_1_2_i686.whl", hash = "sha256:166c6eb52ee4af3c5a9bb267d165d93000daa55c6758cd8ff3199741fb75917d", size = 11725585, upload-time = "2026-06-12T03:08:33.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/e1/fdb42ee239f618800842681af5bb8598117e74512c10974a8b7b9086a898/ty-0.0.49-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:91e81d832c287b05782ee32eb1b801f62c1fa08df37d589d2b88c3f1d51c9731", size = 12237261, upload-time = "2026-06-12T03:08:31.105Z" }, + { url = "https://files.pythonhosted.org/packages/98/0f/a2d6a5fc9d0786cbeb3c200786da4e18c203589be3984bb5def83ca92320/ty-0.0.49-py3-none-win32.whl", hash = "sha256:7186af5ca9829d1f5d8916bcf767b8e819bfbf61b1b8ec843bb3fc699cb502e1", size = 11100789, upload-time = "2026-06-12T03:07:59.092Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9d/473ac8bc57b5a2d121da893bf9dd74a118efb19a01d711df1a6e397f05cc/ty-0.0.49-py3-none-win_amd64.whl", hash = "sha256:ae2142fc126a01effcca0c222908b0e6654b5ba1266d4e4d406e4866aef8e1d1", size = 12204644, upload-time = "2026-06-12T03:08:04.327Z" }, + { url = "https://files.pythonhosted.org/packages/ef/a2/8959249da951ba3977fee20e688d28678b8a1d30a9ed4464228a85d45853/ty-0.0.49-py3-none-win_arm64.whl", hash = "sha256:75d5e2e7649765f31f4bed6c8adb149a75b18edd3fa6336dac4d0efc1a66466f", size = 11558965, upload-time = "2026-06-12T03:08:23.012Z" }, +] + +[[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 = "ua-generator" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/42/5c/9aece80ddd5272e8174e87370a8a92099e8008081a9fb3e2156d3ff5b60c/ua_generator-2.1.2.tar.gz", hash = "sha256:f28c8050461cce6fb209acb861a8d65bab84654842e546a6b4a4b07fb220865b", size = 29646, upload-time = "2026-06-14T23:22:19.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/85/07aa75d9e65fadd162bda4a6ffb22b574f4f042b4010efe6d74c9a9e91b8/ua_generator-2.1.2-py3-none-any.whl", hash = "sha256:18a52f4bebdc43963defb071cd66a2a68f84607767403d425a9fcf7542aeb727", size = 32776, upload-time = "2026-06-14T23:22:18.663Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.49.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +]