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:
@@ -0,0 +1,9 @@
|
||||
.venv
|
||||
.env
|
||||
bikeslop.db
|
||||
garmin_gpx_exports/*.gpx
|
||||
insights.json
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
.git
|
||||
@@ -15,3 +15,8 @@ insights.json
|
||||
|
||||
# Local environment
|
||||
.env
|
||||
|
||||
# Runtime data
|
||||
data/
|
||||
bikeslop.db
|
||||
*.swp
|
||||
|
||||
@@ -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`).
|
||||
|
||||
## 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
|
||||
|
||||
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:
|
||||
- `GET /api/rides` — summary list (no track points)
|
||||
- `GET /api/rides/{id}` — full ride metadata + cached insight
|
||||
- `GET /api/rides/{id}/points` — full lat/lon/ele/hr array
|
||||
- `POST /api/rides/{id}/insights` — streams AI coaching response
|
||||
- `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)
|
||||
**`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.
|
||||
|
||||
**`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.
|
||||
|
||||
**`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.
|
||||
|
||||
**`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
|
||||
|
||||
**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 without running the web server.
|
||||
**`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).
|
||||
|
||||
## GPX data notes
|
||||
|
||||
|
||||
+22
@@ -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"]
|
||||
@@ -9,11 +9,18 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
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.staticfiles import StaticFiles
|
||||
from garminconnect import Garmin, GarminConnectAuthenticationError
|
||||
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(
|
||||
level=logging.DEBUG,
|
||||
@@ -24,19 +31,18 @@ for _noisy in ("httpx", "httpcore", "openai"):
|
||||
logging.getLogger(_noisy).setLevel(logging.WARNING)
|
||||
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")
|
||||
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.add_middleware(SessionMiddleware, secret_key=SECRET_KEY)
|
||||
app.include_router(auth_router)
|
||||
|
||||
_insights_cache: dict[str, str] = {}
|
||||
_mfa_sessions: dict[str, dict] = {}
|
||||
|
||||
|
||||
def _save_insights() -> None:
|
||||
INSIGHTS_FILE.write_text(json.dumps(_insights_cache, indent=2, ensure_ascii=False))
|
||||
_user_rides_cache: dict[str, dict[str, dict]] = {}
|
||||
_points_cache: dict[str, list[dict]] = {}
|
||||
_POINTS_CACHE_MAX = 50
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GPX parsing helpers
|
||||
@@ -49,7 +55,6 @@ NS = {
|
||||
|
||||
|
||||
def haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Return distance in metres between two GPS coordinates."""
|
||||
R = 6_371_000
|
||||
phi1, phi2 = math.radians(lat1), math.radians(lat2)
|
||||
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))
|
||||
|
||||
|
||||
def parse_gpx(path: Path) -> dict:
|
||||
tree = ET.parse(path)
|
||||
root = tree.getroot()
|
||||
def parse_gpx_bytes(data: bytes, filename: str) -> dict:
|
||||
"""Parse GPX bytes into a ride dict including 'points'. No 'id' field."""
|
||||
root = ET.fromstring(data)
|
||||
|
||||
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)
|
||||
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
|
||||
points.append({"lat": lat, "lon": lon, "ele": ele, "time": ts, "hr": hr})
|
||||
|
||||
# Compute stats
|
||||
distance_m = 0.0
|
||||
ele_gain = 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]
|
||||
avg_hr = round(sum(hrs) / len(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
|
||||
|
||||
# HR zone distribution (% of points)
|
||||
zone_counts = {"z1": 0, "z2": 0, "z3": 0, "z4": 0, "z5": 0}
|
||||
for h in hrs:
|
||||
if h < 120:
|
||||
@@ -127,13 +130,10 @@ def parse_gpx(path: Path) -> dict:
|
||||
for k, v in zone_counts.items()
|
||||
}
|
||||
|
||||
ride_id = path.stem # use filename without extension as ID
|
||||
|
||||
return {
|
||||
"id": ride_id,
|
||||
"name": name,
|
||||
"activity_type": activity_type,
|
||||
"filename": path.name,
|
||||
"filename": filename,
|
||||
"start_time": start_time.isoformat() if start_time else None,
|
||||
"end_time": end_time.isoformat() if end_time else None,
|
||||
"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")
|
||||
def load_rides():
|
||||
def startup():
|
||||
global MODEL_NAME
|
||||
# Auto-discover model name from llamacpp
|
||||
create_tables()
|
||||
log.info("Database tables ready.")
|
||||
|
||||
try:
|
||||
client = OpenAI(base_url=LLAMACPP_BASE_URL, api_key="not-needed")
|
||||
models = client.models.list()
|
||||
@@ -178,62 +226,50 @@ def load_rides():
|
||||
MODEL_NAME = first.id
|
||||
log.info("Discovered model: %s", MODEL_NAME)
|
||||
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:
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@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")
|
||||
def list_rides():
|
||||
def list_rides(user: User = Depends(require_user)):
|
||||
summary_keys = [
|
||||
"id", "name", "activity_type", "start_time", "duration_s",
|
||||
"distance_km", "avg_speed_kmh", "elevation_gain_m",
|
||||
"avg_hr", "max_hr", "has_gps",
|
||||
]
|
||||
rides = []
|
||||
for r in _rides.values():
|
||||
rides.append({k: r[k] for k in summary_keys})
|
||||
# Sort by start_time descending
|
||||
rides_map = get_user_rides_cached(user.id)
|
||||
rides = [{k: r[k] for k in summary_keys} for r in rides_map.values()]
|
||||
rides.sort(key=lambda x: x["start_time"] or "", reverse=True)
|
||||
return rides
|
||||
|
||||
|
||||
@app.get("/api/rides/{ride_id}")
|
||||
def get_ride(ride_id: str):
|
||||
if ride_id not in _rides:
|
||||
def get_ride(ride_id: str, user: User = Depends(require_user)):
|
||||
rides_map = get_user_rides_cached(user.id)
|
||||
if ride_id not in rides_map:
|
||||
raise HTTPException(status_code=404, detail="Ride not found")
|
||||
r = _rides[ride_id]
|
||||
result = {k: v for k, v in r.items() if k != "points"}
|
||||
result["cached_insight"] = _insights_cache.get(ride_id)
|
||||
return result
|
||||
r = {k: v for k, v in rides_map[ride_id].items() if k != "points"}
|
||||
r["cached_insight"] = _get_ride_insight(ride_id)
|
||||
return r
|
||||
|
||||
|
||||
@app.get("/api/rides/{ride_id}/points")
|
||||
def get_ride_points(ride_id: str):
|
||||
if ride_id not in _rides:
|
||||
def get_ride_points(ride_id: str, user: User = Depends(require_user)):
|
||||
rides_map = get_user_rides_cached(user.id)
|
||||
if ride_id not in rides_map:
|
||||
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:
|
||||
@@ -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]
|
||||
if not hrs:
|
||||
return {l: 0 for l in labels}
|
||||
return {label: 0 for label in labels}
|
||||
counts = [0] * 5
|
||||
for h in hrs:
|
||||
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")
|
||||
|
||||
|
||||
def _get_previous_insight(ride: dict) -> str | None:
|
||||
"""Return the cached insight for the most recent ride before this one, or None."""
|
||||
ride_time = ride["start_time"]
|
||||
def _get_previous_insight_for_user(ride: dict, user_id: str) -> str | None:
|
||||
ride_time = ride.get("start_time")
|
||||
if not ride_time:
|
||||
return None
|
||||
gps_rides = [r for r in _rides.values() if r["has_gps"] and r["distance_km"] > 0 and r["start_time"]]
|
||||
prior = sorted(
|
||||
[r for r in gps_rides if r["start_time"] < ride_time and r["id"] in _insights_cache],
|
||||
key=lambda r: r["start_time"],
|
||||
)
|
||||
if not prior:
|
||||
return None
|
||||
return _insights_cache[prior[-1]["id"]]
|
||||
with Session(engine) as s:
|
||||
row = s.execute(
|
||||
select(Insight.text)
|
||||
.join(Ride, Insight.ride_id == Ride.id)
|
||||
.where(and_(
|
||||
Ride.user_id == user_id,
|
||||
Ride.start_time < ride_time,
|
||||
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"]
|
||||
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]
|
||||
|
||||
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())
|
||||
duration_min = ride["duration_s"] // 60
|
||||
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:
|
||||
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"]]
|
||||
avg_hr = 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."
|
||||
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_val:.0f} bpm."
|
||||
else:
|
||||
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:
|
||||
prior_section = f"""
|
||||
Your feedback from their last ride:
|
||||
@@ -321,17 +363,19 @@ Then one blank line, then write exactly 3–4 short paragraphs in a warm, direct
|
||||
|
||||
|
||||
@app.post("/api/rides/{ride_id}/insights")
|
||||
def get_insights(ride_id: str, rhr: int = Query(60, ge=30, le=100)):
|
||||
if ride_id not in _rides:
|
||||
def post_insights(ride_id: str, user: User = Depends(require_user), rhr: int = Query(60, ge=30, le=100)):
|
||||
rides_map = get_user_rides_cached(user.id)
|
||||
if ride_id not in rides_map:
|
||||
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)
|
||||
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()
|
||||
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)
|
||||
|
||||
def stream_response():
|
||||
@@ -356,8 +400,7 @@ def get_insights(ride_id: str, rhr: int = Query(60, ge=30, le=100)):
|
||||
accumulated.append(delta)
|
||||
yield delta
|
||||
log.info("Stream complete — %d chunks, %d chars", chunks_received, sum(len(c) for c in accumulated))
|
||||
_insights_cache[ride_id] = "".join(accumulated)
|
||||
_save_insights()
|
||||
_save_ride_insight(ride_id, user.id, "".join(accumulated))
|
||||
log.info("Saved insight for %s", ride_id)
|
||||
except Exception as 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")
|
||||
def generate_all_insights(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"]]
|
||||
def generate_all_insights(user: User = Depends(require_user), rhr: int = Query(60, ge=30, le=100)):
|
||||
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)
|
||||
# Chronological order so time-aware context is always correct
|
||||
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)
|
||||
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():
|
||||
client = _llm_client()
|
||||
llm = _llm_client()
|
||||
for idx, ride in enumerate(to_generate, 1):
|
||||
ride_id = ride["id"]
|
||||
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)
|
||||
try:
|
||||
prompt = _build_prompt(ride, rhr, global_max_hr)
|
||||
response = client.chat.completions.create(
|
||||
points = _fetch_ride_points(ride_id, ride["s3_key"])
|
||||
prompt = _build_prompt(ride, user.id, rhr, global_max_hr, points)
|
||||
response = llm.chat.completions.create(
|
||||
model=MODEL_NAME,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
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()
|
||||
text = msg.get("content") or msg.get("reasoning_content") or ""
|
||||
_insights_cache[ride_id] = text
|
||||
_save_insights()
|
||||
_save_ride_insight(ride_id, user.id, text)
|
||||
yield f"data: {json.dumps({'index': idx, 'total': total, 'ride_id': ride_id, 'status': 'done'})}\n\n"
|
||||
except Exception as 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")
|
||||
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()
|
||||
email = body.get("email", "")
|
||||
password = body.get("password", "")
|
||||
user_id = user.id
|
||||
|
||||
session_id = uuid.uuid4().hex
|
||||
queue: asyncio.Queue = asyncio.Queue()
|
||||
@@ -432,16 +483,23 @@ async def fetch_rides_from_garmin(request: Request):
|
||||
def garmin_worker():
|
||||
try:
|
||||
emit({"status": "auth"})
|
||||
client = Garmin(email=email, password=password, prompt_mfa=prompt_mfa)
|
||||
client.login()
|
||||
garmin = Garmin(email=email, password=password, prompt_mfa=prompt_mfa)
|
||||
garmin.login()
|
||||
|
||||
emit({"status": "fetching"})
|
||||
activities = client.get_activities(0, 50)
|
||||
activities = garmin.get_activities(0, 50)
|
||||
cycling = [
|
||||
a for a in activities
|
||||
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 = []
|
||||
for act in cycling:
|
||||
act_id = act["activityId"]
|
||||
@@ -450,18 +508,42 @@ async def fetch_rides_from_garmin(request: Request):
|
||||
safe_time = start.replace(":", "-").replace(" ", "_")
|
||||
safe_name = "".join(c for c in name if c.isalnum() or c in (" ", "_", "-")).strip()
|
||||
filename = f"{safe_time}_{safe_name}_{act_id}.gpx"
|
||||
if not (GPX_DIR / filename).exists():
|
||||
to_download.append((act_id, name, GPX_DIR / filename))
|
||||
if filename not in existing_filenames:
|
||||
to_download.append((act_id, name, filename))
|
||||
|
||||
skipped = len(cycling) - len(to_download)
|
||||
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})
|
||||
try:
|
||||
gpx_data = client.download_activity(act_id, dl_fmt=client.ActivityDownloadFormat.GPX)
|
||||
filepath.write_bytes(gpx_data)
|
||||
ride = parse_gpx(filepath)
|
||||
_rides[ride["id"]] = ride
|
||||
gpx_data = garmin.download_activity(act_id, dl_fmt=garmin.ActivityDownloadFormat.GPX)
|
||||
s3_key = upload_gpx(user_id, filename, gpx_data)
|
||||
parsed = parse_gpx_bytes(gpx_data, filename)
|
||||
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
|
||||
except Exception as 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)})
|
||||
finally:
|
||||
_mfa_sessions.pop(session_id, None)
|
||||
emit(None) # sentinel to end the SSE stream
|
||||
emit(None)
|
||||
|
||||
async def generate():
|
||||
loop.run_in_executor(None, garmin_worker)
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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:
|
||||
@@ -9,4 +9,8 @@ dependencies = [
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn[standard]>=0.30.0",
|
||||
"openai>=1.50.0",
|
||||
"sqlalchemy>=2.0.51",
|
||||
"boto3>=1.43.66",
|
||||
"authlib>=1.7.2",
|
||||
"itsdangerous>=2.2.0",
|
||||
]
|
||||
|
||||
@@ -33,6 +33,10 @@
|
||||
#sidebar-header { padding: 16px; border-bottom: 1px solid var(--border); }
|
||||
#sidebar-header h1 { font-size: 15px; font-weight: 600; color: var(--text); }
|
||||
#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::-webkit-scrollbar { width: 4px; }
|
||||
#ride-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||
@@ -147,6 +151,10 @@
|
||||
<div id="sidebar-header">
|
||||
<h1>bikeslop</h1>
|
||||
<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">
|
||||
<label for="rhr-input">Resting HR</label>
|
||||
<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);
|
||||
});
|
||||
|
||||
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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+36
@@ -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()
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "certifi"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "curl-cffi"
|
||||
version = "0.15.0"
|
||||
@@ -229,22 +347,50 @@ wheels = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gpxport"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "garminconnect" },
|
||||
{ name = "openai" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "fastapi", specifier = ">=0.115.0" },
|
||||
{ name = "garminconnect", specifier = ">=0.3.6" },
|
||||
{ name = "openai", specifier = ">=1.50.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" },
|
||||
name = "greenlet"
|
||||
version = "3.5.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
{ 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]]
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "jiter"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "markdown-it-py"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "python-dotenv"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "sniffio"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "starlette"
|
||||
version = "1.3.1"
|
||||
|
||||
Reference in New Issue
Block a user