Add multi-user support: Authelia OIDC, SQLite, Garage S3

- Auth via Authelia OIDC (public client + PKCE) with signed session cookies
- SQLite DB for users, rides, and insights (SQLAlchemy)
- Garage S3 for GPX file storage (boto3)
- All API endpoints scoped to authenticated user
- Dockerfile + docker-compose.yml with Garage service
- Frontend auth check: redirects to /auth/login on 401, shows user badge

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 22:12:23 -04:00
parent 65a1bb010d
commit c6d9a3b8b6
12 changed files with 853 additions and 134 deletions
+9
View File
@@ -0,0 +1,9 @@
.venv
.env
bikeslop.db
garmin_gpx_exports/*.gpx
insights.json
__pycache__
*.pyc
*.pyo
.git
+5
View File
@@ -15,3 +15,8 @@ insights.json
# Local environment # Local environment
.env .env
# Runtime data
data/
bikeslop.db
*.swp
+38 -13
View File
@@ -13,26 +13,51 @@ uv add <package> # add a new dependency
Set `LLAMACPP_BASE_URL=http://host:port/v1` to point at a local LLM (inline, exported, or via `.env`). Set `LLAMACPP_BASE_URL=http://host:port/v1` to point at a local LLM (inline, exported, or via `.env`).
## Environment variables
```
# Auth (Authelia OIDC — public client with PKCE, no secret)
OIDC_ISSUER=https://auth.example.com
OIDC_CLIENT_ID=bikeslop
APP_BASE_URL=https://bikeslop.example.com
SECRET_KEY=<64-char random hex> # signs session cookies
# S3 (Garage or any S3-compatible)
S3_ENDPOINT=https://s3.example.com
S3_BUCKET=bikeslop-gpx
S3_ACCESS_KEY=<key>
S3_SECRET_KEY=<secret>
# Optional
DATABASE_URL=sqlite:///bikeslop.db # default
LLAMACPP_BASE_URL=http://localhost:8080/v1
```
## Architecture ## Architecture
Two-file app: a FastAPI backend (`app.py`) and a single-page frontend (`static/index.html`). Four-file app: `app.py` (FastAPI backend), `db.py` (SQLAlchemy models), `auth.py` (OIDC + session), `storage.py` (S3 wrapper), and `static/index.html` (SPA frontend).
**`app.py`** parses all GPX files in `garmin_gpx_exports/` at startup into an in-memory dict (`_rides`), keyed by filename stem. Stats (haversine distance, elevation gain/loss, HR zones) are computed once at load time. API routes: **`db.py`** defines SQLAlchemy models (`User`, `Ride`, `Insight`) backed by SQLite (or any SQLAlchemy-compatible DB). Tables are created at startup via `create_tables()`. Ride IDs are UUIDs generated at insert time.
- `GET /api/rides` — summary list (no track points)
- `GET /api/rides/{id}` — full ride metadata + cached insight **`auth.py`** handles Authelia OIDC login via `authlib`. Session is a signed cookie (itsdangerous) containing the user UUID — no server-side session storage. Routes: `GET /auth/login`, `GET /auth/callback`, `GET /auth/logout`. The `require_user` FastAPI dependency reads the cookie and raises 401 if not authenticated.
- `GET /api/rides/{id}/points` — full lat/lon/ele/hr array
- `POST /api/rides/{id}/insights` — streams AI coaching response **`storage.py`** wraps boto3 for Garage S3. GPX files are stored at `users/{user_id}/{filename}.gpx`. Raises `RuntimeError` on first use if env vars are not configured.
- `POST /api/insights/generate-all` — SSE stream, generates insights for all rides in chronological order
- `POST /api/fetch-rides` — SSE stream, fetches new rides from Garmin Connect (supports MFA) **`app.py`** — all API endpoints require auth (`Depends(require_user)`) and are scoped to the current user. GPX metadata is stored in the DB at upload time; raw GPX bytes live in S3 and are fetched on demand for the `/points` endpoint and insight generation. In-memory caches: `_user_rides_cache` (ride metadata per user) and `_points_cache` (LRU, 50 entries) to avoid repeated S3 fetches. API routes:
- `GET /api/me` — current user info
- `GET /api/rides` — summary list for current user
- `GET /api/rides/{id}` — ride metadata + insight from DB
- `GET /api/rides/{id}/points` — downloads GPX from S3, parses, returns lat/lon/ele/hr array
- `POST /api/rides/{id}/insights` — streams AI coaching, saves to DB
- `POST /api/insights/generate-all` — SSE stream, generates insights for all rides without one
- `POST /api/fetch-rides` — SSE stream, fetches from Garmin → uploads to S3 → inserts into DB
- `POST /api/mfa` — submits MFA code for an in-progress Garmin session - `POST /api/mfa` — submits MFA code for an in-progress Garmin session
**AI integration** uses the OpenAI Python SDK pointed at a local LLM endpoint (`LLAMACPP_BASE_URL` env var, defaults to `http://localhost:8080/v1`). The model name is auto-discovered from `GET /v1/models` at startup (falls back to `"local-model"`). If the endpoint is unreachable at startup a warning is logged and the app continues — only insight generation is affected. The chunk reader handles both `content` and `reasoning_content` delta fields for reasoning models. **AI integration** uses the OpenAI Python SDK pointed at a local LLM endpoint (`LLAMACPP_BASE_URL` env var, defaults to `http://localhost:8080/v1`). The model name is auto-discovered from `GET /v1/models` at startup (falls back to `"local-model"`). The chunk reader handles both `content` and `reasoning_content` delta fields for reasoning models.
**Insights cache** is persisted to `insights.json` (gitignored) and loaded at startup. The prompt includes the previous ride's cached insight so the coach can reference its own prior advice. **`static/index.html`** is a self-contained SPA (no build step). On load it calls `GET /api/me`; a 401 redirects to `/auth/login`. Leaflet.js draws the route as per-segment polylines colored by HR intensity zone. Chart.js renders elevation and HR profiles. AI insights stream via `fetch` + `ReadableStream`. Units (km/mi) toggle and resting HR are persisted in `localStorage`.
**`static/index.html`** is a self-contained SPA (no build step). Leaflet.js draws the route as per-segment polylines colored by HR intensity zone. Chart.js renders elevation and HR profiles against cumulative distance. AI insights stream via `fetch` + `ReadableStream`. Units (km/mi) toggle and resting HR are persisted in `localStorage`. **`main.py`** is a standalone CLI script for bulk-downloading Garmin Connect GPX files (single-user, pre-auth era — largely superseded by the in-app Garmin sync).
**`main.py`** is a standalone CLI script for bulk-downloading Garmin Connect GPX files without running the web server.
## GPX data notes ## GPX data notes
+22
View File
@@ -0,0 +1,22 @@
FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim
WORKDIR /app
ENV UV_COMPILE_BYTECODE=1
ENV UV_LINK_MODE=copy
# Install dependencies (cached layer — only reruns when lock file changes)
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --frozen --no-install-project --no-dev
COPY . .
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev
ENV PATH="/app/.venv/bin:$PATH"
EXPOSE 8000
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
+186 -104
View File
@@ -9,11 +9,18 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from xml.etree import ElementTree as ET from xml.etree import ElementTree as ET
from fastapi import FastAPI, HTTPException, Query, Request from fastapi import Depends, FastAPI, HTTPException, Query, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from garminconnect import Garmin, GarminConnectAuthenticationError from garminconnect import Garmin, GarminConnectAuthenticationError
from openai import OpenAI from openai import OpenAI
from sqlalchemy import and_, select
from sqlalchemy.orm import Session
from starlette.middleware.sessions import SessionMiddleware
from auth import require_user, router as auth_router
from db import Insight, Ride, User, create_tables, engine
from storage import download_gpx, upload_gpx
logging.basicConfig( logging.basicConfig(
level=logging.DEBUG, level=logging.DEBUG,
@@ -24,19 +31,18 @@ for _noisy in ("httpx", "httpcore", "openai"):
logging.getLogger(_noisy).setLevel(logging.WARNING) logging.getLogger(_noisy).setLevel(logging.WARNING)
log = logging.getLogger("bikeslop") log = logging.getLogger("bikeslop")
GPX_DIR = Path(__file__).parent / "garmin_gpx_exports"
INSIGHTS_FILE = Path(__file__).parent / "insights.json"
LLAMACPP_BASE_URL = os.getenv("LLAMACPP_BASE_URL", "http://localhost:8080/v1") LLAMACPP_BASE_URL = os.getenv("LLAMACPP_BASE_URL", "http://localhost:8080/v1")
MODEL_NAME = "local-model" # overwritten at startup by auto-discovery SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-change-me")
MODEL_NAME = "local-model"
app = FastAPI() app = FastAPI()
app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY)
app.include_router(auth_router)
_insights_cache: dict[str, str] = {}
_mfa_sessions: dict[str, dict] = {} _mfa_sessions: dict[str, dict] = {}
_user_rides_cache: dict[str, dict[str, dict]] = {}
_points_cache: dict[str, list[dict]] = {}
def _save_insights() -> None: _POINTS_CACHE_MAX = 50
INSIGHTS_FILE.write_text(json.dumps(_insights_cache, indent=2, ensure_ascii=False))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# GPX parsing helpers # GPX parsing helpers
@@ -49,7 +55,6 @@ NS = {
def haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float: def haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Return distance in metres between two GPS coordinates."""
R = 6_371_000 R = 6_371_000
phi1, phi2 = math.radians(lat1), math.radians(lat2) phi1, phi2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1) dphi = math.radians(lat2 - lat1)
@@ -58,12 +63,13 @@ def haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
return 2 * R * math.asin(math.sqrt(a)) return 2 * R * math.asin(math.sqrt(a))
def parse_gpx(path: Path) -> dict: def parse_gpx_bytes(data: bytes, filename: str) -> dict:
tree = ET.parse(path) """Parse GPX bytes into a ride dict including 'points'. No 'id' field."""
root = tree.getroot() root = ET.fromstring(data)
name_el = root.find(".//gpx:trk/gpx:name", NS) name_el = root.find(".//gpx:trk/gpx:name", NS)
name = name_el.text if name_el is not None else path.stem stem = Path(filename).stem
name = name_el.text if name_el is not None else stem
type_el = root.find(".//gpx:trk/gpx:type", NS) type_el = root.find(".//gpx:trk/gpx:type", NS)
activity_type = type_el.text if type_el is not None else "cycling" activity_type = type_el.text if type_el is not None else "cycling"
@@ -81,7 +87,6 @@ def parse_gpx(path: Path) -> dict:
hr = int(hr_el.text) if hr_el is not None else None hr = int(hr_el.text) if hr_el is not None else None
points.append({"lat": lat, "lon": lon, "ele": ele, "time": ts, "hr": hr}) points.append({"lat": lat, "lon": lon, "ele": ele, "time": ts, "hr": hr})
# Compute stats
distance_m = 0.0 distance_m = 0.0
ele_gain = 0.0 ele_gain = 0.0
ele_loss = 0.0 ele_loss = 0.0
@@ -105,10 +110,8 @@ def parse_gpx(path: Path) -> dict:
hrs = [p["hr"] for p in points if p["hr"] is not None] hrs = [p["hr"] for p in points if p["hr"] is not None]
avg_hr = round(sum(hrs) / len(hrs)) if hrs else None avg_hr = round(sum(hrs) / len(hrs)) if hrs else None
max_hr = max(hrs) if hrs else None max_hr = max(hrs) if hrs else None
avg_speed_kmh = (distance_m / 1000) / (duration_s / 3600) if duration_s > 0 else 0 avg_speed_kmh = (distance_m / 1000) / (duration_s / 3600) if duration_s > 0 else 0
# HR zone distribution (% of points)
zone_counts = {"z1": 0, "z2": 0, "z3": 0, "z4": 0, "z5": 0} zone_counts = {"z1": 0, "z2": 0, "z3": 0, "z4": 0, "z5": 0}
for h in hrs: for h in hrs:
if h < 120: if h < 120:
@@ -127,13 +130,10 @@ def parse_gpx(path: Path) -> dict:
for k, v in zone_counts.items() for k, v in zone_counts.items()
} }
ride_id = path.stem # use filename without extension as ID
return { return {
"id": ride_id,
"name": name, "name": name,
"activity_type": activity_type, "activity_type": activity_type,
"filename": path.name, "filename": filename,
"start_time": start_time.isoformat() if start_time else None, "start_time": start_time.isoformat() if start_time else None,
"end_time": end_time.isoformat() if end_time else None, "end_time": end_time.isoformat() if end_time else None,
"duration_s": duration_s, "duration_s": duration_s,
@@ -160,16 +160,64 @@ def parse_gpx(path: Path) -> dict:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Load all rides at startup # Per-user ride + points cache
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_rides: dict[str, dict] = {}
def _load_user_rides(user_id: str) -> dict[str, dict]:
with Session(engine) as s:
rows = s.query(Ride).filter_by(user_id=user_id).all()
return {r.id: r.to_dict() for r in rows}
def get_user_rides_cached(user_id: str) -> dict[str, dict]:
if user_id not in _user_rides_cache:
_user_rides_cache[user_id] = _load_user_rides(user_id)
return _user_rides_cache[user_id]
def invalidate_user_cache(user_id: str) -> None:
_user_rides_cache.pop(user_id, None)
def _fetch_ride_points(ride_id: str, s3_key: str) -> list[dict]:
if ride_id not in _points_cache:
if len(_points_cache) >= _POINTS_CACHE_MAX:
del _points_cache[next(iter(_points_cache))]
gpx_data = download_gpx(s3_key)
parsed = parse_gpx_bytes(gpx_data, s3_key.split("/")[-1])
_points_cache[ride_id] = parsed["points"]
return _points_cache[ride_id]
def _get_ride_insight(ride_id: str) -> str | None:
with Session(engine) as s:
row = s.query(Insight).filter_by(ride_id=ride_id).first()
return row.text if row else None
def _save_ride_insight(ride_id: str, user_id: str, text: str) -> None:
with Session(engine) as s:
row = s.query(Insight).filter_by(ride_id=ride_id).first()
if row:
row.text = text
else:
row = Insight(ride_id=ride_id, user_id=user_id, text=text)
s.add(row)
s.commit()
# ---------------------------------------------------------------------------
# Startup
# ---------------------------------------------------------------------------
@app.on_event("startup") @app.on_event("startup")
def load_rides(): def startup():
global MODEL_NAME global MODEL_NAME
# Auto-discover model name from llamacpp create_tables()
log.info("Database tables ready.")
try: try:
client = OpenAI(base_url=LLAMACPP_BASE_URL, api_key="not-needed") client = OpenAI(base_url=LLAMACPP_BASE_URL, api_key="not-needed")
models = client.models.list() models = client.models.list()
@@ -178,62 +226,50 @@ def load_rides():
MODEL_NAME = first.id MODEL_NAME = first.id
log.info("Discovered model: %s", MODEL_NAME) log.info("Discovered model: %s", MODEL_NAME)
else: else:
log.warning("No models returned from %s — using fallback '%s'", LLAMACPP_BASE_URL, MODEL_NAME) log.warning("No models from %s — using fallback '%s'", LLAMACPP_BASE_URL, MODEL_NAME)
except Exception as exc: except Exception as exc:
log.warning("Could not reach llamacpp at %s (%s) — using fallback '%s'", LLAMACPP_BASE_URL, exc, MODEL_NAME) log.warning("Could not reach llamacpp at %s (%s) — using fallback '%s'", LLAMACPP_BASE_URL, exc, MODEL_NAME)
for gpx_file in sorted(GPX_DIR.glob("*.gpx"), reverse=True):
try:
ride = parse_gpx(gpx_file)
_rides[ride["id"]] = ride
except Exception as exc:
log.error("Failed to parse %s: %s", gpx_file.name, exc)
log.info("Loaded %d rides.", len(_rides))
if INSIGHTS_FILE.exists():
try:
_insights_cache.update(json.loads(INSIGHTS_FILE.read_text()))
cached = sum(1 for rid in _insights_cache if rid in _rides)
log.info("Loaded %d cached insights from %s", cached, INSIGHTS_FILE)
except Exception as exc:
log.warning("Could not load insights cache: %s", exc)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# API endpoints # API endpoints
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@app.get("/api/me")
def get_me(user: User = Depends(require_user)):
return {"display_name": user.display_name, "email": user.email}
@app.get("/api/rides") @app.get("/api/rides")
def list_rides(): def list_rides(user: User = Depends(require_user)):
summary_keys = [ summary_keys = [
"id", "name", "activity_type", "start_time", "duration_s", "id", "name", "activity_type", "start_time", "duration_s",
"distance_km", "avg_speed_kmh", "elevation_gain_m", "distance_km", "avg_speed_kmh", "elevation_gain_m",
"avg_hr", "max_hr", "has_gps", "avg_hr", "max_hr", "has_gps",
] ]
rides = [] rides_map = get_user_rides_cached(user.id)
for r in _rides.values(): rides = [{k: r[k] for k in summary_keys} for r in rides_map.values()]
rides.append({k: r[k] for k in summary_keys})
# Sort by start_time descending
rides.sort(key=lambda x: x["start_time"] or "", reverse=True) rides.sort(key=lambda x: x["start_time"] or "", reverse=True)
return rides return rides
@app.get("/api/rides/{ride_id}") @app.get("/api/rides/{ride_id}")
def get_ride(ride_id: str): def get_ride(ride_id: str, user: User = Depends(require_user)):
if ride_id not in _rides: rides_map = get_user_rides_cached(user.id)
if ride_id not in rides_map:
raise HTTPException(status_code=404, detail="Ride not found") raise HTTPException(status_code=404, detail="Ride not found")
r = _rides[ride_id] r = {k: v for k, v in rides_map[ride_id].items() if k != "points"}
result = {k: v for k, v in r.items() if k != "points"} r["cached_insight"] = _get_ride_insight(ride_id)
result["cached_insight"] = _insights_cache.get(ride_id) return r
return result
@app.get("/api/rides/{ride_id}/points") @app.get("/api/rides/{ride_id}/points")
def get_ride_points(ride_id: str): def get_ride_points(ride_id: str, user: User = Depends(require_user)):
if ride_id not in _rides: rides_map = get_user_rides_cached(user.id)
if ride_id not in rides_map:
raise HTTPException(status_code=404, detail="Ride not found") raise HTTPException(status_code=404, detail="Ride not found")
return _rides[ride_id]["points"] return _fetch_ride_points(ride_id, rides_map[ride_id]["s3_key"])
def _karvonen_zones(rhr: int, max_hr: int, points: list[dict]) -> dict: def _karvonen_zones(rhr: int, max_hr: int, points: list[dict]) -> dict:
@@ -248,7 +284,7 @@ def _karvonen_zones(rhr: int, max_hr: int, points: list[dict]) -> dict:
] ]
hrs = [p["hr"] for p in points if p.get("hr") is not None] hrs = [p["hr"] for p in points if p.get("hr") is not None]
if not hrs: if not hrs:
return {l: 0 for l in labels} return {label: 0 for label in labels}
counts = [0] * 5 counts = [0] * 5
for h in hrs: for h in hrs:
if h < bounds[0]: counts[0] += 1 if h < bounds[0]: counts[0] += 1
@@ -263,27 +299,33 @@ def _llm_client() -> OpenAI:
return OpenAI(base_url=LLAMACPP_BASE_URL, api_key="not-needed") return OpenAI(base_url=LLAMACPP_BASE_URL, api_key="not-needed")
def _get_previous_insight(ride: dict) -> str | None: def _get_previous_insight_for_user(ride: dict, user_id: str) -> str | None:
"""Return the cached insight for the most recent ride before this one, or None.""" ride_time = ride.get("start_time")
ride_time = ride["start_time"]
if not ride_time: if not ride_time:
return None return None
gps_rides = [r for r in _rides.values() if r["has_gps"] and r["distance_km"] > 0 and r["start_time"]] with Session(engine) as s:
prior = sorted( row = s.execute(
[r for r in gps_rides if r["start_time"] < ride_time and r["id"] in _insights_cache], select(Insight.text)
key=lambda r: r["start_time"], .join(Ride, Insight.ride_id == Ride.id)
) .where(and_(
if not prior: Ride.user_id == user_id,
return None Ride.start_time < ride_time,
return _insights_cache[prior[-1]["id"]] Ride.has_gps == True,
Ride.distance_km > 0,
))
.order_by(Ride.start_time.desc())
.limit(1)
).first()
return row[0] if row else None
def _build_prompt(ride: dict, rhr: int, global_max_hr: int) -> str: def _build_prompt(ride: dict, user_id: str, rhr: int, global_max_hr: int, points: list[dict]) -> str:
ride_time = ride["start_time"] ride_time = ride["start_time"]
gps_rides = [r for r in _rides.values() if r["has_gps"] and r["distance_km"] > 0 and r["start_time"]] rides_map = get_user_rides_cached(user_id)
gps_rides = [r for r in rides_map.values() if r["has_gps"] and r["distance_km"] > 0 and r["start_time"]]
past_rides = [r for r in gps_rides if r["start_time"] < ride_time] past_rides = [r for r in gps_rides if r["start_time"] < ride_time]
zones = _karvonen_zones(rhr, global_max_hr, ride["points"]) zones = _karvonen_zones(rhr, global_max_hr, points)
zone_str = ", ".join(f"{k}={v}%" for k, v in zones.items()) zone_str = ", ".join(f"{k}={v}%" for k, v in zones.items())
duration_min = ride["duration_s"] // 60 duration_min = ride["duration_s"] // 60
ride_date = ride_time[:10] if ride_time else "unknown" ride_date = ride_time[:10] if ride_time else "unknown"
@@ -291,12 +333,12 @@ def _build_prompt(ride: dict, rhr: int, global_max_hr: int) -> str:
if past_rides: if past_rides:
avg_dist = sum(r["distance_km"] for r in past_rides) / len(past_rides) avg_dist = sum(r["distance_km"] for r in past_rides) / len(past_rides)
hr_list = [r["avg_hr"] for r in past_rides if r["avg_hr"]] hr_list = [r["avg_hr"] for r in past_rides if r["avg_hr"]]
avg_hr = sum(hr_list) / len(hr_list) if hr_list else 0 avg_hr_val = sum(hr_list) / len(hr_list) if hr_list else 0
history_str = f"Before today they've completed {len(past_rides)} rides, averaging {avg_dist:.1f} km and {avg_hr:.0f} bpm." history_str = f"Before today they've completed {len(past_rides)} rides, averaging {avg_dist:.1f} km and {avg_hr_val:.0f} bpm."
else: else:
history_str = "This is one of their earliest recorded rides — no prior history to compare against." history_str = "This is one of their earliest recorded rides — no prior history to compare against."
previous_insight = _get_previous_insight(ride) previous_insight = _get_previous_insight_for_user(ride, user_id)
if previous_insight: if previous_insight:
prior_section = f""" prior_section = f"""
Your feedback from their last ride: Your feedback from their last ride:
@@ -321,17 +363,19 @@ Then one blank line, then write exactly 34 short paragraphs in a warm, direct
@app.post("/api/rides/{ride_id}/insights") @app.post("/api/rides/{ride_id}/insights")
def get_insights(ride_id: str, rhr: int = Query(60, ge=30, le=100)): def post_insights(ride_id: str, user: User = Depends(require_user), rhr: int = Query(60, ge=30, le=100)):
if ride_id not in _rides: rides_map = get_user_rides_cached(user.id)
if ride_id not in rides_map:
raise HTTPException(status_code=404, detail="Ride not found") raise HTTPException(status_code=404, detail="Ride not found")
ride = _rides[ride_id] ride = rides_map[ride_id]
gps_rides = [r for r in _rides.values() if r["has_gps"] and r["distance_km"] > 0] gps_rides = [r for r in rides_map.values() if r["has_gps"] and r["distance_km"] > 0]
global_max_hr = max((r["max_hr"] or 0 for r in gps_rides), default=200) global_max_hr = max((r["max_hr"] or 0 for r in gps_rides), default=200)
prompt = _build_prompt(ride, rhr, global_max_hr) points = _fetch_ride_points(ride_id, ride["s3_key"])
prompt = _build_prompt(ride, user.id, rhr, global_max_hr, points)
client = _llm_client() client = _llm_client()
log.info("POST /insights ride_id=%s rhr=%d", ride_id, rhr) log.info("POST /insights ride_id=%s rhr=%d user=%s", ride_id, rhr, user.id)
log.debug("Prompt:\n%s", prompt) log.debug("Prompt:\n%s", prompt)
def stream_response(): def stream_response():
@@ -356,8 +400,7 @@ def get_insights(ride_id: str, rhr: int = Query(60, ge=30, le=100)):
accumulated.append(delta) accumulated.append(delta)
yield delta yield delta
log.info("Stream complete — %d chunks, %d chars", chunks_received, sum(len(c) for c in accumulated)) log.info("Stream complete — %d chunks, %d chars", chunks_received, sum(len(c) for c in accumulated))
_insights_cache[ride_id] = "".join(accumulated) _save_ride_insight(ride_id, user.id, "".join(accumulated))
_save_insights()
log.info("Saved insight for %s", ride_id) log.info("Saved insight for %s", ride_id)
except Exception as exc: except Exception as exc:
log.exception("Error streaming insights: %s", exc) log.exception("Error streaming insights: %s", exc)
@@ -367,24 +410,32 @@ def get_insights(ride_id: str, rhr: int = Query(60, ge=30, le=100)):
@app.post("/api/insights/generate-all") @app.post("/api/insights/generate-all")
def generate_all_insights(rhr: int = Query(60, ge=30, le=100)): def generate_all_insights(user: User = Depends(require_user), rhr: int = Query(60, ge=30, le=100)):
gps_rides = [r for r in _rides.values() if r["has_gps"] and r["distance_km"] > 0 and r["start_time"]] rides_map = get_user_rides_cached(user.id)
gps_rides = [r for r in rides_map.values() if r["has_gps"] and r["distance_km"] > 0 and r["start_time"]]
global_max_hr = max((r["max_hr"] or 0 for r in gps_rides), default=200) global_max_hr = max((r["max_hr"] or 0 for r in gps_rides), default=200)
# Chronological order so time-aware context is always correct
ordered = sorted(gps_rides, key=lambda r: r["start_time"]) ordered = sorted(gps_rides, key=lambda r: r["start_time"])
to_generate = [r for r in ordered if r["id"] not in _insights_cache]
with Session(engine) as s:
insighted_ids = {
row[0] for row in s.execute(
select(Insight.ride_id).where(Insight.user_id == user.id)
)
}
to_generate = [r for r in ordered if r["id"] not in insighted_ids]
total = len(to_generate) total = len(to_generate)
log.info("generate-all: %d rides to generate (rhr=%d)", total, rhr) log.info("generate-all: %d rides to generate (rhr=%d user=%s)", total, rhr, user.id)
def sse_stream(): def sse_stream():
client = _llm_client() llm = _llm_client()
for idx, ride in enumerate(to_generate, 1): for idx, ride in enumerate(to_generate, 1):
ride_id = ride["id"] ride_id = ride["id"]
yield f"data: {json.dumps({'index': idx, 'total': total, 'ride_id': ride_id, 'status': 'generating'})}\n\n" yield f"data: {json.dumps({'index': idx, 'total': total, 'ride_id': ride_id, 'status': 'generating'})}\n\n"
log.info("Generating insight %d/%d: %s", idx, total, ride_id) log.info("Generating insight %d/%d: %s", idx, total, ride_id)
try: try:
prompt = _build_prompt(ride, rhr, global_max_hr) points = _fetch_ride_points(ride_id, ride["s3_key"])
response = client.chat.completions.create( prompt = _build_prompt(ride, user.id, rhr, global_max_hr, points)
response = llm.chat.completions.create(
model=MODEL_NAME, model=MODEL_NAME,
messages=[{"role": "user", "content": prompt}], messages=[{"role": "user", "content": prompt}],
stream=False, stream=False,
@@ -393,8 +444,7 @@ def generate_all_insights(rhr: int = Query(60, ge=30, le=100)):
) )
msg = response.choices[0].message.model_dump() msg = response.choices[0].message.model_dump()
text = msg.get("content") or msg.get("reasoning_content") or "" text = msg.get("content") or msg.get("reasoning_content") or ""
_insights_cache[ride_id] = text _save_ride_insight(ride_id, user.id, text)
_save_insights()
yield f"data: {json.dumps({'index': idx, 'total': total, 'ride_id': ride_id, 'status': 'done'})}\n\n" yield f"data: {json.dumps({'index': idx, 'total': total, 'ride_id': ride_id, 'status': 'done'})}\n\n"
except Exception as exc: except Exception as exc:
log.exception("Failed insight for %s: %s", ride_id, exc) log.exception("Failed insight for %s: %s", ride_id, exc)
@@ -410,10 +460,11 @@ def generate_all_insights(rhr: int = Query(60, ge=30, le=100)):
@app.post("/api/fetch-rides") @app.post("/api/fetch-rides")
async def fetch_rides_from_garmin(request: Request): async def fetch_rides_from_garmin(request: Request, user: User = Depends(require_user)):
body = await request.json() body = await request.json()
email = body.get("email", "") email = body.get("email", "")
password = body.get("password", "") password = body.get("password", "")
user_id = user.id
session_id = uuid.uuid4().hex session_id = uuid.uuid4().hex
queue: asyncio.Queue = asyncio.Queue() queue: asyncio.Queue = asyncio.Queue()
@@ -432,16 +483,23 @@ async def fetch_rides_from_garmin(request: Request):
def garmin_worker(): def garmin_worker():
try: try:
emit({"status": "auth"}) emit({"status": "auth"})
client = Garmin(email=email, password=password, prompt_mfa=prompt_mfa) garmin = Garmin(email=email, password=password, prompt_mfa=prompt_mfa)
client.login() garmin.login()
emit({"status": "fetching"}) emit({"status": "fetching"})
activities = client.get_activities(0, 50) activities = garmin.get_activities(0, 50)
cycling = [ cycling = [
a for a in activities a for a in activities
if "cycling" in a.get("activityType", {}).get("typeKey", "") if "cycling" in a.get("activityType", {}).get("typeKey", "")
] ]
with Session(engine) as s:
existing_filenames = {
row[0] for row in s.execute(
select(Ride.filename).where(Ride.user_id == user_id)
)
}
to_download = [] to_download = []
for act in cycling: for act in cycling:
act_id = act["activityId"] act_id = act["activityId"]
@@ -450,18 +508,42 @@ async def fetch_rides_from_garmin(request: Request):
safe_time = start.replace(":", "-").replace(" ", "_") safe_time = start.replace(":", "-").replace(" ", "_")
safe_name = "".join(c for c in name if c.isalnum() or c in (" ", "_", "-")).strip() safe_name = "".join(c for c in name if c.isalnum() or c in (" ", "_", "-")).strip()
filename = f"{safe_time}_{safe_name}_{act_id}.gpx" filename = f"{safe_time}_{safe_name}_{act_id}.gpx"
if not (GPX_DIR / filename).exists(): if filename not in existing_filenames:
to_download.append((act_id, name, GPX_DIR / filename)) to_download.append((act_id, name, filename))
skipped = len(cycling) - len(to_download) skipped = len(cycling) - len(to_download)
added = 0 added = 0
for idx, (act_id, name, filepath) in enumerate(to_download, 1): for idx, (act_id, name, filename) in enumerate(to_download, 1):
emit({"status": "downloading", "index": idx, "total": len(to_download), "name": name}) emit({"status": "downloading", "index": idx, "total": len(to_download), "name": name})
try: try:
gpx_data = client.download_activity(act_id, dl_fmt=client.ActivityDownloadFormat.GPX) gpx_data = garmin.download_activity(act_id, dl_fmt=garmin.ActivityDownloadFormat.GPX)
filepath.write_bytes(gpx_data) s3_key = upload_gpx(user_id, filename, gpx_data)
ride = parse_gpx(filepath) parsed = parse_gpx_bytes(gpx_data, filename)
_rides[ride["id"]] = ride ride_id = uuid.uuid4().hex
with Session(engine) as s:
s.add(Ride(
id=ride_id,
user_id=user_id,
s3_key=s3_key,
filename=filename,
name=parsed["name"],
activity_type=parsed["activity_type"],
start_time=parsed["start_time"],
end_time=parsed["end_time"],
duration_s=parsed["duration_s"],
distance_m=parsed["distance_m"],
distance_km=parsed["distance_km"],
avg_speed_kmh=parsed["avg_speed_kmh"],
elevation_gain_m=parsed["elevation_gain_m"],
elevation_loss_m=parsed["elevation_loss_m"],
avg_hr=parsed["avg_hr"],
max_hr=parsed["max_hr"],
hr_zones_json=json.dumps(parsed["hr_zones"]),
has_gps=parsed["has_gps"],
))
s.commit()
_points_cache[ride_id] = parsed["points"]
invalidate_user_cache(user_id)
added += 1 added += 1
except Exception as exc: except Exception as exc:
log.error("Failed to download activity %s: %s", act_id, exc) log.error("Failed to download activity %s: %s", act_id, exc)
@@ -474,7 +556,7 @@ async def fetch_rides_from_garmin(request: Request):
emit({"status": "error", "message": str(exc)}) emit({"status": "error", "message": str(exc)})
finally: finally:
_mfa_sessions.pop(session_id, None) _mfa_sessions.pop(session_id, None)
emit(None) # sentinel to end the SSE stream emit(None)
async def generate(): async def generate():
loop.run_in_executor(None, garmin_worker) loop.run_in_executor(None, garmin_worker)
+105
View File
@@ -0,0 +1,105 @@
import os
import logging
from authlib.integrations.starlette_client import OAuth
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import RedirectResponse
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from db import User, get_user_by_id, upsert_user
log = logging.getLogger("bikeslop")
OIDC_ISSUER = os.getenv("OIDC_ISSUER", "")
OIDC_CLIENT_ID = os.getenv("OIDC_CLIENT_ID", "")
APP_BASE_URL = os.getenv("APP_BASE_URL", "http://localhost:8000")
SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-change-me")
SESSION_COOKIE = "bs_session"
SESSION_MAX_AGE = 30 * 24 * 3600 # 30 days
_signer = URLSafeTimedSerializer(SECRET_KEY)
oauth = OAuth()
oauth.register(
name="authelia",
client_id=OIDC_CLIENT_ID,
client_secret=None, # public client — no secret
server_metadata_url=f"{OIDC_ISSUER}/.well-known/openid-configuration" if OIDC_ISSUER else None,
client_kwargs={
"scope": "openid email profile",
"code_challenge_method": "S256", # PKCE required by Authelia config
"token_endpoint_auth_method": "none", # public client
},
)
def _sign_session(user_id: str) -> str:
return _signer.dumps({"uid": user_id})
def _unsign_session(token: str) -> str | None:
try:
data = _signer.loads(token, max_age=SESSION_MAX_AGE)
return data.get("uid")
except (BadSignature, SignatureExpired):
return None
def get_session_user(request: Request) -> User | None:
token = request.cookies.get(SESSION_COOKIE)
if not token:
return None
user_id = _unsign_session(token)
if not user_id:
return None
return get_user_by_id(user_id)
def require_user(request: Request) -> User:
user = get_session_user(request)
if user is None:
raise HTTPException(status_code=401, detail="Not authenticated")
return user
router = APIRouter()
@router.get("/auth/login")
async def login(request: Request):
if not OIDC_ISSUER:
raise HTTPException(status_code=503, detail="OIDC not configured (set OIDC_ISSUER)")
redirect_uri = f"{APP_BASE_URL}/auth/callback"
return await oauth.authelia.authorize_redirect(request, redirect_uri)
@router.get("/auth/callback")
async def callback(request: Request):
token = await oauth.authelia.authorize_access_token(request)
userinfo = token.get("userinfo") or await oauth.authelia.userinfo(token=token)
sub = userinfo.get("sub")
if not sub:
raise HTTPException(status_code=400, detail="OIDC userinfo missing 'sub'")
email = userinfo.get("email")
display_name = userinfo.get("name") or userinfo.get("preferred_username") or email
user = upsert_user(oidc_sub=sub, email=email, display_name=display_name)
log.info("User logged in: %s (%s)", display_name, user.id)
session_token = _sign_session(user.id)
response = RedirectResponse(url="/", status_code=302)
response.set_cookie(
SESSION_COOKIE,
session_token,
max_age=SESSION_MAX_AGE,
httponly=True,
samesite="lax",
secure=APP_BASE_URL.startswith("https"),
)
return response
@router.get("/auth/logout")
def logout():
response = RedirectResponse(url="/", status_code=302)
response.delete_cookie(SESSION_COOKIE)
return response
+145
View File
@@ -0,0 +1,145 @@
import json
import os
import uuid
from datetime import datetime, timezone
from sqlalchemy import (
Boolean,
Column,
DateTime,
Float,
ForeignKey,
Integer,
String,
Text,
create_engine,
)
from sqlalchemy.orm import DeclarativeBase, Session, relationship
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///bikeslop.db")
engine = create_engine(
DATABASE_URL,
connect_args={"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {},
)
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
oidc_sub = Column(String, unique=True, nullable=False)
email = Column(String, nullable=True)
display_name = Column(String, nullable=True)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
rides = relationship("Ride", back_populates="user", cascade="all, delete-orphan")
insights = relationship("Insight", back_populates="user", cascade="all, delete-orphan")
class Ride(Base):
__tablename__ = "rides"
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
user_id = Column(String, ForeignKey("users.id"), nullable=False)
s3_key = Column(String, nullable=False)
filename = Column(String, nullable=False)
name = Column(String, nullable=True)
activity_type = Column(String, nullable=True)
start_time = Column(String, nullable=True)
end_time = Column(String, nullable=True)
duration_s = Column(Integer, default=0)
distance_m = Column(Integer, default=0)
distance_km = Column(Float, default=0.0)
avg_speed_kmh = Column(Float, default=0.0)
elevation_gain_m = Column(Integer, default=0)
elevation_loss_m = Column(Integer, default=0)
avg_hr = Column(Integer, nullable=True)
max_hr = Column(Integer, nullable=True)
hr_zones_json = Column(Text, default="{}")
has_gps = Column(Boolean, default=True)
user = relationship("User", back_populates="rides")
insight = relationship("Insight", back_populates="ride", uselist=False, cascade="all, delete-orphan")
def to_dict(self) -> dict:
return {
"id": self.id,
"s3_key": self.s3_key,
"filename": self.filename,
"name": self.name,
"activity_type": self.activity_type,
"start_time": self.start_time,
"end_time": self.end_time,
"duration_s": self.duration_s,
"distance_m": self.distance_m,
"distance_km": self.distance_km,
"avg_speed_kmh": self.avg_speed_kmh,
"elevation_gain_m": self.elevation_gain_m,
"elevation_loss_m": self.elevation_loss_m,
"avg_hr": self.avg_hr,
"max_hr": self.max_hr,
"hr_zones": json.loads(self.hr_zones_json or "{}"),
"has_gps": self.has_gps,
}
class Insight(Base):
__tablename__ = "insights"
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
ride_id = Column(String, ForeignKey("rides.id"), nullable=False, unique=True)
user_id = Column(String, ForeignKey("users.id"), nullable=False)
text = Column(Text, nullable=False)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
ride = relationship("Ride", back_populates="insight")
user = relationship("User", back_populates="insights")
def create_tables() -> None:
Base.metadata.create_all(engine)
def get_session() -> Session:
return Session(engine)
def upsert_user(oidc_sub: str, email: str | None, display_name: str | None) -> User:
with get_session() as s:
user = s.query(User).filter_by(oidc_sub=oidc_sub).first()
if user is None:
user = User(oidc_sub=oidc_sub, email=email, display_name=display_name)
s.add(user)
else:
if email:
user.email = email
if display_name:
user.display_name = display_name
s.commit()
s.refresh(user)
return User(
id=user.id,
oidc_sub=user.oidc_sub,
email=user.email,
display_name=user.display_name,
created_at=user.created_at,
)
def get_user_by_id(user_id: str) -> User | None:
with get_session() as s:
user = s.query(User).filter_by(id=user_id).first()
if user is None:
return None
return User(
id=user.id,
oidc_sub=user.oidc_sub,
email=user.email,
display_name=user.display_name,
created_at=user.created_at,
)
+30
View File
@@ -0,0 +1,30 @@
services:
bikeslop:
build: .
restart: unless-stopped
ports:
- "8000:8000"
env_file: .env
environment:
DATABASE_URL: sqlite:////data/bikeslop.db
volumes:
- ./data:/data
depends_on:
- garage
garage:
image: dxflrs/garage:v2.2.0
restart: unless-stopped
ports:
- "3900:3900"
- "3901:3901"
- "3902:3902"
- "3903:3903"
volumes:
- ./garage.toml:/etc/garage.toml:ro
- garage-meta:/var/lib/garage/meta
- garage-data:/var/lib/garage/data
volumes:
garage-meta:
garage-data:
+4
View File
@@ -9,4 +9,8 @@ dependencies = [
"fastapi>=0.115.0", "fastapi>=0.115.0",
"uvicorn[standard]>=0.30.0", "uvicorn[standard]>=0.30.0",
"openai>=1.50.0", "openai>=1.50.0",
"sqlalchemy>=2.0.51",
"boto3>=1.43.66",
"authlib>=1.7.2",
"itsdangerous>=2.2.0",
] ]
+21 -1
View File
@@ -33,6 +33,10 @@
#sidebar-header { padding: 16px; border-bottom: 1px solid var(--border); } #sidebar-header { padding: 16px; border-bottom: 1px solid var(--border); }
#sidebar-header h1 { font-size: 15px; font-weight: 600; color: var(--text); } #sidebar-header h1 { font-size: 15px; font-weight: 600; color: var(--text); }
#sidebar-header p { color: var(--muted); font-size: 11px; margin-top: 2px; } #sidebar-header p { color: var(--muted); font-size: 11px; margin-top: 2px; }
#user-row { display: none; align-items: center; justify-content: space-between; margin-top: 4px; }
#user-name { font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#logout-link { font-size: 11px; color: var(--muted); text-decoration: none; flex-shrink: 0; margin-left: 6px; }
#logout-link:hover { color: var(--accent); }
#ride-list { flex: 1; overflow-y: auto; } #ride-list { flex: 1; overflow-y: auto; }
#ride-list::-webkit-scrollbar { width: 4px; } #ride-list::-webkit-scrollbar { width: 4px; }
#ride-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } #ride-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
@@ -147,6 +151,10 @@
<div id="sidebar-header"> <div id="sidebar-header">
<h1>bikeslop</h1> <h1>bikeslop</h1>
<p id="ride-count">Loading...</p> <p id="ride-count">Loading...</p>
<div id="user-row">
<span id="user-name"></span>
<a id="logout-link" href="/auth/logout">Sign out</a>
</div>
<div class="rhr-row"> <div class="rhr-row">
<label for="rhr-input">Resting HR</label> <label for="rhr-input">Resting HR</label>
<input id="rhr-input" type="number" min="30" max="100" value="60"> <input id="rhr-input" type="number" min="30" max="100" value="60">
@@ -804,7 +812,19 @@ document.getElementById('unit-toggle').addEventListener('click', () => {
if (currentPoints && currentPoints.length) renderCharts(currentPoints); if (currentPoints && currentPoints.length) renderCharts(currentPoints);
}); });
loadRides(); async function initAuth() {
const res = await fetch('/api/me');
if (res.status === 401) {
window.location.href = '/auth/login';
return;
}
const me = await res.json();
const userRow = document.getElementById('user-row');
userRow.style.display = 'flex';
document.getElementById('user-name').textContent = me.display_name || me.email || '';
loadRides();
}
initAuth();
</script> </script>
</body> </body>
</html> </html>
+36
View File
@@ -0,0 +1,36 @@
import os
import boto3
from botocore.config import Config
S3_ENDPOINT = os.getenv("S3_ENDPOINT")
S3_BUCKET = os.getenv("S3_BUCKET", "bikeslop-gpx")
S3_ACCESS_KEY = os.getenv("S3_ACCESS_KEY")
S3_SECRET_KEY = os.getenv("S3_SECRET_KEY")
def get_s3_client():
if not S3_ENDPOINT or not S3_ACCESS_KEY or not S3_SECRET_KEY:
raise RuntimeError(
"S3 not configured — set S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY"
)
return boto3.client(
"s3",
endpoint_url=S3_ENDPOINT,
aws_access_key_id=S3_ACCESS_KEY,
aws_secret_access_key=S3_SECRET_KEY,
config=Config(signature_version="s3v4"),
)
def upload_gpx(user_id: str, filename: str, data: bytes) -> str:
s3_key = f"users/{user_id}/{filename}"
client = get_s3_client()
client.put_object(Bucket=S3_BUCKET, Key=s3_key, Body=data, ContentType="application/gpx+xml")
return s3_key
def download_gpx(s3_key: str) -> bytes:
client = get_s3_client()
response = client.get_object(Bucket=S3_BUCKET, Key=s3_key)
return response["Body"].read()
Generated
+252 -16
View File
@@ -32,6 +32,74 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
] ]
[[package]]
name = "authlib"
version = "1.7.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "joserfc" },
]
sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" },
]
[[package]]
name = "bikeslop"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "authlib" },
{ name = "boto3" },
{ name = "fastapi" },
{ name = "garminconnect" },
{ name = "itsdangerous" },
{ name = "openai" },
{ name = "sqlalchemy" },
{ name = "uvicorn", extra = ["standard"] },
]
[package.metadata]
requires-dist = [
{ name = "authlib", specifier = ">=1.7.2" },
{ name = "boto3", specifier = ">=1.43.66" },
{ name = "fastapi", specifier = ">=0.115.0" },
{ name = "garminconnect", specifier = ">=0.3.6" },
{ name = "itsdangerous", specifier = ">=2.2.0" },
{ name = "openai", specifier = ">=1.50.0" },
{ name = "sqlalchemy", specifier = ">=2.0.51" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" },
]
[[package]]
name = "boto3"
version = "1.43.66"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/90/8f/92486dc60e9caf63a766bce02c561381455169a1b7090548d1fdc14d6b8d/boto3-1.43.66.tar.gz", hash = "sha256:ffc77129a4e5519bdbd9b278e4fe9e6276c9d951e0d2b60d626977bdb957cb38", size = 112668, upload-time = "2026-08-06T19:38:42.066Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/bc/131d596cf673f7b7c6b52ff88fa05f73bbdf9dff70d93e31d4b62645def3/boto3-1.43.66-py3-none-any.whl", hash = "sha256:8d097a41e2f545c25252105570f782bb035b1ac13d6213c772d1c895dc7cedeb", size = 140027, upload-time = "2026-08-06T19:38:40.651Z" },
]
[[package]]
name = "botocore"
version = "1.43.66"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5d/81/9ce1f5fe88b4b1429edf83189b8994ffb9883fc20bdd46b7ee9d1cc1da24/botocore-1.43.66.tar.gz", hash = "sha256:dde324cbe8be14b536b78f4fafe192f94fffcc70716bde804044e62495af8c3c", size = 15880416, upload-time = "2026-08-06T19:38:37.603Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/e6/53f0e28dea0f17a1b041bb3356d0c21fda7ba4198a515cfaf4d5f3088fe3/botocore-1.43.66-py3-none-any.whl", hash = "sha256:0fa62529579469a9224c186eba87e7e561db87dfad20c63ce5d52e427c7fa325", size = 15566310, upload-time = "2026-08-06T19:38:34.446Z" },
]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2026.7.22" version = "2026.7.22"
@@ -156,6 +224,56 @@ 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" }, { 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 = "50.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" },
{ url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" },
{ url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" },
{ url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" },
{ url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" },
{ url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" },
{ url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" },
{ url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" },
{ url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" },
{ url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" },
{ url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" },
{ url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" },
{ url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" },
{ url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" },
{ url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" },
{ url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" },
{ url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" },
{ url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" },
{ url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" },
{ url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" },
{ url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" },
{ url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" },
{ url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" },
{ url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" },
{ url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" },
{ url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" },
{ url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" },
{ url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" },
{ url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" },
{ url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" },
{ url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" },
{ url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" },
{ url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" },
{ url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" },
{ url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" },
{ url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" },
{ url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" },
{ url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" },
{ url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" },
]
[[package]] [[package]]
name = "curl-cffi" name = "curl-cffi"
version = "0.15.0" version = "0.15.0"
@@ -229,22 +347,50 @@ wheels = [
] ]
[[package]] [[package]]
name = "gpxport" name = "greenlet"
version = "0.1.0" version = "3.5.4"
source = { virtual = "." } source = { registry = "https://pypi.org/simple" }
dependencies = [ sdist = { url = "https://files.pythonhosted.org/packages/a3/74/b13368064b09053253555d3f2839cc2684d22d5aed0d2ccffbf7a6736558/greenlet-3.5.4.tar.gz", hash = "sha256:0232ae1de90a8e07867bb127d7a6ba2301e859145489f25cda8a6096dabe1d20", size = 206538, upload-time = "2026-07-22T12:47:14.468Z" }
{ name = "fastapi" }, wheels = [
{ name = "garminconnect" }, { url = "https://files.pythonhosted.org/packages/a5/a7/6ab1d4f9cd548d15ab90da29947f2076100130bb179b0bde59f795a459e3/greenlet-3.5.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7e8afa5eac028f8140ceafe5ceec66e6aa127ddcb21452d2a564dcd2900b5f22", size = 295410, upload-time = "2026-07-22T11:40:35.747Z" },
{ name = "openai" }, { url = "https://files.pythonhosted.org/packages/cd/7a/422f63b4715cbc0b24385305407adf38b48f6bb68b3e6b04090e994d0f5a/greenlet-3.5.4-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73b37afe369021423ea53dd3123e04bffa7e93ac64429b9f50835b2e4fcae7cf", size = 661286, upload-time = "2026-07-22T12:26:43.8Z" },
{ name = "uvicorn", extra = ["standard"] }, { url = "https://files.pythonhosted.org/packages/d0/31/5a1cac663bf5582190c5a714ef81364f03cde232227f39748f8ae4c11da5/greenlet-3.5.4-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ef964f56dfcb6f9bbef2a190d9126795eac408716aeae47b5e7c73c32aafca9", size = 673517, upload-time = "2026-07-22T12:29:04.815Z" },
] { url = "https://files.pythonhosted.org/packages/9c/bf/250c2921c7b585dde12f5239e313ca2dcbc464d161ecca36e4e6ef21762d/greenlet-3.5.4-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cef589bc65fae02d10bca2ac341191c5b33acc2967892ebf4fcbd10eabb7a74c", size = 677968, upload-time = "2026-07-22T12:43:46.788Z" },
{ url = "https://files.pythonhosted.org/packages/15/4a/2a82a1e3f8aaca020853ac8d12211280ca2b231aa08ea39f636f1060c319/greenlet-3.5.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c53ff01a5c53a40f2c16820ebc56d7c61a77f5fbe009dadd96292d5682f80f8", size = 670917, upload-time = "2026-07-22T11:51:13.589Z" },
[package.metadata] { url = "https://files.pythonhosted.org/packages/18/40/10bfcf6513558d82f7b95dd728001c63bd388259fe27d3e30ae01f103430/greenlet-3.5.4-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:dfc41ae893d9ceaf22c824f2153a88b30651b20e8758c2cd9ac143f23640563c", size = 480643, upload-time = "2026-07-22T12:39:54.149Z" },
requires-dist = [ { url = "https://files.pythonhosted.org/packages/68/b0/e379a152b17bfdfa95795af4049e37c0fd1b4d81f020d426db104ed07c77/greenlet-3.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecca4d80d55a01ad6b23b33262662956149fbb7b2c6be2910f1705921958cbf3", size = 1628478, upload-time = "2026-07-22T12:25:06.678Z" },
{ name = "fastapi", specifier = ">=0.115.0" }, { url = "https://files.pythonhosted.org/packages/5e/43/bffdfa64f7317f954c5c1230b5dd5922676ce198689a68c1ac1ed4b1b1a5/greenlet-3.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffbc533e0eaf8e80d8471411646ab88fe58f641d508c0b02b24494479f4d9ec", size = 1692021, upload-time = "2026-07-22T11:51:17.008Z" },
{ name = "garminconnect", specifier = ">=0.3.6" }, { url = "https://files.pythonhosted.org/packages/d0/11/f799f9637e2c6e9b0b716015e339040598b058cf7654dfc0d67468b177ed/greenlet-3.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:305f69e6c4523d7f6979ed001cff4e5853c063e5da04880296603aa0227e544c", size = 248031, upload-time = "2026-07-22T11:40:11.007Z" },
{ name = "openai", specifier = ">=1.50.0" }, { url = "https://files.pythonhosted.org/packages/05/75/625bcdd74d5e6b2dca1ecba3c3ac77bcf8a026c21a649a46cef23e421f97/greenlet-3.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:f260930bbbbcf9caee661211235a5111c86dfe5832fdf6ae4570da1e0995320f", size = 246892, upload-time = "2026-07-22T11:40:27.357Z" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" }, { url = "https://files.pythonhosted.org/packages/ec/69/35c62ed49c320cb4d98e14698ccca5467d3bfe683984172be9cb564d9ce3/greenlet-3.5.4-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:41ddab54e4b238f4a6c323f39b4e59e176affd5a94d461a9fb7583dac74240a3", size = 305571, upload-time = "2026-07-22T11:40:31.659Z" },
{ url = "https://files.pythonhosted.org/packages/5b/6c/64d60216b3640dcb0b62d913dd9e0d80030c09115bb2e4ba70c95d10ca45/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3dabe3e2809013052c68bdf0b7fa5f5f2859c43a80803131ad61af9cabd7867", size = 672568, upload-time = "2026-07-22T12:26:45.298Z" },
{ url = "https://files.pythonhosted.org/packages/5c/de/ba3ab0a96292e53039530333b0d2ae18d9e508f3a325cd7bf15f8172944c/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:27d3f00718634d4520a3a150154ac5da36f257869d41321953375b90bfbbc72c", size = 680076, upload-time = "2026-07-22T12:29:06.125Z" },
{ url = "https://files.pythonhosted.org/packages/ae/db/24a10af12bf8e639cec46c38b9ce1a282543ba42ff4fb0b31a970f1ab603/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39169a11d87a6a263afda3e9a27d1df16d0f919d40a4837cc73986c9884c0dd8", size = 681690, upload-time = "2026-07-22T12:43:48.109Z" },
{ url = "https://files.pythonhosted.org/packages/a5/be/aeada79083c6f1c15f45d77a332f9c441af263ee298e3eb17522cd337d22/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbd60b5763c6543c1827e48faaf14ea9bfbad245f52b1a4d76a2a2d8884c6c66", size = 676733, upload-time = "2026-07-22T11:51:16.027Z" },
{ url = "https://files.pythonhosted.org/packages/f4/60/44a2eca7b9fd71ae0fae7ff184da1cd3169d176652b97aa1cffcbb0ef961/greenlet-3.5.4-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:bd3d1145f603b2db19feb9078c2e6855eb7c67e15580c010ed815cee519b86fd", size = 510263, upload-time = "2026-07-22T12:39:55.678Z" },
{ url = "https://files.pythonhosted.org/packages/e9/10/2392fc3a98948652ef5fd1e7275c04f861dd13f74b78a2b4309f4ee4d090/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f00f910f0e7b35416c63b23ad78b769aeccfc1775f712b43c4ee525624a2eef7", size = 1637327, upload-time = "2026-07-22T12:25:07.879Z" },
{ url = "https://files.pythonhosted.org/packages/55/c6/e7237a3dfa1f205ed0d9ea1e46d70bd2811b32d516266399fb59d28ab90a/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:91c26423753b92caf41ab3f98fd547d7374d4d9fc2d85be041886c1579d9255e", size = 1697493, upload-time = "2026-07-22T11:51:19.214Z" },
{ url = "https://files.pythonhosted.org/packages/55/e3/4ba8154ba2a3d43729e499f72471b4b5c993f3826d3e24da81d5f06d6572/greenlet-3.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:ee032b91fd8ec29ec6c4cea2b8c561b178435134bd0752c7334b94e9c736c132", size = 251637, upload-time = "2026-07-22T11:40:37.44Z" },
{ url = "https://files.pythonhosted.org/packages/90/03/e3f96dfc100261a29545ddc8270cafe58f9195b6651466910e820910de77/greenlet-3.5.4-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:178111881dd7a6c946471fda85485ec796e1043c2b939f694b096e2ecf986809", size = 296076, upload-time = "2026-07-22T11:39:38.364Z" },
{ url = "https://files.pythonhosted.org/packages/a4/3d/da52d208e5c977bce8667e784729e584e38b5785f4c1ec0f4c836e9a1c42/greenlet-3.5.4-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d92df08dd65fede97fc37aad36c2e9dcda3b31c467f8e0c2c096456cb818e927", size = 666870, upload-time = "2026-07-22T12:26:46.691Z" },
{ url = "https://files.pythonhosted.org/packages/2d/8a/7e6dee25cb8a8cf9b362c8e597cc269593378bd916f16c736c059a52e85a/greenlet-3.5.4-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:99e8f8c4ebc4fd80aa26c1280ae9ad43a0976e786349703a181cf0bae60413e5", size = 677678, upload-time = "2026-07-22T12:29:07.508Z" },
{ url = "https://files.pythonhosted.org/packages/51/a7/dafc7415d430b0a43a16396eb49ecb3b62fd720877fb259cc4dcfaf5f31e/greenlet-3.5.4-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1f17e362d78e37559e0506c5a7d066bdd45073c36a0127a543e8a0df27242ff3", size = 681428, upload-time = "2026-07-22T12:43:49.623Z" },
{ url = "https://files.pythonhosted.org/packages/6c/21/5a38699fa45de749e3857d93b8f07e4c20489e77c2d35d915a2e1c456606/greenlet-3.5.4-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:394de08dad5ffcb1f50c2159d93e398d9d2da3ed437645eaa54771fa720db9f0", size = 676067, upload-time = "2026-07-22T11:51:18.163Z" },
{ url = "https://files.pythonhosted.org/packages/2e/d9/6298f3432de301d4718766cf934bd73c418c73f81fbb77247319364b0d96/greenlet-3.5.4-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:cd320d998cbaa032932830448e39abf3c6a12901295e386e8114db926e10cffb", size = 487446, upload-time = "2026-07-22T12:39:57.044Z" },
{ url = "https://files.pythonhosted.org/packages/5e/ba/863116ab8ff1ca7a729e327800268939d182db47aa433db70e216e7d9194/greenlet-3.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:c883d61f2282d72c767a14936641b3efcbde9d82f1080712aaea0b1d3126cb88", size = 1633489, upload-time = "2026-07-22T12:25:09.605Z" },
{ url = "https://files.pythonhosted.org/packages/fa/06/7466ced82818d6132462d7f26b3f83c66ea15d2b193a6c0088d558ed7d95/greenlet-3.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2a924f15d17957e252a810acefcb5942f5ca712298e8b6fcaed9a307d357522c", size = 1696584, upload-time = "2026-07-22T11:51:21.304Z" },
{ url = "https://files.pythonhosted.org/packages/f9/4d/55b638489260065de9ffce606c8b5d04507bef705de4b212a0c3d6a1a0df/greenlet-3.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:ed17e5f3420360d5b459de8462efb52060399a5326a613d4cde31cef63ef95da", size = 248297, upload-time = "2026-07-22T11:42:04.055Z" },
{ url = "https://files.pythonhosted.org/packages/bb/08/9dd4ae635da93d41dc268bc34bd62a9d711ed8b8825c5d22ac910c7d6e6d/greenlet-3.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:f908898d6fa484ce4b6f447ce70ea99b52c503fee419e53cf74d60a16bc9e667", size = 247423, upload-time = "2026-07-22T11:44:00.764Z" },
{ url = "https://files.pythonhosted.org/packages/19/66/7c87ed9cdbf1d49c2c6cd1c7b9dd4d16c33b24235ca03972293a1876b30c/greenlet-3.5.4-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:1833637f17d5e7472548a48575c394fe39f1b1890d676d162d86593610f44d8c", size = 306487, upload-time = "2026-07-22T11:41:25.118Z" },
{ url = "https://files.pythonhosted.org/packages/5b/05/0a4201e7c0054866eefc05da234f236dd4c950d0fbf9ca0517141f01b269/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12cda9122e03341f1cb6b8207a19d7a9d375e52f1b4e9243918375f40fd7b4b9", size = 676479, upload-time = "2026-07-22T12:26:48.129Z" },
{ url = "https://files.pythonhosted.org/packages/3d/c0/4b6b8c5a3aec70f0649cd89662d120fdd6421e2bdc8e3b15c3ab5ec568d8/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d83ae0e32d14957ab7170785a20f582635c8474deab1bfbb552b17e769a6ce25", size = 684321, upload-time = "2026-07-22T12:29:08.925Z" },
{ url = "https://files.pythonhosted.org/packages/88/15/0b167aeea95285b0e654ddce651922f666c089363c2ec528ca8b9a9ba74f/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:123aa379c962ed5fe90a880327e0c3066124ac64ec99e12a238be9fd8eb3db3d", size = 685995, upload-time = "2026-07-22T12:43:50.993Z" },
{ url = "https://files.pythonhosted.org/packages/24/c9/b49c31c9a972eee91e260445770e922244a7efc542697f51a012ec046d0f/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f1467de1bb767f75db0aa34c195e3a496d8d1278c796e70c24ce205d3e99cde", size = 681293, upload-time = "2026-07-22T11:51:20.43Z" },
{ url = "https://files.pythonhosted.org/packages/de/90/c023ec337f32ff505be7db759c80d98f0532bb94d0c6fa13645efe9bee2e/greenlet-3.5.4-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:adf2244d7f69409925a8f22ed22cc5f93cdfe5c9dc87ff3476be2c2aaae61a05", size = 516928, upload-time = "2026-07-22T12:39:58.359Z" },
{ url = "https://files.pythonhosted.org/packages/95/6f/7f2d4653770500eee667866016d42d7a68e3d3462f80df6b8e3fcd48a0eb/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0fa53040b78b578120eecdc0265e3f1051487cc425d11a2b7c761daadf4feaa8", size = 1642474, upload-time = "2026-07-22T12:25:10.819Z" },
{ url = "https://files.pythonhosted.org/packages/5a/d8/8cba31036a4caae448087ba5d150660ab03b4a0f54d9150f6495a3be7262/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:60e0bc961d367df506660e9ac0177a76bc6d81305300704b0977d1634f76efe2", size = 1701012, upload-time = "2026-07-22T11:51:23.17Z" },
{ url = "https://files.pythonhosted.org/packages/e3/cd/3f77a4cce3bae631b08eb52f53a82a976669600337e21dfdba811cb50267/greenlet-3.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:f680e549edb3eaf21eea4e7fe101e15ec180c74b7879ab46adc080f22d4015d2", size = 251977, upload-time = "2026-07-22T11:41:38.125Z" },
{ url = "https://files.pythonhosted.org/packages/93/e8/65e8707d00fe2a49bf12f609a9b2b39ba6dd23c2810eacad877c4fc94bfe/greenlet-3.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:08fc36de8442d5c3e95b044550dbea9bf144d31ec0cc58e36fb241cb6ef6a994", size = 250538, upload-time = "2026-07-22T11:40:17.985Z" },
] ]
[[package]] [[package]]
@@ -315,6 +461,15 @@ 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" }, { 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 = "itsdangerous"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
]
[[package]] [[package]]
name = "jiter" name = "jiter"
version = "0.16.0" version = "0.16.0"
@@ -351,6 +506,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/a2/d88de6d313d734a544a7901353ad5db67cb38dcfcd91713b7979dafc345d/jiter-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0fa25b09b13075c46f5bc174f2690525a925a4fc2f7c82969a2bbabff22386ce", size = 190516, upload-time = "2026-06-29T13:04:38.004Z" }, { url = "https://files.pythonhosted.org/packages/7c/a2/d88de6d313d734a544a7901353ad5db67cb38dcfcd91713b7979dafc345d/jiter-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0fa25b09b13075c46f5bc174f2690525a925a4fc2f7c82969a2bbabff22386ce", size = 190516, upload-time = "2026-06-29T13:04:38.004Z" },
] ]
[[package]]
name = "jmespath"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
]
[[package]]
name = "joserfc"
version = "1.7.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c7/e0/27a6a081ae25420eda6768ceae05d7022a7f2447f420588843f2a44e4298/joserfc-1.7.4.tar.gz", hash = "sha256:b3bc561672ae541b17a9237053b48a03dacddd92d68047b3ecdfb4b5714a88ed", size = 234027, upload-time = "2026-07-19T15:43:02.739Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/bf/249dcd99b3376375910b7fa922383b57792975c8758f50d44612e749226c/joserfc-1.7.4-py3-none-any.whl", hash = "sha256:32d46c2cd5e3203c13e87a6c61333cab310b1ba80cd54b4c4f386a848a122463", size = 71000, upload-time = "2026-07-19T15:43:01.299Z" },
]
[[package]] [[package]]
name = "markdown-it-py" name = "markdown-it-py"
version = "4.2.0" version = "4.2.0"
@@ -465,6 +641,18 @@ 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" }, { 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 = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.2.2" version = "1.2.2"
@@ -528,6 +716,27 @@ 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" }, { 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 = "s3transfer"
version = "0.19.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
]
sdist = { url = "https://files.pythonhosted.org/packages/76/43/35e4d8aa320bffe8287fe8f65f578fa2d2db0a64212f0e710dce58267854/s3transfer-0.19.2.tar.gz", hash = "sha256:ba0309fd86be3c27dbf78cdd813c13c5e1df16e5874b99d2535ebbdfb9892993", size = 165592, upload-time = "2026-07-22T19:30:44.432Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl", hash = "sha256:d8168eccca828cbb2cd573675333f3bddd254313a9c42494b84c76b539e8ba25", size = 90216, upload-time = "2026-07-22T19:30:43.251Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]] [[package]]
name = "sniffio" name = "sniffio"
version = "1.3.1" version = "1.3.1"
@@ -537,6 +746,33 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
] ]
[[package]]
name = "sqlalchemy"
version = "2.0.51"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" },
{ url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" },
{ url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" },
{ url = "https://files.pythonhosted.org/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929, upload-time = "2026-06-15T16:19:52.625Z" },
{ url = "https://files.pythonhosted.org/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888, upload-time = "2026-06-15T16:26:46.454Z" },
{ url = "https://files.pythonhosted.org/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551, upload-time = "2026-06-15T16:23:15.629Z" },
{ url = "https://files.pythonhosted.org/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318, upload-time = "2026-06-15T16:23:17.108Z" },
{ url = "https://files.pythonhosted.org/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920, upload-time = "2026-06-15T15:59:56.285Z" },
{ url = "https://files.pythonhosted.org/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534, upload-time = "2026-06-15T15:58:35.024Z" },
{ url = "https://files.pythonhosted.org/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844, upload-time = "2026-06-15T16:02:43.973Z" },
{ url = "https://files.pythonhosted.org/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355, upload-time = "2026-06-15T15:58:36.592Z" },
{ url = "https://files.pythonhosted.org/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591, upload-time = "2026-06-15T16:02:45.346Z" },
{ url = "https://files.pythonhosted.org/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313, upload-time = "2026-06-15T16:03:39.127Z" },
{ url = "https://files.pythonhosted.org/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280, upload-time = "2026-06-15T16:03:40.569Z" },
{ url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" },
]
[[package]] [[package]]
name = "starlette" name = "starlette"
version = "1.3.1" version = "1.3.1"