first commit

This commit is contained in:
Justin Visser 2026-03-02 20:09:27 +01:00
commit b47f825d54
83 changed files with 10016 additions and 0 deletions

8
.env.example Normal file
View file

@ -0,0 +1,8 @@
# Required
HANDIN_ADMIN_TOKEN=changeme
# Optional
HANDIN_SITE_TITLE=Untitled Site
# HANDIN_DATA_DIR=./data
# HANDIN_MAX_UPLOAD_BYTES=100000000
# HANDIN_ALLOWED_UPLOAD_TYPES=pdf,png,jpg,jpeg,gif,mp4,webm,mp3,wav,ogg,webp

100
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,100 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
repo-hygiene:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Verify .env.example exists
run: test -f .env.example
lint:
runs-on: ubuntu-latest
needs: [repo-hygiene]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- uses: astral-sh/setup-uv@v4
- name: Install dependencies
run: uv sync --frozen --group dev
- name: Ruff check
run: uv run ruff check backend/
- name: Ruff format check
run: uv run ruff format --check backend/
- name: Mypy
run: uv run mypy
test:
runs-on: ubuntu-latest
needs: [lint]
env:
HANDIN_ADMIN_TOKEN: ci-test-token
HANDIN_DATA_DIR: /tmp/handin-test-data
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- uses: astral-sh/setup-uv@v4
- name: Install dependencies
run: uv sync --frozen --group dev
- name: Run tests
run: uv run pytest
frontend-quality:
runs-on: ubuntu-latest
needs: [repo-hygiene]
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install dependencies
run: npm ci
- name: Typecheck
run: npx vue-tsc --noEmit
- name: Build
run: npm run build
docker-publish:
runs-on: ubuntu-latest
needs: [test, frontend-quality]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- id: meta
uses: docker/metadata-action@v5
with:
images: justinzeus/handin
tags: |
type=raw,value=latest
type=sha,format=short,prefix=sha-
- uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
target: prod
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

38
.gitignore vendored Normal file
View file

@ -0,0 +1,38 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
dist/
build/
.mypy_cache/
.ruff_cache/
.pytest_cache/
*.egg
# Virtual environments
.venv/
venv/
# Node
node_modules/
frontend/dist/
# Data (persistent volume content)
data/
# IDE
.vscode/
.idea/
*.swp
*.swo
# Environment
.env
.env.local
# OS
.DS_Store
Thumbs.db
.claude/
.claude

51
Dockerfile Normal file
View file

@ -0,0 +1,51 @@
# ---- Stage 1: Build frontend ----
FROM node:20-alpine AS frontend-builder
WORKDIR /build
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ .
RUN npm run build
# ---- Stage 2: Dev (includes dev dependencies) ----
FROM python:3.12-slim AS dev
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
UV_LINK_MODE=copy \
UV_PROJECT_ENVIRONMENT=/opt/venv \
PATH="/opt/venv/bin:$PATH"
WORKDIR /app
COPY --from=ghcr.io/astral-sh/uv:0.6 /uv /uvx /bin/
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --group dev
COPY backend/ backend/
COPY scripts/ scripts/
ENTRYPOINT ["/bin/sh", "/app/scripts/entrypoint.sh"]
# ---- Stage 3: Production (minimal, no dev deps) ----
FROM python:3.12-slim AS prod
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
UV_LINK_MODE=copy \
UV_PROJECT_ENVIRONMENT=/opt/venv \
PATH="/opt/venv/bin:$PATH"
WORKDIR /app
COPY --from=ghcr.io/astral-sh/uv:0.6 /uv /uvx /bin/
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY backend/ backend/
COPY --from=frontend-builder /build/dist static/
COPY scripts/ scripts/
EXPOSE 8000
ENTRYPOINT ["/bin/sh", "/app/scripts/entrypoint.sh"]

243
PLAN.md Normal file
View file

@ -0,0 +1,243 @@
# Academic Group Submission Website — Architecture Plan
> **Implementation guide:** See [SLICES.md](SLICES.md) for the step-by-step slice-based implementation order.
## Context
Build a lightweight, containerized CMS for academic group submissions. The site aggregates multimodal content (PDFs, video, audio, markdown, embeds, galleries) into a single web page. Admin access is gated by a shared token — no user accounts. Multiple students may edit concurrently. All data persists in SQLite + uploaded assets on a Docker volume.
**Stack:** Python 3.12+ / FastAPI, Vue 3 / TypeScript / Tailwind CSS, SQLite (stdlib), single Docker container.
---
## Project Structure
```
handin-website/
├── pyproject.toml
├── Dockerfile
├── docker-compose.yml
├── .gitignore
├── coding-style.md
├── PLAN.md # This file — architecture reference
├── SLICES.md # Slice-based implementation guide
├── backend/
│ ├── app/
│ │ ├── main.py # FastAPI app, static mounts, lifespan
│ │ ├── config.py # pydantic-settings: token, paths, limits
│ │ ├── auth.py # require_admin dependency (token via query/header)
│ │ ├── models.py # Pydantic models for requests/responses
│ │ ├── database.py # SQLite connection, schema init, WAL mode
│ │ ├── routes/
│ │ │ ├── segments.py # CRUD + reorder endpoints
│ │ │ └── assets.py # Upload + delete endpoints
│ │ └── services/
│ │ ├── segment_service.py
│ │ └── asset_service.py
│ └── tests/
│ ├── conftest.py # Fixtures: test client, temp DB, temp asset dir
│ ├── test_auth.py
│ ├── test_segments.py
│ ├── test_assets.py
│ └── test_concurrent.py
├── frontend/
│ ├── package.json
│ ├── tsconfig.json
│ ├── vite.config.ts # Includes @tailwindcss/vite plugin
│ ├── index.html
│ └── src/
│ ├── App.vue
│ ├── main.ts
│ ├── style.css # Tailwind directives + custom theme
│ ├── types/segment.ts
│ ├── types/vuedraggable.d.ts # Type declarations for vuedraggable 4.x
│ ├── composables/
│ │ ├── useSegments.ts
│ │ ├── useAdmin.ts
│ │ └── useAssetUpload.ts
│ ├── components/
│ │ ├── layout/
│ │ │ ├── AppShell.vue # Sidebar + main content layout
│ │ │ ├── SidebarNav.vue # Segment nav, collapses on mobile
│ │ │ └── MobileHeader.vue # Hamburger menu for mobile
│ │ ├── segments/
│ │ │ ├── SegmentRenderer.vue
│ │ │ ├── SegmentList.vue
│ │ │ ├── MarkdownSegment.vue
│ │ │ ├── PdfSegment.vue
│ │ │ ├── VideoSegment.vue
│ │ │ ├── AudioSegment.vue
│ │ │ ├── IframeSegment.vue
│ │ │ └── GallerySegment.vue
│ │ └── admin/
│ │ ├── AdminToolbar.vue
│ │ ├── SegmentEditor.vue
│ │ ├── AssetUploader.vue
│ │ ├── AddSegmentModal.vue # Modal for creating new segments
│ │ └── TokenPrompt.vue
│ └── views/
│ └── HomePage.vue
└── data/ # Docker volume mount
├── handin.db # SQLite database
└── assets/ # Uploaded files
```
---
## Data Persistence — SQLite
SQLite with WAL mode for concurrent student edits. Single portable file, no external service.
### Schema
```sql
CREATE TABLE site (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE segments (
id TEXT PRIMARY KEY, -- UUID as text
type TEXT NOT NULL, -- markdown|pdf|video|audio|iframe|gallery
sort_order INTEGER NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
metadata TEXT NOT NULL DEFAULT '{}', -- JSON string
created_at TEXT NOT NULL, -- ISO 8601
updated_at TEXT NOT NULL -- ISO 8601
);
CREATE INDEX idx_segments_order ON segments(sort_order);
```
**Segment types:** `markdown`, `pdf`, `video`, `audio`, `iframe`, `gallery`, `link`.
> **`link` type:** Nav-only segment — renders as an external `<a target="_blank">` in the tab bar and mobile menu. Does not appear in the main content area. The `content` field stores the destination URL.
### Database layer (`database.py`)
- WAL mode: `PRAGMA journal_mode=WAL` for concurrent read/write.
- `PRAGMA busy_timeout=5000` so concurrent writers wait instead of failing.
- Schema auto-created on startup via `CREATE TABLE IF NOT EXISTS`.
- Connection managed per-request via FastAPI dependency.
### Assets
Uploaded files stored as `data/assets/<uuid>.<ext>`. Original filename preserved only in segment metadata (prevents path traversal and collisions).
---
## Auth
- Single token set via `HANDIN_ADMIN_TOKEN` env var.
- Accepted via `?token=` query param or `Authorization: Bearer` header.
- `require_admin` FastAPI dependency on all write endpoints.
- Public endpoints (GET) require no auth.
- `GET /api/auth/verify` for frontend token validation on load.
---
## API Endpoints
```
PUBLIC:
GET /api/site/ Site metadata (title)
GET /api/segments/ All segments, ordered by sort_order
GET /api/segments/{id} Single segment
GET /api/assets/{filename} Served via StaticFiles
NOTE: Collection routes use trailing slashes. Omitting them causes 307 redirects.
ADMIN (token required):
PUT /api/site/ Update site title
POST /api/segments/ Create segment
PATCH /api/segments/{id} Update segment (partial — send only changed fields)
DELETE /api/segments/{id} Delete segment
PUT /api/segments/reorder Reorder (body: ordered UUID list)
POST /api/assets/ Upload file (multipart)
DELETE /api/assets/{filename} Delete asset
GET /api/auth/verify Validate token
```
**Note:** Register `/reorder` route before `/{id}` to avoid path conflict.
---
## UI Design — Tailwind + #ffcd00 Theme
### Color System
Primary color `#ffcd00` (golden yellow). Tailwind extended palette:
| Token | Hex | Usage |
|-------------|-----------|----------------------------------|
| primary-50 | `#fffbeb` | Backgrounds, hover states |
| primary-100 | `#fff3c4` | Editor panel borders |
| primary-200 | `#fce588` | Subtle highlights |
| primary-300 | `#ffcd00` | **Brand color** — accents, active states |
| primary-400 | `#e6b800` | Hover on primary elements |
| primary-500 | `#cc9900` | Text on light backgrounds |
| primary-600 | `#997300` | Dark accent text |
| primary-700 | `#664d00` | Darkest accent |
Neutrals: Tailwind `slate` for text and backgrounds. Dark sidebar (`slate-900`), light main content.
### Layout — Top Bar with Tabs
> **Note:** The original plan specified a sidebar layout. Implementation changed to a horizontal top bar with tabs (Slice 5). Component **file names** from the original plan are preserved but their implementations differ.
**Desktop (≥768px `md+`):**
- **Title bar:** `bg-primary-300` (golden yellow) full-width at top, dark text, lock icon (admin entry) on the right.
- **Tab navigation:** Horizontal tab bar below the title bar, `border-b-2 border-primary-400` on the active tab.
- **Content area:** Centered `max-w-4xl` on white background.
**Mobile (<768px):**
- Title bar still visible. Tab bar hidden.
- `MobileHeader.vue` shows hamburger icon → dropdown from top with backdrop overlay.
- Segments stack vertically, full-width, comfortable touch targets.
**Component mapping:**
- `SidebarNav.vue` → horizontal tab bar (desktop, hidden below `md`).
- `MobileHeader.vue` → hamburger dropdown (mobile, hidden at `md+`).
- `AppShell.vue` → composes title bar + TokenPrompt + tab nav + mobile header + AdminToolbar + main content slot.
### Admin Integration (Minimal Interference)
No separate admin page. Controls blend into the existing UI:
- **Token active:** subtle edit icons on segment cards (pencil, muted → `primary-300` on hover). Drag handles in sidebar nav. Thin `primary-300` top bar with "Editing" badge + "Add Segment" button.
- **Segment editing:** inline — editor panel expands below the card. Same card styling, `primary-100` border.
- **Asset upload:** dashed-border drag-and-drop zone matching card aesthetics.
- **Token inactive:** zero admin UI visible.
---
## Dependencies
**Backend:** `fastapi`, `uvicorn[standard]`, `pydantic`, `pydantic-settings`, `python-multipart`
**Backend dev:** `pytest`, `httpx`, `ruff`, `mypy`
**Frontend runtime:** `vue`, `marked`, `vuedraggable`
**Frontend dev:** `vite`, `@vitejs/plugin-vue`, `typescript`, `vue-tsc`, `tailwindcss`, `@tailwindcss/vite`, `@tailwindcss/typography`
---
## Docker Strategy
Multi-stage Dockerfile:
1. **Stage 1** (`node:20-alpine`): `npm ci && npm run build``dist/`.
2. **Stage 2** (`python:3.12-slim`): `uv sync --frozen --no-dev`, copy backend + built frontend to `/app/static/`.
FastAPI serves:
- API routes at `/api/*` (registered first)
- Uploaded assets at `/api/assets` via `StaticFiles`
- Vue SPA at `/` via `StaticFiles(directory="/app/static", html=True)` (registered last, catch-all)
`docker-compose.yml`: named volume for `/data`, `HANDIN_ADMIN_TOKEN` from environment.
---
## Verification
1. `uv run pytest backend/tests/ -v` — all pass
2. `uv run ruff check backend/` + `uv run mypy --strict backend/app/` — clean
3. `docker compose up --build` — serves at `localhost:8000`
4. No-token visit: read-only, sidebar nav works, mobile responsive
5. Token visit: admin controls appear inline, CRUD + reorder works
6. Concurrent edits from two browsers: no corruption
7. Container restart: data persists

84
README.md Normal file
View file

@ -0,0 +1,84 @@
<div align="center">
# Handin
**A simple hand-in page builder, made for Utrecht University.**
[![CI](https://img.shields.io/github/actions/workflow/status/JustinZeus/handin-website/ci.yml?style=for-the-badge)](https://github.com/JustinZeus/handin-website/actions/workflows/ci.yml)
[![Docker Pulls](https://img.shields.io/docker/pulls/justinzeus/handin?style=for-the-badge&logo=docker)](https://hub.docker.com/r/justinzeus/handin)
[![Python](https://img.shields.io/badge/python-3.12+-3776AB?style=for-the-badge&logo=python&logoColor=white)](https://python.org)
</div>
---
## What is this?
I built this as a boilerplate for group hand-ins at Utrecht University. It lets you combine PDFs, video, audio, markdown, embedded content, and image galleries into a single web page. I'm making it publicly available in case anyone else finds it useful.
## Features
- **Multimodal content** — markdown, PDF, video, audio, iframes, image galleries, and links
- **Drag-and-drop ordering** — reorder segments visually
- **Token-based admin** — single admin token, no user accounts needed
- **Dark mode** — automatic dark/light theme
- **Zero-config database** — SQLite with WAL mode, no external DB to manage
- **Single container** — FastAPI backend + Vue 3 SPA ship as one Docker image
## Quick Start
```bash
git clone https://github.com/JustinZeus/handin-website.git
cd handin-website
cp .env.example .env
# Edit .env and set HANDIN_ADMIN_TOKEN to something secure
docker compose up -d
```
The app listens on port 8000 inside the container. Wire it through your reverse proxy (e.g. Caddy) to expose it.
## How It Works
```mermaid
graph LR
Browser <-->|HTTP| FastAPI[FastAPI + Vue SPA]
FastAPI --> DB[(SQLite)]
FastAPI --> Assets[/data/assets/]
```
The Vue 3 frontend is built at image build time and served as static files by FastAPI. All data (database + uploaded assets) lives in a single `/data` volume.
## Configuration
| Variable | Default | Description |
|----------|---------|-------------|
| `HANDIN_ADMIN_TOKEN` | *required* | Bearer token for admin endpoints |
| `HANDIN_SITE_TITLE` | `Untitled Site` | Page title |
| `HANDIN_DATA_DIR` | `./data` | SQLite DB + uploaded assets directory |
| `HANDIN_MAX_UPLOAD_BYTES` | `100000000` | Max file upload size (~100 MB) |
| `HANDIN_ALLOWED_UPLOAD_TYPES` | `pdf,png,jpg,...` | Comma-separated allowed file extensions |
## Tech Stack
| Layer | Technology |
|-------|------------|
| Backend | Python 3.12, FastAPI, Pydantic v2, SQLite |
| Frontend | TypeScript, Vue 3, Vite, Tailwind CSS v4 |
| Infrastructure | Multi-stage Docker, Docker Compose, UV |
## Development
```bash
# Backend
uv sync --group dev
uv run ruff check backend/
uv run ruff format --check backend/
uv run mypy
uv run pytest
# Frontend
cd frontend
npm ci
npx vue-tsc --noEmit
npm run dev
```

175
SLICES.md Normal file
View file

@ -0,0 +1,175 @@
# Implementation Slices
Each slice is a self-contained unit of work. Implement sequentially — each slice builds on the previous. Every slice follows TDD: write tests first, then implement until tests pass.
**Reference:** See [PLAN.md](PLAN.md) for full architecture context, color system, layout details, and project structure.
**Prompt files:** Each slice has a corresponding prompt in `prompts/` that can be handed to an agent for autonomous execution.
---
## Slice 0: Project Scaffold ✅
**Goal:** Set up the project skeleton so all subsequent slices have a working dev environment.
**Deliverables:**
- `pyproject.toml` — Python 3.12+, managed with `uv`. Deps: `fastapi`, `uvicorn[standard]`, `pydantic`, `pydantic-settings`, `python-multipart`. Dev deps (via `[dependency-groups]`): `pytest`, `httpx`, `ruff`, `mypy`. Ruff + mypy config sections included.
- `backend/app/__init__.py` (empty)
- `backend/app/config.py``Settings` class via `pydantic-settings` with fields: `admin_token: str`, `data_dir: str = "./data"`, `max_upload_bytes: int = 100_000_000`, `allowed_upload_types: str = "pdf,png,jpg,jpeg,gif,mp4,webm,mp3,wav,ogg,webp"`. Env prefix `HANDIN_`.
- `backend/tests/__init__.py` (empty)
- `backend/tests/conftest.py` — fixtures: `tmp_data_dir` (creates temp dir with `assets/` subdir), `settings` (overrides `data_dir` and `admin_token`), `db` (initializes SQLite in temp dir), `client` (FastAPI `TestClient`).
- `frontend/package.json` — deps: `vue`, `marked`, `vuedraggable`. Dev deps: `vite`, `@vitejs/plugin-vue`, `typescript`, `vue-tsc`, `tailwindcss`, `@tailwindcss/vite`, `@tailwindcss/typography`. Pin exact versions.
- `frontend/tsconfig.json`, `frontend/vite.config.ts` (with `@tailwindcss/vite` plugin).
- `frontend/src/main.ts`, `frontend/src/App.vue` (minimal), `frontend/src/style.css` (Tailwind directives).
- `frontend/index.html`
- `.gitignore` — Python, Node, data dir, IDE files.
**Verify:** `uv run pytest backend/tests/ -v` runs (0 tests collected, no errors). `cd frontend && npm install && npm run build` succeeds.
---
## Slice 1: Database + Models ✅
**Prompt:** `prompts/slice-1-database-models.md`
**Goal:** SQLite persistence layer with schema initialization and Pydantic models.
**Tests first (`backend/tests/test_database.py`):**
- Test: `init_db` creates tables (`site`, `segments`) in a fresh DB.
- Test: calling `init_db` twice is idempotent.
- Test: WAL mode is enabled after init.
- Test: `get_connection` returns a connection with `busy_timeout` set.
**Implement:**
- `backend/app/database.py``init_db(db_path)` creates schema with `CREATE TABLE IF NOT EXISTS`. Sets `PRAGMA journal_mode=WAL` and `PRAGMA busy_timeout=5000`. `get_connection(db_path)` returns a `sqlite3.Connection` with row factory.
- `backend/app/models.py``SegmentType(StrEnum)`, `Segment(BaseModel)`, `SegmentCreateRequest`, `SegmentUpdateRequest`, `ReorderRequest`, `SegmentResponse`, `SiteResponse`, `SiteUpdateRequest`.
**Verify:** `uv run pytest backend/tests/test_database.py -v` — all pass. `uv run mypy --strict backend/app/models.py backend/app/database.py` — clean.
---
## Slice 2: Segment & Site Service Layer ✅
**Prompt:** `prompts/slice-2-segment-service.md`
**Goal:** Business logic + persistence for segments and site metadata.
**Tests first (`backend/tests/test_services.py`):**
- 12 segment service tests (create, auto-increment sort_order, list empty/ordered, get found/not found, update title/not found, delete found/not found, reorder valid/invalid)
- 3 site service tests (default title, update + get, overwrite)
**Implement:**
- `backend/app/services/segment_service.py``create_segment`, `list_segments`, `get_segment`, `update_segment`, `delete_segment`, `reorder_segments`, `_row_to_segment`.
- `backend/app/services/site_service.py``get_site_title`, `update_site_title`.
**Verify:** 15 service tests pass. Zero mypy/ruff errors.
---
## Slice 3: Auth + API Routes + Concurrency
**Prompt:** `prompts/slice-3-api-routes.md`
**Goal:** Complete the backend — auth dependency, all API route handlers, concurrent write safety.
**Covers (from original plan):** Auth (PLAN slice 2), Segment CRUD routes (3), Reorder route (4), Site routes (6), Concurrency tests (7).
**Tests first:**
- `backend/tests/test_routes.py` — ~19 HTTP-level tests for segments, site, and auth (CRUD, reorder, unauthorized access, not-found cases)
- `backend/tests/test_concurrent.py` — 2 concurrency tests (parallel creates, concurrent create + reorder)
**Implement:**
- `backend/app/auth.py``require_admin` dependency. Checks `?token=` query param first, falls back to `Authorization: Bearer` header.
- `backend/app/routes/auth.py``GET /api/auth/verify` endpoint.
- `backend/app/routes/segments.py` — CRUD + reorder at `/api/segments`. Public: GET list, GET by id. Admin: POST, PATCH, DELETE, PUT reorder.
- `backend/app/routes/site.py` — GET + PUT at `/api/site`. Public: GET. Admin: PUT.
- `backend/app/main.py` — register routers, wire lifespan with `init_db`.
**Verify:** All backend tests pass (~40 total). Zero mypy/ruff errors across entire backend.
---
## Slice 4: Asset Service + Docker
**Prompt:** `prompts/slice-4-assets-docker.md`
**Goal:** File upload/serve/delete and containerized deployment.
**Covers (from original plan):** Asset Upload + Serve (5), Docker (13).
**Tests first (`backend/tests/test_assets.py`):**
- 8 tests: upload valid file, file exists on disk, disallowed type → 415, too large → 413, no auth → 401, delete file, delete not found → 404, serve via StaticFiles.
**Implement:**
- `backend/app/services/asset_service.py``save_asset`, `delete_asset`.
- `backend/app/routes/assets.py` — POST upload, DELETE by filename.
- Mount `StaticFiles` at `/api/assets` in `main.py`.
- `Dockerfile` — multi-stage build (Node → Python).
- `docker-compose.yml` — single service, volume mount, env vars.
**Verify:** All backend tests pass. `docker compose up --build` serves at localhost:8000.
---
## Slice 5: Frontend Shell + Renderers
**Prompt:** `prompts/slice-5-frontend-shell.md`
**Goal:** Read-only frontend — app shell, sidebar nav, mobile layout, all segment renderers.
**Covers (from original plan):** Frontend Shell + Tailwind Theme (8), Segment Renderers (9).
**Deliverables:**
- `style.css` — Tailwind v4 with `@theme` block for primary palette.
- `types/segment.ts` — TypeScript types matching backend models.
- Layout components: `AppShell.vue`, `SidebarNav.vue`, `MobileHeader.vue`.
- `composables/useSegments.ts` — fetch segments + site title.
- `views/HomePage.vue` — main page rendering.
- Segment components: `SegmentRenderer.vue`, `SegmentList.vue`, plus renderers for markdown, PDF, video, audio, iframe, gallery.
**Verify:** `npm run build` succeeds. Dev server renders sidebar + segments correctly on desktop and mobile.
---
## Slice 6: Admin UI ✅
**Prompt:** `prompts/slice-6-admin-ui.md`
**Goal:** Full admin experience — token entry, inline editing, asset upload, drag-and-drop reorder.
**Covers (from original plan):** Admin Token + Toolbar (10), Segment Editor + CRUD (11), Asset Upload + Drag-and-Drop Reorder (12).
**Deliverables:**
- `composables/useAdmin.ts` — token management via module-level reactive state, auth headers, verify/login/logout, sessionStorage persistence.
- `composables/useAssetUpload.ts` — file upload via FormData + native fetch.
- `components/admin/TokenPrompt.vue` — subtle lock icon in title bar → inline token input.
- `components/admin/AdminToolbar.vue` — thin editing bar below tabs with "Editing" badge + "Add Segment" + "Logout".
- `components/admin/SegmentEditor.vue` — inline editor below each segment card, type-aware content editing, custom styled delete confirmation modal.
- `components/admin/AssetUploader.vue` — drag-and-drop file upload zone with click-to-browse.
- `components/admin/AddSegmentModal.vue` — modal dialog with button-group type selector for creating new segments.
- `types/vuedraggable.d.ts` — type declaration for vuedraggable 4.x.
- Drag-and-drop reorder via `vuedraggable` in `SegmentList.vue` with grip-dot handles.
- Modified `AppShell.vue` — TokenPrompt in header, AdminToolbar below tabs (when authenticated).
- Modified `SegmentList.vue` — pencil edit icons, inline editors, draggable reorder (admin), plain rendering (read-only).
- Modified `HomePage.vue` — wires all admin mutations (create/PATCH/DELETE/reorder) with auth headers + refresh().
- **New segment type: `link`** — nav-only external links. Renders as `<a target="_blank">` in SidebarNav/MobileHeader, excluded from content area. Backend `SegmentType` enum and frontend type updated.
**Verify:** `npm run build` passes with zero errors. Full admin flow works end-to-end. No admin UI visible without token.
---
## Slice 7: Polish + Final Validation
**Prompt:** `prompts/slice-7-polish.md`
**Goal:** End-to-end sweep, edge cases, visual polish.
**Tasks:**
- Run full backend test suite: `uv run pytest backend/tests/ -v`.
- Run `uv run ruff check backend/` + `uv run mypy --strict backend/app/`.
- Run `npm run lint` + `npm run type-check` in frontend.
- Test mobile layout on narrow viewport (sidebar collapses, touch-friendly targets).
- Test all segment types render correctly in read-only mode.
- Test admin flow end-to-end: enter token, add each segment type, upload assets, reorder, delete, logout.
- Test concurrent edits: two browser tabs editing simultaneously.
- Verify no admin UI leaks in read-only mode.

0
backend/app/__init__.py Normal file
View file

14
backend/app/auth.py Normal file
View file

@ -0,0 +1,14 @@
from fastapi import HTTPException, Request
from app.config import get_settings
def require_admin(request: Request) -> None:
token = request.query_params.get("token")
if token is None:
auth_header = request.headers.get("authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header.removeprefix("Bearer ")
if token is None or token != get_settings().admin_token:
raise HTTPException(status_code=401, detail="Invalid or missing token")

18
backend/app/config.py Normal file
View file

@ -0,0 +1,18 @@
from functools import lru_cache
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
admin_token: str
site_title: str = "Untitled Site"
data_dir: str = "./data"
max_upload_bytes: int = 100_000_000
allowed_upload_types: str = "pdf,png,jpg,jpeg,gif,mp4,webm,mp3,wav,ogg,webp"
model_config = {"env_prefix": "HANDIN_"}
@lru_cache
def get_settings() -> Settings:
return Settings()

41
backend/app/database.py Normal file
View file

@ -0,0 +1,41 @@
import sqlite3
from pathlib import Path
def init_db(db_path: Path) -> None:
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db_path)
try:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
conn.execute("""
CREATE TABLE IF NOT EXISTS site (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS segments (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
sort_order INTEGER NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
metadata TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
""")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_segments_order ON segments(sort_order)"
)
conn.commit()
finally:
conn.close()
def get_connection(db_path: Path) -> sqlite3.Connection:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA busy_timeout=5000")
return conn

41
backend/app/main.py Normal file
View file

@ -0,0 +1,41 @@
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from app.config import get_settings
from app.database import init_db
from app.routes.assets import router as assets_router
from app.routes.auth import router as auth_router
from app.routes.segments import router as segments_router
from app.routes.site import router as site_router
@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
data_dir = Path(get_settings().data_dir)
init_db(data_dir / "handin.db")
assets_path = data_dir / "assets"
assets_path.mkdir(parents=True, exist_ok=True)
yield
app = FastAPI(title="Handin Website", lifespan=lifespan)
app.include_router(auth_router)
app.include_router(segments_router)
app.include_router(site_router)
app.include_router(assets_router)
@app.get("/api/health")
def health_check() -> dict[str, str]:
return {"status": "ok"}
# Serve built frontend SPA (must be registered last — catch-all)
_static_dir = Path(__file__).resolve().parent.parent.parent / "static"
if _static_dir.is_dir():
app.mount("/", StaticFiles(directory=str(_static_dir), html=True), name="static")

62
backend/app/models.py Normal file
View file

@ -0,0 +1,62 @@
from datetime import datetime
from enum import StrEnum
from uuid import UUID
from pydantic import BaseModel, Field
class SegmentType(StrEnum):
MARKDOWN = "markdown"
PDF = "pdf"
VIDEO = "video"
AUDIO = "audio"
IFRAME = "iframe"
GALLERY = "gallery"
LINK = "link"
class Segment(BaseModel):
id: UUID
type: SegmentType
sort_order: int
title: str
content: str = ""
metadata: dict[str, object] = Field(default_factory=dict)
created_at: datetime
updated_at: datetime
class SegmentCreateRequest(BaseModel):
type: SegmentType
title: str
content: str = ""
metadata: dict[str, object] = Field(default_factory=dict)
class SegmentUpdateRequest(BaseModel):
title: str | None = None
content: str | None = None
metadata: dict[str, object] | None = None
class ReorderRequest(BaseModel):
segment_ids: list[UUID]
class SiteUpdateRequest(BaseModel):
title: str
class SegmentResponse(BaseModel):
id: UUID
type: SegmentType
sort_order: int
title: str
content: str
metadata: dict[str, object]
created_at: datetime
updated_at: datetime
class SiteResponse(BaseModel):
title: str

View file

View file

@ -0,0 +1,69 @@
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Response, UploadFile
from fastapi.responses import FileResponse
from app.auth import require_admin
from app.config import Settings, get_settings
from app.services import asset_service
router = APIRouter(prefix="/api/assets", tags=["assets"])
def get_assets_dir() -> Path:
return Path(get_settings().data_dir) / "assets"
AssetsDir = Annotated[Path, Depends(get_assets_dir)]
Admin = Annotated[None, Depends(require_admin)]
AppSettings = Annotated[Settings, Depends(get_settings)]
@router.post("/", status_code=201)
async def upload_asset(
file: UploadFile,
_admin: Admin,
assets_dir: AssetsDir,
settings: AppSettings,
) -> dict[str, str]:
content = await file.read()
try:
saved = asset_service.save_asset(
assets_dir,
file.filename or "upload",
content,
settings.allowed_upload_types,
settings.max_upload_bytes,
)
except ValueError as exc:
msg = str(exc)
if "File too large" in msg:
raise HTTPException(status_code=413, detail=msg) from None
raise HTTPException(status_code=415, detail=msg) from None
return {"filename": saved}
@router.get("/{filename}")
def serve_asset(
filename: str,
assets_dir: AssetsDir,
) -> FileResponse:
file_path = (assets_dir / filename).resolve()
if not str(file_path).startswith(str(assets_dir.resolve())):
raise HTTPException(status_code=404, detail="Asset not found")
if not file_path.exists():
raise HTTPException(status_code=404, detail="Asset not found")
return FileResponse(file_path)
@router.delete("/{filename}")
def delete_asset(
filename: str,
_admin: Admin,
assets_dir: AssetsDir,
) -> Response:
deleted = asset_service.delete_asset(assets_dir, filename)
if not deleted:
raise HTTPException(status_code=404, detail="Asset not found")
return Response(status_code=204)

View file

@ -0,0 +1,14 @@
from typing import Annotated
from fastapi import APIRouter, Depends
from app.auth import require_admin
router = APIRouter(prefix="/api/auth", tags=["auth"])
Admin = Annotated[None, Depends(require_admin)]
@router.get("/verify")
def verify_token(_: Admin) -> dict[str, bool]:
return {"valid": True}

View file

@ -0,0 +1,93 @@
import threading
from pathlib import Path
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Response
from app.auth import require_admin
from app.config import get_settings
from app.database import init_db
from app.models import (
ReorderRequest,
SegmentCreateRequest,
SegmentResponse,
SegmentUpdateRequest,
)
from app.services import segment_service
router = APIRouter(prefix="/api/segments", tags=["segments"])
# Serialize writes to prevent sort_order race conditions in SQLite
_write_lock = threading.Lock()
def get_db_path() -> Path:
data_dir = get_settings().data_dir
db_path = Path(data_dir) / "handin.db"
init_db(db_path)
return db_path
DbPath = Annotated[Path, Depends(get_db_path)]
Admin = Annotated[None, Depends(require_admin)]
@router.get("/")
def list_segments(db_path: DbPath) -> list[SegmentResponse]:
segments = segment_service.list_segments(db_path)
return [SegmentResponse.model_validate(s.model_dump()) for s in segments]
@router.post("/", status_code=201)
def create_segment(
request: SegmentCreateRequest,
_: Admin,
db_path: DbPath,
) -> SegmentResponse:
with _write_lock:
segment = segment_service.create_segment(db_path, request)
return SegmentResponse.model_validate(segment.model_dump())
@router.put("/reorder")
def reorder_segments(
request: ReorderRequest,
_: Admin,
db_path: DbPath,
) -> list[SegmentResponse]:
with _write_lock:
try:
segments = segment_service.reorder_segments(db_path, request.segment_ids)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return [SegmentResponse.model_validate(s.model_dump()) for s in segments]
@router.get("/{segment_id}")
def get_segment(segment_id: UUID, db_path: DbPath) -> SegmentResponse:
segment = segment_service.get_segment(db_path, segment_id)
if segment is None:
raise HTTPException(status_code=404, detail="Segment not found")
return SegmentResponse.model_validate(segment.model_dump())
@router.patch("/{segment_id}")
def update_segment(
segment_id: UUID,
request: SegmentUpdateRequest,
_: Admin,
db_path: DbPath,
) -> SegmentResponse:
segment = segment_service.update_segment(db_path, segment_id, request)
if segment is None:
raise HTTPException(status_code=404, detail="Segment not found")
return SegmentResponse.model_validate(segment.model_dump())
@router.delete("/{segment_id}", status_code=204)
def delete_segment(segment_id: UUID, _: Admin, db_path: DbPath) -> Response:
deleted = segment_service.delete_segment(db_path, segment_id)
if not deleted:
raise HTTPException(status_code=404, detail="Segment not found")
return Response(status_code=204)

View file

@ -0,0 +1,33 @@
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Depends
from app.auth import require_admin
from app.config import get_settings
from app.models import SiteResponse, SiteUpdateRequest
from app.routes.segments import get_db_path
from app.services import site_service
router = APIRouter(prefix="/api/site", tags=["site"])
Admin = Annotated[None, Depends(require_admin)]
DbPath = Annotated[Path, Depends(get_db_path)]
@router.get("/")
def get_site(db_path: DbPath) -> SiteResponse:
title = site_service.get_site_title(
db_path, default=get_settings().site_title
)
return SiteResponse(title=title)
@router.put("/")
def update_site(
request: SiteUpdateRequest,
_: Admin,
db_path: DbPath,
) -> SiteResponse:
title = site_service.update_site_title(db_path, request.title)
return SiteResponse(title=title)

View file

View file

@ -0,0 +1,35 @@
from pathlib import Path
from uuid import uuid4
def save_asset(
assets_dir: Path,
filename: str,
content: bytes,
allowed_types: str,
max_bytes: int,
) -> str:
ext = filename.rsplit(".", maxsplit=1)[-1].lower() if "." in filename else ""
allowed = {t.strip() for t in allowed_types.split(",")}
if ext not in allowed:
raise ValueError("Unsupported file type")
if len(content) > max_bytes:
raise ValueError("File too large")
new_filename = f"{uuid4()}.{ext}"
assets_dir.mkdir(parents=True, exist_ok=True)
(assets_dir / new_filename).write_bytes(content)
return new_filename
def delete_asset(assets_dir: Path, filename: str) -> bool:
file_path = (assets_dir / filename).resolve()
if not str(file_path).startswith(str(assets_dir.resolve())):
return False
if not file_path.exists():
return False
file_path.unlink()
return True

View file

@ -0,0 +1,147 @@
import json
import sqlite3
from datetime import UTC, datetime
from pathlib import Path
from uuid import UUID, uuid4
from app.database import get_connection
from app.models import Segment, SegmentCreateRequest, SegmentType, SegmentUpdateRequest
def _row_to_segment(row: sqlite3.Row) -> Segment:
return Segment(
id=UUID(row["id"]),
type=SegmentType(row["type"]),
sort_order=row["sort_order"],
title=row["title"],
content=row["content"],
metadata=json.loads(row["metadata"]),
created_at=datetime.fromisoformat(row["created_at"]),
updated_at=datetime.fromisoformat(row["updated_at"]),
)
def create_segment(db_path: Path, request: SegmentCreateRequest) -> Segment:
conn = get_connection(db_path)
try:
row = conn.execute(
"SELECT COALESCE(MAX(sort_order) + 1, 0) AS next_order FROM segments"
).fetchone()
sort_order: int = row["next_order"] if row else 0
segment_id = uuid4()
now = datetime.now(UTC).isoformat()
metadata_json = json.dumps(request.metadata)
cols = "id, type, sort_order, title, content, metadata, created_at, updated_at"
conn.execute(
f"INSERT INTO segments ({cols}) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(str(segment_id), request.type.value, sort_order, request.title,
request.content, metadata_json, now, now),
)
conn.commit()
return Segment(
id=segment_id,
type=request.type,
sort_order=sort_order,
title=request.title,
content=request.content,
metadata=request.metadata,
created_at=datetime.fromisoformat(now),
updated_at=datetime.fromisoformat(now),
)
finally:
conn.close()
def list_segments(db_path: Path) -> list[Segment]:
conn = get_connection(db_path)
try:
rows = conn.execute(
"SELECT * FROM segments ORDER BY sort_order ASC"
).fetchall()
return [_row_to_segment(row) for row in rows]
finally:
conn.close()
def get_segment(db_path: Path, segment_id: UUID) -> Segment | None:
conn = get_connection(db_path)
try:
row = conn.execute(
"SELECT * FROM segments WHERE id = ?", (str(segment_id),)
).fetchone()
if row is None:
return None
return _row_to_segment(row)
finally:
conn.close()
def update_segment(
db_path: Path, segment_id: UUID, request: SegmentUpdateRequest
) -> Segment | None:
conn = get_connection(db_path)
try:
existing = conn.execute(
"SELECT * FROM segments WHERE id = ?", (str(segment_id),)
).fetchone()
if existing is None:
return None
now = datetime.now(UTC).isoformat()
title = request.title if request.title is not None else existing["title"]
content = request.content if request.content is not None else existing["content"]
metadata_json = (
json.dumps(request.metadata) if request.metadata is not None
else existing["metadata"]
)
conn.execute(
"""UPDATE segments SET title = ?, content = ?, metadata = ?, updated_at = ?
WHERE id = ?""",
(title, content, metadata_json, now, str(segment_id)),
)
conn.commit()
return get_segment(db_path, segment_id)
finally:
conn.close()
def delete_segment(db_path: Path, segment_id: UUID) -> bool:
conn = get_connection(db_path)
try:
cursor = conn.execute(
"DELETE FROM segments WHERE id = ?", (str(segment_id),)
)
conn.commit()
return cursor.rowcount > 0
finally:
conn.close()
def reorder_segments(db_path: Path, segment_ids: list[UUID]) -> list[Segment]:
conn = get_connection(db_path)
try:
existing_ids = {
row["id"]
for row in conn.execute("SELECT id FROM segments").fetchall()
}
requested_ids = {str(sid) for sid in segment_ids}
missing = requested_ids - existing_ids
if missing:
raise ValueError(f"Segment IDs not found: {missing}")
now = datetime.now(UTC).isoformat()
for index, sid in enumerate(segment_ids):
conn.execute(
"UPDATE segments SET sort_order = ?, updated_at = ? WHERE id = ?",
(index, now, str(sid)),
)
conn.commit()
return list_segments(db_path)
finally:
conn.close()

View file

@ -0,0 +1,31 @@
from pathlib import Path
from app.database import get_connection
DEFAULT_SITE_TITLE = "Untitled Site"
def get_site_title(db_path: Path, *, default: str = DEFAULT_SITE_TITLE) -> str:
conn = get_connection(db_path)
try:
row = conn.execute(
"SELECT value FROM site WHERE key = ?", ("title",)
).fetchone()
if row is None:
return default
return str(row["value"])
finally:
conn.close()
def update_site_title(db_path: Path, title: str) -> str:
conn = get_connection(db_path)
try:
conn.execute(
"INSERT OR REPLACE INTO site (key, value) VALUES (?, ?)",
("title", title),
)
conn.commit()
return title
finally:
conn.close()

View file

60
backend/tests/conftest.py Normal file
View file

@ -0,0 +1,60 @@
import os
import tempfile
from collections.abc import Generator
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from app.config import Settings
TEST_ADMIN_TOKEN = "test-secret-token"
@pytest.fixture
def tmp_data_dir() -> Generator[Path, None, None]:
with tempfile.TemporaryDirectory() as tmpdir:
data_dir = Path(tmpdir)
(data_dir / "assets").mkdir()
yield data_dir
@pytest.fixture
def _env_settings(tmp_data_dir: Path) -> Generator[None, None, None]:
original_env = os.environ.copy()
os.environ["HANDIN_ADMIN_TOKEN"] = TEST_ADMIN_TOKEN
os.environ["HANDIN_DATA_DIR"] = str(tmp_data_dir)
from app.config import get_settings
get_settings.cache_clear()
yield
os.environ.clear()
os.environ.update(original_env)
get_settings.cache_clear()
@pytest.fixture
def settings(_env_settings: None) -> Settings:
from app.config import get_settings
s = get_settings()
assert isinstance(s, Settings)
return s
@pytest.fixture
def db(tmp_data_dir: Path) -> Path:
"""Return path to an initialized SQLite database in the temp dir."""
from app.database import init_db
db_path = tmp_data_dir / "handin.db"
init_db(db_path)
return db_path
@pytest.fixture
def client(_env_settings: None) -> TestClient:
from app.main import app
return TestClient(app)

View file

@ -0,0 +1,119 @@
import os
from pathlib import Path
from fastapi.testclient import TestClient
from tests.conftest import TEST_ADMIN_TOKEN
BEARER_HEADERS = {"Authorization": f"Bearer {TEST_ADMIN_TOKEN}"}
def test_upload_asset(client: TestClient) -> None:
resp = client.post(
"/api/assets",
headers=BEARER_HEADERS,
files={"file": ("test.pdf", b"fake pdf content", "application/pdf")},
)
assert resp.status_code == 201
data = resp.json()
assert "filename" in data
assert data["filename"] != "test.pdf"
assert data["filename"].endswith(".pdf")
def test_upload_asset_file_exists_on_disk(client: TestClient) -> None:
resp = client.post(
"/api/assets",
headers=BEARER_HEADERS,
files={"file": ("test.pdf", b"fake pdf content", "application/pdf")},
)
assert resp.status_code == 201
filename = resp.json()["filename"]
from app.config import get_settings
assets_dir = Path(get_settings().data_dir) / "assets"
file_path = assets_dir / filename
assert file_path.exists()
assert file_path.read_bytes() == b"fake pdf content"
def test_upload_asset_disallowed_type(client: TestClient) -> None:
resp = client.post(
"/api/assets",
headers=BEARER_HEADERS,
files={"file": ("malware.exe", b"evil bytes", "application/octet-stream")},
)
assert resp.status_code == 415
def test_upload_asset_too_large(client: TestClient) -> None:
from app.config import get_settings
original = os.environ.get("HANDIN_MAX_UPLOAD_BYTES")
os.environ["HANDIN_MAX_UPLOAD_BYTES"] = "100"
get_settings.cache_clear()
try:
resp = client.post(
"/api/assets",
headers=BEARER_HEADERS,
files={"file": ("big.pdf", b"x" * 200, "application/pdf")},
)
assert resp.status_code == 413
finally:
if original is None:
os.environ.pop("HANDIN_MAX_UPLOAD_BYTES", None)
else:
os.environ["HANDIN_MAX_UPLOAD_BYTES"] = original
get_settings.cache_clear()
def test_upload_asset_unauthorized(client: TestClient) -> None:
resp = client.post(
"/api/assets",
files={"file": ("test.pdf", b"fake pdf content", "application/pdf")},
)
assert resp.status_code == 401
def test_delete_asset(client: TestClient) -> None:
resp = client.post(
"/api/assets",
headers=BEARER_HEADERS,
files={"file": ("test.pdf", b"fake pdf content", "application/pdf")},
)
assert resp.status_code == 201
filename = resp.json()["filename"]
from app.config import get_settings
assets_dir = Path(get_settings().data_dir) / "assets"
del_resp = client.delete(f"/api/assets/{filename}", headers=BEARER_HEADERS)
assert del_resp.status_code == 204
assert not (assets_dir / filename).exists()
def test_delete_asset_not_found(client: TestClient) -> None:
resp = client.delete("/api/assets/nonexistent.pdf", headers=BEARER_HEADERS)
assert resp.status_code == 404
def test_delete_asset_unauthorized(client: TestClient) -> None:
resp = client.delete("/api/assets/somefile.pdf")
assert resp.status_code == 401
def test_serve_asset(client: TestClient) -> None:
content = b"fake pdf content for serving"
resp = client.post(
"/api/assets",
headers=BEARER_HEADERS,
files={"file": ("test.pdf", content, "application/pdf")},
)
assert resp.status_code == 201
filename = resp.json()["filename"]
get_resp = client.get(f"/api/assets/{filename}")
assert get_resp.status_code == 200
assert get_resp.content == content

View file

@ -0,0 +1,81 @@
from concurrent.futures import ThreadPoolExecutor
from fastapi.testclient import TestClient
from tests.conftest import TEST_ADMIN_TOKEN
BEARER_HEADERS = {"Authorization": f"Bearer {TEST_ADMIN_TOKEN}"}
def test_concurrent_creates(client: TestClient) -> None:
def create_segment(i: int) -> dict[str, object]:
resp = client.post(
"/api/segments",
json={"type": "markdown", "title": f"Seg {i}"},
headers=BEARER_HEADERS,
)
assert resp.status_code == 201
data: dict[str, object] = resp.json()
return data
with ThreadPoolExecutor(max_workers=10) as pool:
results = list(pool.map(create_segment, range(10)))
assert len(results) == 10
ids = {r["id"] for r in results}
assert len(ids) == 10
listing = client.get("/api/segments").json()
assert len(listing) == 10
sort_orders = sorted(s["sort_order"] for s in listing)
assert sort_orders == list(range(10))
def test_concurrent_create_and_reorder(client: TestClient) -> None:
segments = []
for i in range(3):
resp = client.post(
"/api/segments",
json={"type": "markdown", "title": f"Init {i}"},
headers=BEARER_HEADERS,
)
assert resp.status_code == 201
segments.append(resp.json())
seg_ids = [s["id"] for s in segments]
def create_extra(i: int) -> dict[str, object]:
resp = client.post(
"/api/segments",
json={"type": "markdown", "title": f"Extra {i}"},
headers=BEARER_HEADERS,
)
assert resp.status_code == 201
data: dict[str, object] = resp.json()
return data
def reorder() -> int:
resp = client.put(
"/api/segments/reorder",
json={"segment_ids": list(reversed(seg_ids))},
headers=BEARER_HEADERS,
)
return resp.status_code
with ThreadPoolExecutor(max_workers=10) as pool:
create_futures = [pool.submit(create_extra, i) for i in range(2)]
reorder_future = pool.submit(reorder)
for f in create_futures:
f.result()
reorder_status = reorder_future.result()
assert 200 <= reorder_status < 300
listing = client.get("/api/segments").json()
assert len(listing) == 5
sort_orders = [s["sort_order"] for s in listing]
assert len(set(sort_orders)) == 5

View file

@ -0,0 +1,78 @@
import sqlite3
from pathlib import Path
from app.database import get_connection, init_db
def test_init_db_creates_tables(tmp_data_dir: Path) -> None:
db_path = tmp_data_dir / "handin.db"
init_db(db_path)
conn = sqlite3.connect(db_path)
cursor = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
)
tables = {row[0] for row in cursor.fetchall()}
conn.close()
assert "site" in tables
assert "segments" in tables
def test_init_db_idempotent(tmp_data_dir: Path) -> None:
db_path = tmp_data_dir / "handin.db"
init_db(db_path)
init_db(db_path)
conn = sqlite3.connect(db_path)
cursor = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
)
tables = {row[0] for row in cursor.fetchall()}
conn.close()
assert "site" in tables
assert "segments" in tables
def test_wal_mode_enabled(tmp_data_dir: Path) -> None:
db_path = tmp_data_dir / "handin.db"
init_db(db_path)
conn = sqlite3.connect(db_path)
cursor = conn.execute("PRAGMA journal_mode")
mode = cursor.fetchone()[0]
conn.close()
assert mode == "wal"
def test_get_connection_busy_timeout(db: Path) -> None:
conn = get_connection(db)
cursor = conn.execute("PRAGMA busy_timeout")
timeout = cursor.fetchone()[0]
conn.close()
assert timeout == 5000
def test_segments_table_schema(db: Path) -> None:
conn = sqlite3.connect(db)
cursor = conn.execute("PRAGMA table_info(segments)")
columns = {row[1] for row in cursor.fetchall()}
conn.close()
expected = {
"id", "type", "sort_order", "title",
"content", "metadata", "created_at", "updated_at",
}
assert columns == expected
def test_site_table_schema(db: Path) -> None:
conn = sqlite3.connect(db)
cursor = conn.execute("PRAGMA table_info(site)")
columns = {row[1] for row in cursor.fetchall()}
conn.close()
assert columns == {"key", "value"}

View file

@ -0,0 +1,224 @@
from uuid import uuid4
from fastapi.testclient import TestClient
from tests.conftest import TEST_ADMIN_TOKEN
BEARER_HEADERS = {"Authorization": f"Bearer {TEST_ADMIN_TOKEN}"}
def _create_segment(
client: TestClient,
title: str = "Test",
seg_type: str = "markdown",
) -> dict[str, object]:
resp = client.post(
"/api/segments",
json={"type": seg_type, "title": title},
headers=BEARER_HEADERS,
)
assert resp.status_code == 201
data: dict[str, object] = resp.json()
return data
# --- Segment route tests ---
def test_list_segments_empty(client: TestClient) -> None:
resp = client.get("/api/segments")
assert resp.status_code == 200
assert resp.json() == []
def test_create_segment(client: TestClient) -> None:
data = _create_segment(client, title="Intro")
assert "id" in data
assert data["title"] == "Intro"
assert data["type"] == "markdown"
assert data["sort_order"] == 0
def test_create_segment_unauthorized(client: TestClient) -> None:
resp = client.post("/api/segments", json={"type": "markdown", "title": "X"})
assert resp.status_code == 401
def test_create_segment_wrong_token(client: TestClient) -> None:
resp = client.post(
"/api/segments",
json={"type": "markdown", "title": "X"},
headers={"Authorization": "Bearer wrong-token"},
)
assert resp.status_code == 401
def test_create_segment_via_query_param(client: TestClient) -> None:
resp = client.post(
f"/api/segments?token={TEST_ADMIN_TOKEN}",
json={"type": "markdown", "title": "Query Auth"},
)
assert resp.status_code == 201
def test_get_segment(client: TestClient) -> None:
created = _create_segment(client, title="Fetch Me")
seg_id = created["id"]
resp = client.get(f"/api/segments/{seg_id}")
assert resp.status_code == 200
assert resp.json()["title"] == "Fetch Me"
def test_get_segment_not_found(client: TestClient) -> None:
resp = client.get(f"/api/segments/{uuid4()}")
assert resp.status_code == 404
def test_update_segment(client: TestClient) -> None:
created = _create_segment(client, title="Original")
seg_id = created["id"]
resp = client.patch(
f"/api/segments/{seg_id}",
json={"title": "Updated"},
headers=BEARER_HEADERS,
)
assert resp.status_code == 200
assert resp.json()["title"] == "Updated"
def test_update_segment_not_found(client: TestClient) -> None:
resp = client.patch(
f"/api/segments/{uuid4()}",
json={"title": "Nope"},
headers=BEARER_HEADERS,
)
assert resp.status_code == 404
def test_update_segment_unauthorized(client: TestClient) -> None:
created = _create_segment(client, title="NoAuth")
seg_id = created["id"]
resp = client.patch(
f"/api/segments/{seg_id}",
json={"title": "Hacked"},
)
assert resp.status_code == 401
def test_delete_segment(client: TestClient) -> None:
created = _create_segment(client, title="Delete Me")
seg_id = created["id"]
resp = client.delete(f"/api/segments/{seg_id}", headers=BEARER_HEADERS)
assert resp.status_code == 204
resp2 = client.get(f"/api/segments/{seg_id}")
assert resp2.status_code == 404
def test_delete_segment_not_found(client: TestClient) -> None:
resp = client.delete(f"/api/segments/{uuid4()}", headers=BEARER_HEADERS)
assert resp.status_code == 404
def test_delete_segment_unauthorized(client: TestClient) -> None:
created = _create_segment(client, title="NoAuthDel")
seg_id = created["id"]
resp = client.delete(f"/api/segments/{seg_id}")
assert resp.status_code == 401
def test_reorder_segments(client: TestClient) -> None:
a = _create_segment(client, title="A")
b = _create_segment(client, title="B")
c = _create_segment(client, title="C")
resp = client.put(
"/api/segments/reorder",
json={"segment_ids": [c["id"], a["id"], b["id"]]},
headers=BEARER_HEADERS,
)
assert resp.status_code == 200
listing = client.get("/api/segments").json()
titles = [s["title"] for s in listing]
assert titles == ["C", "A", "B"]
def test_reorder_segments_invalid_ids(client: TestClient) -> None:
resp = client.put(
"/api/segments/reorder",
json={"segment_ids": [str(uuid4())]},
headers=BEARER_HEADERS,
)
assert resp.status_code == 400
def test_reorder_segments_unauthorized(client: TestClient) -> None:
resp = client.put(
"/api/segments/reorder",
json={"segment_ids": []},
)
assert resp.status_code == 401
# --- Site route tests ---
def test_get_site_title_default(client: TestClient) -> None:
resp = client.get("/api/site")
assert resp.status_code == 200
assert resp.json()["title"] == "Untitled Site"
def test_update_site_title(client: TestClient) -> None:
resp = client.put(
"/api/site",
json={"title": "My Course"},
headers=BEARER_HEADERS,
)
assert resp.status_code == 200
assert resp.json()["title"] == "My Course"
resp2 = client.get("/api/site")
assert resp2.json()["title"] == "My Course"
def test_update_site_title_unauthorized(client: TestClient) -> None:
resp = client.put("/api/site", json={"title": "Hacked"})
assert resp.status_code == 401
# --- Auth route tests ---
def test_auth_verify_valid_bearer(client: TestClient) -> None:
resp = client.get("/api/auth/verify", headers=BEARER_HEADERS)
assert resp.status_code == 200
assert resp.json() == {"valid": True}
def test_auth_verify_valid_query_param(client: TestClient) -> None:
resp = client.get(f"/api/auth/verify?token={TEST_ADMIN_TOKEN}")
assert resp.status_code == 200
assert resp.json() == {"valid": True}
def test_auth_verify_invalid(client: TestClient) -> None:
resp = client.get(
"/api/auth/verify",
headers={"Authorization": "Bearer wrong-token"},
)
assert resp.status_code == 401
def test_auth_verify_no_token(client: TestClient) -> None:
resp = client.get("/api/auth/verify")
assert resp.status_code == 401
# --- Health test ---
def test_health(client: TestClient) -> None:
resp = client.get("/api/health")
assert resp.status_code == 200
assert resp.json()["status"] == "ok"

View file

@ -0,0 +1,172 @@
import time
from pathlib import Path
from uuid import uuid4
import pytest
from app.models import SegmentCreateRequest, SegmentType, SegmentUpdateRequest
from app.services.segment_service import (
create_segment,
delete_segment,
get_segment,
list_segments,
reorder_segments,
update_segment,
)
from app.services.site_service import get_site_title, update_site_title
# --- Segment Service Tests ---
def test_create_segment(db: Path) -> None:
request = SegmentCreateRequest(
type=SegmentType.MARKDOWN,
title="Intro",
content="Hello world",
)
segment = create_segment(db, request)
assert segment.id is not None
assert segment.sort_order == 0
assert segment.title == "Intro"
assert segment.type == SegmentType.MARKDOWN
assert segment.content == "Hello world"
assert segment.created_at == segment.updated_at
def test_create_segment_auto_increments_sort_order(db: Path) -> None:
for i in range(3):
request = SegmentCreateRequest(
type=SegmentType.MARKDOWN,
title=f"Segment {i}",
)
segment = create_segment(db, request)
assert segment.sort_order == i
def test_list_segments_empty(db: Path) -> None:
segments = list_segments(db)
assert segments == []
def test_list_segments_ordered(db: Path) -> None:
titles = ["First", "Second", "Third"]
for title in titles:
create_segment(
db,
SegmentCreateRequest(type=SegmentType.MARKDOWN, title=title),
)
segments = list_segments(db)
assert [s.title for s in segments] == titles
assert [s.sort_order for s in segments] == [0, 1, 2]
def test_get_segment_found(db: Path) -> None:
created = create_segment(
db,
SegmentCreateRequest(type=SegmentType.PDF, title="Slides"),
)
fetched = get_segment(db, created.id)
assert fetched is not None
assert fetched.id == created.id
assert fetched.title == "Slides"
assert fetched.type == SegmentType.PDF
def test_get_segment_not_found(db: Path) -> None:
result = get_segment(db, uuid4())
assert result is None
def test_update_segment_title(db: Path) -> None:
created = create_segment(
db,
SegmentCreateRequest(
type=SegmentType.MARKDOWN,
title="Old Title",
content="Keep me",
),
)
time.sleep(0.01)
updated = update_segment(
db,
created.id,
SegmentUpdateRequest(title="New Title"),
)
assert updated is not None
assert updated.title == "New Title"
assert updated.content == "Keep me"
assert updated.updated_at > updated.created_at
def test_update_segment_not_found(db: Path) -> None:
result = update_segment(
db,
uuid4(),
SegmentUpdateRequest(title="Nope"),
)
assert result is None
def test_delete_segment(db: Path) -> None:
created = create_segment(
db,
SegmentCreateRequest(type=SegmentType.MARKDOWN, title="Bye"),
)
assert delete_segment(db, created.id) is True
assert get_segment(db, created.id) is None
def test_delete_segment_not_found(db: Path) -> None:
assert delete_segment(db, uuid4()) is False
def test_reorder_segments(db: Path) -> None:
a = create_segment(
db,
SegmentCreateRequest(type=SegmentType.MARKDOWN, title="A"),
)
b = create_segment(
db,
SegmentCreateRequest(type=SegmentType.MARKDOWN, title="B"),
)
c = create_segment(
db,
SegmentCreateRequest(type=SegmentType.MARKDOWN, title="C"),
)
reorder_segments(db, [c.id, a.id, b.id])
segments = list_segments(db)
assert [s.title for s in segments] == ["C", "A", "B"]
assert [s.sort_order for s in segments] == [0, 1, 2]
def test_reorder_segments_invalid_ids(db: Path) -> None:
create_segment(
db,
SegmentCreateRequest(type=SegmentType.MARKDOWN, title="Only"),
)
with pytest.raises(ValueError):
reorder_segments(db, [uuid4()])
# --- Site Service Tests ---
def test_get_site_title_default(db: Path) -> None:
assert get_site_title(db) == "Untitled Site"
def test_update_and_get_site_title(db: Path) -> None:
update_site_title(db, "My Course")
assert get_site_title(db) == "My Course"
def test_update_site_title_overwrites(db: Path) -> None:
update_site_title(db, "First")
update_site_title(db, "Second")
assert get_site_title(db) == "Second"

133
coding-style.md Normal file
View file

@ -0,0 +1,133 @@
# Coding Style Rules
## Python (Backend)
### General
- Python 3.12+ features encouraged (type parameter syntax, match statements where clearer).
- `ruff` handles formatting and linting. Config lives in `pyproject.toml`.
- `mypy` in strict mode. No `Any` types. No `# type: ignore` without a comment explaining why.
### Naming
| Entity | Convention | Example |
|---|---|---|
| Files/modules | `snake_case` | `order_service.py` |
| Functions | `snake_case` | `calculate_total()` |
| Classes | `PascalCase` | `Invoice` |
| Constants | `UPPER_SNAKE_CASE` | `MAX_RETRY_COUNT` |
| Database columns | `snake_case` | `created_at` |
| Pydantic models | `PascalCase` + suffix | `ItemCreateRequest`, `ItemResponse` |
### Functions
- Max 50 lines. If longer, extract helpers.
- Use early returns to avoid nesting (see Negative Space Programming below).
### Negative Space Programming (Fail Early)
Reject invalid state at the top of every function. The happy path is the code that
remains after all guard clauses. This applies at every layer.
**Guard clauses before logic:**
```python
# CORRECT - fail early, happy path is clean
def process_order(quantity: int, unit_price: Decimal) -> OrderResult:
if quantity <= 0:
raise ValidationError("Quantity must be positive")
if unit_price <= 0:
raise ValidationError("Unit price must be positive")
total = quantity * unit_price
return OrderResult(total=total)
# WRONG - nested conditionals, happy path buried
def process_order(quantity: int, unit_price: Decimal) -> OrderResult:
if quantity > 0:
if unit_price > 0:
total = quantity * unit_price
return OrderResult(total=total)
else:
raise ValidationError("Unit price must be positive")
else:
raise ValidationError("Quantity must be positive")
```
**Key rules:**
- Max 3 levels of indentation in any function. If deeper, extract or flatten.
- Never use `else` after a `return`, `raise`, or `throw` — the guard already exited.
- No silent failures: if something is wrong, raise/throw immediately with context.
- Optional/nullable returns are acceptable only when "not found" is a normal case (e.g., repository lookups). For business rule violations, always raise.
### Imports
- Group: stdlib -> third-party -> local. Ruff enforces this.
- No wildcard imports (`from module import *`).
- No relative imports across packages. Use absolute imports: `from app.module import ...`.
### Type Hints
- All function signatures must have type hints (params and return).
- Use `UUID` from `uuid`, not `str`, for ID fields.
- Use `datetime` from `datetime`, not `str`, for timestamps.
- Collections: `list[Item]`, not `List[Item]` (Python 3.12+).
## TypeScript (Frontend)
### General
- Strict TypeScript. No `any`. No `@ts-ignore` without explanation.
- ESLint + Prettier via the project config. No overrides in individual files.
### Naming
| Entity | Convention | Example |
|---|---|---|
| Files (components) | `PascalCase.vue` | `OrderForm.vue` |
| Files (composables) | `camelCase.ts` | `useOrderForm.ts` |
| Files (utilities) | `camelCase.ts` | `formatCurrency.ts` |
| Variables/functions | `camelCase` | `calculateTotal()` |
| Types/interfaces | `PascalCase` | `Order` |
| Constants | `UPPER_SNAKE_CASE` | `DEFAULT_PAGE_SIZE` |
| Props | `camelCase` | `orderItems` |
| Events | `camelCase` verb | `@submit`, `@update:modelValue` |
### Vue Components
- Use `<script setup lang="ts">` exclusively. No Options API.
- Props and emits must be typed with `defineProps<{}>()` and `defineEmits<{}>()`.
- One component per file. Max 400 lines target.
- Composables for reusable stateful logic. Components for reusable UI.
### No Default Exports (except Vue components)
```typescript
// CORRECT
export function calculateTotal(...) { ... }
export interface Order { ... }
// WRONG
export default function calculateTotal(...) { ... }
```
Vue components are the exception (required by Vue conventions).
## Shared Rules
### Dependencies
- Do not add dependencies without discussing first. Check if stdlib or an existing dep solves it.
- Pin exact versions in `pyproject.toml` and `package.json`.
### Comments
- Code should be self-documenting. Comments explain WHY, not WHAT.
- No commented-out code. Use git history.
- Docstrings on public functions in the domain layer (they're the API for other layers).
### File Organization
- No barrel files (`__init__.py` re-exports or `index.ts` re-exports). Import directly.
- No "utils" grab-bag files. If a utility is specific to a domain, put it in that domain's directory.
- If a file exceeds 600 lines, it must be split before adding more code.

29
docker-compose.yml Normal file
View file

@ -0,0 +1,29 @@
services:
web:
build:
context: .
target: prod
environment:
- HANDIN_ADMIN_TOKEN=${HANDIN_ADMIN_TOKEN:?Set HANDIN_ADMIN_TOKEN}
- HANDIN_SITE_TITLE=${HANDIN_SITE_TITLE:-Untitled Site}
- HANDIN_DATA_DIR=/data
volumes:
- handin-data:/data
frontend:
image: node:20-alpine
working_dir: /app
command: sh -c "npm install && npm run dev -- --host"
ports:
- "5174:5174"
environment:
- API_URL=http://web:8000
volumes:
- ./frontend:/app
- frontend-node-modules:/app/node_modules
depends_on:
- web
volumes:
handin-data:
frontend-node-modules:

7
frontend/env.d.ts vendored Normal file
View file

@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<object, object, unknown>;
export default component;
}

12
frontend/index.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Handin</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2125
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

26
frontend/package.json Normal file
View file

@ -0,0 +1,26 @@
{
"name": "handin-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"vue": "3.5.13",
"marked": "15.0.7",
"vuedraggable": "4.1.0"
},
"devDependencies": {
"@tailwindcss/vite": "4.1.4",
"@tailwindcss/typography": "0.5.16",
"@vitejs/plugin-vue": "5.2.3",
"tailwindcss": "4.1.4",
"typescript": "5.7.3",
"vite": "6.2.6",
"vue-tsc": "2.2.8"
}
}

7
frontend/src/App.vue Normal file
View file

@ -0,0 +1,7 @@
<script setup lang="ts">
import HomePage from "@/views/HomePage.vue";
</script>
<template>
<HomePage />
</template>

View file

@ -0,0 +1,179 @@
<script setup lang="ts">
import { ref, computed } from "vue";
import type { SegmentType } from "@/types/segment";
import AssetUploader from "@/components/admin/AssetUploader.vue";
const emit = defineEmits<{
create: [data: { type: SegmentType; title: string; content: string; metadata?: Record<string, unknown> }];
cancel: [];
}>();
const segmentTypes: { value: SegmentType; label: string }[] = [
{ value: "markdown", label: "Markdown" },
{ value: "pdf", label: "PDF" },
{ value: "video", label: "Video" },
{ value: "audio", label: "Audio" },
{ value: "iframe", label: "Iframe" },
{ value: "gallery", label: "Gallery" },
{ value: "link", label: "Link" },
];
const selectedType = ref<SegmentType>("markdown");
const title = ref("");
const content = ref("");
const galleryImages = ref<string[]>([]);
const canSubmit = computed(() => {
if (!title.value.trim()) return false;
if (selectedType.value === "gallery") return galleryImages.value.length > 0;
if (selectedType.value === "markdown") return true;
return content.value.trim().length > 0;
});
function handleAssetUploaded(filename: string) {
content.value = `/api/assets/${filename}`;
}
function handleGalleryImageUploaded(filename: string) {
galleryImages.value = [...galleryImages.value, `/api/assets/${filename}`];
}
function removeGalleryImage(index: number) {
galleryImages.value = galleryImages.value.filter((_, i) => i !== index);
}
function handleSubmit() {
const data: { type: SegmentType; title: string; content: string; metadata?: Record<string, unknown> } = {
type: selectedType.value,
title: title.value.trim(),
content: content.value,
};
if (selectedType.value === "gallery") {
data.metadata = { images: galleryImages.value };
}
emit("create", data);
}
const assetAcceptMap: Record<string, string> = {
pdf: "application/pdf",
video: "video/*",
audio: "audio/*",
};
</script>
<template>
<Teleport to="body">
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
@click.self="$emit('cancel')"
>
<div class="mx-4 w-full max-w-lg rounded-lg bg-white p-6 shadow-xl dark:bg-slate-800">
<h2 class="mb-4 text-lg font-semibold text-slate-900 dark:text-slate-100">Add New Segment</h2>
<!-- Type selector: button group -->
<div class="mb-4">
<label class="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-300">Type</label>
<div class="flex flex-wrap gap-2">
<button
v-for="st in segmentTypes"
:key="st.value"
class="rounded-full px-3 py-1 text-sm font-medium transition-colors"
:class="
selectedType === st.value
? 'bg-primary-300 text-slate-900 dark:bg-primary-600 dark:text-white'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600'
"
@click="selectedType = st.value; content = ''; galleryImages = []"
>
{{ st.label }}
</button>
</div>
</div>
<!-- Title -->
<div class="mb-4">
<label class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Title</label>
<input
v-model="title"
type="text"
placeholder="Segment title"
class="w-full rounded border border-slate-300 px-3 py-2 text-sm focus:border-primary-400 focus:outline-none dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 dark:placeholder-slate-500"
/>
</div>
<!-- Content: type-dependent -->
<div class="mb-4">
<label class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Content</label>
<!-- Markdown: textarea -->
<textarea
v-if="selectedType === 'markdown'"
v-model="content"
rows="6"
placeholder="Markdown content..."
class="w-full rounded border border-slate-300 px-3 py-2 font-mono text-sm focus:border-primary-400 focus:outline-none dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 dark:placeholder-slate-500"
/>
<!-- Iframe / Link: URL input -->
<input
v-else-if="selectedType === 'iframe' || selectedType === 'link'"
v-model="content"
type="url"
:placeholder="selectedType === 'link' ? 'https://external-site.com' : 'https://...'"
class="w-full rounded border border-slate-300 px-3 py-2 text-sm focus:border-primary-400 focus:outline-none dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 dark:placeholder-slate-500"
/>
<!-- PDF / Video / Audio -->
<div v-else-if="selectedType === 'pdf' || selectedType === 'video' || selectedType === 'audio'">
<div v-if="content" class="mb-2 flex items-center gap-2 rounded bg-slate-50 px-3 py-2 text-sm text-slate-600">
<span class="truncate">{{ content }}</span>
<button class="shrink-0 text-red-400 hover:text-red-600" @click="content = ''">&times;</button>
</div>
<AssetUploader
:accept="assetAcceptMap[selectedType]"
:label="`Upload ${selectedType} file`"
@uploaded="handleAssetUploaded"
/>
</div>
<!-- Gallery -->
<div v-else-if="selectedType === 'gallery'">
<div v-if="galleryImages.length > 0" class="mb-3 grid grid-cols-4 gap-2">
<div
v-for="(img, i) in galleryImages"
:key="i"
class="group relative aspect-square overflow-hidden rounded bg-slate-100"
>
<img :src="img" class="h-full w-full object-cover" />
<button
class="absolute right-1 top-1 hidden rounded bg-red-500 px-1.5 text-xs text-white group-hover:block"
@click="removeGalleryImage(i)"
>
&times;
</button>
</div>
</div>
<AssetUploader accept="image/*" label="Upload gallery image" @uploaded="handleGalleryImageUploaded" />
</div>
</div>
<!-- Actions -->
<div class="flex justify-end gap-2">
<button
class="rounded px-3 py-1.5 text-sm text-slate-500 transition-colors hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
@click="$emit('cancel')"
>
Cancel
</button>
<button
:disabled="!canSubmit"
class="rounded bg-primary-300 px-4 py-1.5 text-sm font-medium text-slate-900 transition-colors hover:bg-primary-400 disabled:opacity-50 dark:bg-primary-600 dark:text-white dark:hover:bg-primary-500"
@click="handleSubmit"
>
Create
</button>
</div>
</div>
</div>
</Teleport>
</template>

View file

@ -0,0 +1,30 @@
<script setup lang="ts">
defineEmits<{
"add-segment": [];
logout: [];
}>();
</script>
<template>
<div class="border-b border-primary-100 bg-primary-50 px-6 py-2 dark:border-primary-700/30 dark:bg-slate-800">
<div class="mx-auto flex max-w-6xl items-center justify-between">
<span class="rounded bg-primary-200 px-2 py-0.5 text-xs font-semibold text-primary-700 dark:bg-primary-700/30 dark:text-primary-300">
Editing
</span>
<div class="flex items-center gap-3">
<button
class="rounded bg-primary-300 px-3 py-1.5 text-sm font-medium text-slate-900 transition-colors hover:bg-primary-400 dark:bg-primary-600 dark:text-white dark:hover:bg-primary-500"
@click="$emit('add-segment')"
>
+ Add Segment
</button>
<button
class="rounded px-3 py-1.5 text-sm text-slate-500 transition-colors hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
@click="$emit('logout')"
>
Logout
</button>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,68 @@
<script setup lang="ts">
import { ref } from "vue";
import { useAdmin } from "@/composables/useAdmin";
import { useAssetUpload } from "@/composables/useAssetUpload";
const props = defineProps<{
accept?: string;
label?: string;
}>();
const emit = defineEmits<{
uploaded: [filename: string];
}>();
const { authHeaders } = useAdmin();
const { uploading, error, upload } = useAssetUpload();
const dragging = ref(false);
const fileInput = ref<HTMLInputElement | null>(null);
async function handleFile(file: File) {
const filename = await upload(file, authHeaders.value);
if (filename) {
emit("uploaded", filename);
}
}
function onDrop(e: DragEvent) {
dragging.value = false;
const file = e.dataTransfer?.files[0];
if (file) void handleFile(file);
}
function onFileSelect(e: Event) {
const target = e.target as HTMLInputElement;
const file = target.files?.[0];
if (file) void handleFile(file);
target.value = "";
}
</script>
<template>
<div
class="relative cursor-pointer rounded-lg border-2 border-dashed p-6 text-center transition-colors"
:class="dragging ? 'border-primary-300 bg-primary-50 dark:border-primary-500 dark:bg-primary-900/20' : 'border-slate-300 hover:border-primary-300 dark:border-slate-600 dark:hover:border-primary-500'"
@dragover.prevent="dragging = true"
@dragleave="dragging = false"
@drop.prevent="onDrop"
@click="fileInput?.click()"
>
<input
ref="fileInput"
type="file"
class="hidden"
:accept="props.accept"
@change="onFileSelect"
/>
<div v-if="uploading" class="text-sm text-slate-500 dark:text-slate-400">Uploading...</div>
<div v-else>
<svg class="mx-auto mb-2 h-8 w-8 text-slate-400 dark:text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p class="text-sm text-slate-500 dark:text-slate-400">{{ props.label ?? "Drop file here or click to browse" }}</p>
</div>
<p v-if="error" class="mt-2 text-sm text-red-500">{{ error }}</p>
</div>
</template>

View file

@ -0,0 +1,188 @@
<script setup lang="ts">
import { ref } from "vue";
import type { Segment } from "@/types/segment";
import AssetUploader from "@/components/admin/AssetUploader.vue";
const props = defineProps<{
segment: Segment;
}>();
const emit = defineEmits<{
save: [updates: { title?: string; content?: string; metadata?: Record<string, unknown> }];
cancel: [];
delete: [];
}>();
const title = ref(props.segment.title);
const content = ref(props.segment.content);
const metadata = ref<Record<string, unknown>>({ ...props.segment.metadata });
const showDeleteConfirm = ref(false);
function handleSave() {
const updates: { title?: string; content?: string; metadata?: Record<string, unknown> } = {};
if (title.value !== props.segment.title) updates.title = title.value;
if (content.value !== props.segment.content) updates.content = content.value;
if (JSON.stringify(metadata.value) !== JSON.stringify(props.segment.metadata)) {
updates.metadata = metadata.value;
}
emit("save", updates);
}
function handleAssetUploaded(filename: string) {
content.value = `/api/assets/${filename}`;
}
function handleGalleryImageUploaded(filename: string) {
const images = Array.isArray(metadata.value.images)
? [...(metadata.value.images as string[])]
: [];
images.push(`/api/assets/${filename}`);
metadata.value = { ...metadata.value, images };
}
function removeGalleryImage(index: number) {
const images = Array.isArray(metadata.value.images)
? [...(metadata.value.images as string[])]
: [];
images.splice(index, 1);
metadata.value = { ...metadata.value, images };
}
const assetAcceptMap: Record<string, string> = {
pdf: "application/pdf",
video: "video/*",
audio: "audio/*",
};
</script>
<template>
<div class="mt-4 rounded-lg border border-primary-100 bg-primary-50/50 p-4 dark:border-slate-600 dark:bg-slate-700/50">
<!-- Title -->
<div class="mb-4">
<label class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Title</label>
<input
v-model="title"
type="text"
class="w-full rounded border border-slate-300 px-3 py-2 text-sm focus:border-primary-400 focus:outline-none dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100"
/>
</div>
<!-- Content: depends on segment type -->
<div class="mb-4">
<label class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Content</label>
<!-- Markdown: textarea -->
<textarea
v-if="segment.type === 'markdown'"
v-model="content"
rows="8"
class="w-full rounded border border-slate-300 px-3 py-2 font-mono text-sm focus:border-primary-400 focus:outline-none dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100"
/>
<!-- Iframe / Link: URL input -->
<input
v-else-if="segment.type === 'iframe' || segment.type === 'link'"
v-model="content"
type="url"
:placeholder="segment.type === 'link' ? 'https://external-site.com' : 'https://...'"
class="w-full rounded border border-slate-300 px-3 py-2 text-sm focus:border-primary-400 focus:outline-none dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100"
/>
<!-- PDF / Video / Audio: file upload + URL display -->
<div v-else-if="segment.type === 'pdf' || segment.type === 'video' || segment.type === 'audio'">
<div v-if="content" class="mb-2 flex items-center gap-2 rounded bg-white px-3 py-2 text-sm text-slate-600 dark:bg-slate-700 dark:text-slate-300">
<span class="truncate">{{ content }}</span>
<button
class="shrink-0 text-red-400 hover:text-red-600"
@click="content = ''"
>
&times;
</button>
</div>
<AssetUploader
:accept="assetAcceptMap[segment.type]"
:label="`Upload ${segment.type} file`"
@uploaded="handleAssetUploaded"
/>
</div>
<!-- Gallery: multiple image upload -->
<div v-else-if="segment.type === 'gallery'">
<div v-if="Array.isArray(metadata.images) && (metadata.images as string[]).length > 0" class="mb-3 grid grid-cols-4 gap-2">
<div
v-for="(img, i) in (metadata.images as string[])"
:key="i"
class="group relative aspect-square overflow-hidden rounded bg-slate-100"
>
<img :src="img" class="h-full w-full object-cover" />
<button
class="absolute right-1 top-1 hidden rounded bg-red-500 px-1.5 text-xs text-white group-hover:block"
@click="removeGalleryImage(i)"
>
&times;
</button>
</div>
</div>
<AssetUploader
accept="image/*"
label="Upload gallery image"
@uploaded="handleGalleryImageUploaded"
/>
</div>
</div>
<!-- Actions -->
<div class="flex items-center justify-between">
<button
class="rounded px-3 py-1.5 text-sm text-red-500 transition-colors hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-950/30"
@click="showDeleteConfirm = true"
>
Delete
</button>
<div class="flex gap-2">
<button
class="rounded px-3 py-1.5 text-sm text-slate-500 transition-colors hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
@click="$emit('cancel')"
>
Cancel
</button>
<button
class="rounded bg-primary-300 px-4 py-1.5 text-sm font-medium text-slate-900 transition-colors hover:bg-primary-400 dark:bg-primary-600 dark:text-white dark:hover:bg-primary-500"
@click="handleSave"
>
Save
</button>
</div>
</div>
<!-- Delete confirmation modal -->
<Teleport to="body">
<div
v-if="showDeleteConfirm"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
@click.self="showDeleteConfirm = false"
>
<div class="w-full max-w-sm rounded-lg bg-white p-6 shadow-xl dark:bg-slate-800">
<h3 class="mb-2 text-lg font-semibold text-slate-900 dark:text-slate-100">Delete Segment</h3>
<p class="mb-4 text-sm text-slate-600 dark:text-slate-400">
Are you sure you want to delete <strong>"{{ segment.title }}"</strong>? This action cannot be undone.
</p>
<div class="flex justify-end gap-2">
<button
class="rounded px-3 py-1.5 text-sm text-slate-500 transition-colors hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
@click="showDeleteConfirm = false"
>
Cancel
</button>
<button
class="rounded bg-red-500 px-4 py-1.5 text-sm font-medium text-white transition-colors hover:bg-red-600"
@click="showDeleteConfirm = false; $emit('delete')"
>
Delete
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>

View file

@ -0,0 +1,79 @@
<script setup lang="ts">
import { ref } from "vue";
import { useAdmin } from "@/composables/useAdmin";
const { isAdmin, verifying, login } = useAdmin();
const emit = defineEmits<{
authenticated: [];
}>();
const showInput = ref(false);
const tokenInput = ref("");
const loginError = ref(false);
async function handleSubmit() {
loginError.value = false;
const success = await login(tokenInput.value);
if (success) {
showInput.value = false;
tokenInput.value = "";
emit("authenticated");
} else {
loginError.value = true;
}
}
function handleCancel() {
showInput.value = false;
tokenInput.value = "";
loginError.value = false;
}
</script>
<template>
<div v-if="!isAdmin" class="flex items-center">
<!-- Lock icon button -->
<button
v-if="!showInput"
class="rounded p-1.5 text-slate-700 transition-colors hover:bg-primary-400/30"
title="Admin login"
@click="showInput = true"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
</button>
<!-- Inline token input -->
<form v-else class="flex items-center gap-2" @submit.prevent="handleSubmit">
<input
v-model="tokenInput"
type="password"
placeholder="Admin token"
class="h-8 w-40 rounded border px-2 text-sm text-slate-900 placeholder-slate-400 focus:border-primary-400 focus:outline-none dark:bg-slate-700 dark:text-slate-100 dark:placeholder-slate-500"
:class="loginError ? 'border-red-400' : 'border-slate-300 dark:border-slate-600'"
autofocus
/>
<button
type="submit"
:disabled="verifying || !tokenInput"
class="h-8 rounded bg-slate-800 px-3 text-sm font-medium text-white transition-colors hover:bg-slate-700 disabled:opacity-50 dark:bg-slate-600 dark:hover:bg-slate-500"
>
{{ verifying ? "..." : "Go" }}
</button>
<button
type="button"
class="h-8 rounded px-2 text-sm text-slate-600 transition-colors hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100"
@click="handleCancel"
>
&times;
</button>
</form>
</div>
</template>

View file

@ -0,0 +1,135 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import type { Segment } from "@/types/segment";
import { useAdmin } from "@/composables/useAdmin";
import { useDarkMode } from "@/composables/useDarkMode";
import SidebarNav from "@/components/layout/SidebarNav.vue";
import MobileHeader from "@/components/layout/MobileHeader.vue";
import TokenPrompt from "@/components/admin/TokenPrompt.vue";
import AdminToolbar from "@/components/admin/AdminToolbar.vue";
defineProps<{
segments: Segment[];
siteTitle: string;
activeId: string | null;
}>();
const emit = defineEmits<{
navigate: [id: string];
"add-segment": [];
logout: [];
}>();
const { isAdmin } = useAdmin();
const { isDark, toggle: toggleDark } = useDarkMode();
const showBackToTop = ref(false);
function handleScroll() {
showBackToTop.value = window.scrollY > 300;
}
function scrollToTop() {
window.scrollTo({ top: 0, behavior: "smooth" });
}
function handleNavigate(id: string) {
emit("navigate", id);
}
onMounted(() => {
window.addEventListener("scroll", handleScroll, { passive: true });
});
onUnmounted(() => {
window.removeEventListener("scroll", handleScroll);
});
</script>
<template>
<div class="min-h-screen bg-slate-50 dark:bg-slate-900">
<!-- Sticky header: title bar + nav -->
<div class="sticky top-0 z-20">
<!-- Title bar -->
<header class="flex items-center justify-between bg-primary-300 px-6 py-4">
<h1 class="text-xl font-bold text-slate-900">{{ siteTitle }}</h1>
<div class="flex items-center gap-2">
<!-- Dark mode toggle -->
<button
class="rounded p-1.5 text-slate-700 transition-colors hover:bg-primary-400/30"
:title="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
@click="toggleDark"
>
<!-- Sun icon (shown in dark mode) -->
<svg v-if="isDark" class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<!-- Moon icon (shown in light mode) -->
<svg v-else class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
</button>
<TokenPrompt />
</div>
</header>
<!-- Desktop tab nav -->
<div class="hidden md:block">
<SidebarNav
:segments="segments"
:site-title="siteTitle"
:active-id="activeId"
@navigate="handleNavigate"
/>
</div>
<!-- Mobile header with hamburger -->
<div class="md:hidden">
<MobileHeader
:site-title="siteTitle"
:segments="segments"
:active-id="activeId"
@navigate="handleNavigate"
/>
</div>
</div>
<!-- Admin toolbar -->
<AdminToolbar
v-if="isAdmin"
@add-segment="$emit('add-segment')"
@logout="$emit('logout')"
/>
<!-- Main content -->
<main class="mx-auto max-w-6xl px-6 py-8">
<slot />
</main>
<!-- Back to top button -->
<Transition name="fade-up">
<button
v-if="showBackToTop"
class="fixed right-6 bottom-6 z-30 rounded-full bg-primary-300 p-3 shadow-lg transition-colors hover:bg-primary-400"
title="Back to top"
@click="scrollToTop"
>
<svg class="h-5 w-5 text-slate-900" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
</button>
</Transition>
</div>
</template>
<style scoped>
.fade-up-enter-active,
.fade-up-leave-active {
transition: opacity 200ms ease, transform 200ms ease;
}
.fade-up-enter-from,
.fade-up-leave-to {
opacity: 0;
transform: translateY(8px);
}
</style>

View file

@ -0,0 +1,129 @@
<script setup lang="ts">
import { ref } from "vue";
import type { Segment } from "@/types/segment";
defineProps<{
siteTitle: string;
segments: Segment[];
activeId: string | null;
}>();
const emit = defineEmits<{
navigate: [id: string];
}>();
const open = ref(false);
function toggle() {
open.value = !open.value;
}
function handleNav(id: string) {
open.value = false;
emit("navigate", id);
}
function handleBackdropClick() {
open.value = false;
}
</script>
<template>
<!-- Mobile nav bar -->
<div class="flex items-center justify-between border-b border-slate-200 bg-white px-4 py-2.5 dark:border-slate-700 dark:bg-slate-800">
<span class="text-sm font-medium text-slate-500 dark:text-slate-400">Sections</span>
<button
class="text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100"
aria-label="Open navigation"
@click="toggle"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
<!-- Backdrop -->
<Transition name="fade">
<div
v-if="open"
class="fixed inset-0 z-40 bg-black/40"
@click="handleBackdropClick"
/>
</Transition>
<!-- Dropdown panel -->
<Transition name="dropdown">
<div
v-if="open"
class="fixed top-0 right-0 left-0 z-50"
>
<div class="bg-white shadow-lg dark:bg-slate-800">
<div class="flex items-center justify-between border-b border-slate-200 px-4 py-3 dark:border-slate-700">
<span class="text-sm font-semibold text-slate-900 dark:text-slate-100">{{ siteTitle }}</span>
<button
class="text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100"
aria-label="Close navigation"
@click="toggle"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<ul class="max-h-80 overflow-y-auto py-1">
<li v-for="segment in segments" :key="segment.id">
<!-- External link segments -->
<a
v-if="segment.type === 'link'"
:href="segment.content"
target="_blank"
rel="noopener noreferrer"
class="flex w-full items-center gap-1 px-4 py-2.5 text-left text-sm text-slate-600 transition-colors duration-150 hover:bg-slate-50 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-100"
@click="open = false"
>
{{ segment.title }}
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
<!-- Regular segment nav buttons -->
<button
v-else
class="w-full px-4 py-2.5 text-left text-sm transition-colors duration-150"
:class="
activeId === segment.id
? 'bg-primary-50 font-medium text-slate-900 dark:bg-primary-700/20 dark:text-slate-100'
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-100'
"
@click="handleNav(segment.id)"
>
{{ segment.title }}
</button>
</li>
</ul>
</div>
</div>
</Transition>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 200ms ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.dropdown-enter-active,
.dropdown-leave-active {
transition: transform 200ms ease, opacity 200ms ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
transform: translateY(-100%);
opacity: 0;
}
</style>

View file

@ -0,0 +1,52 @@
<script setup lang="ts">
import type { Segment } from "@/types/segment";
defineProps<{
segments: Segment[];
siteTitle: string;
activeId: string | null;
}>();
const emit = defineEmits<{
navigate: [id: string];
}>();
function handleClick(id: string) {
emit("navigate", id);
}
</script>
<template>
<nav class="border-b border-slate-200 bg-white px-6 dark:border-slate-700 dark:bg-slate-800">
<ul class="flex gap-1 overflow-x-auto">
<li v-for="segment in segments" :key="segment.id">
<!-- External link segments -->
<a
v-if="segment.type === 'link'"
:href="segment.content"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 whitespace-nowrap border-b-2 border-transparent px-3 py-2.5 text-sm font-medium text-slate-500 transition-colors duration-150 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100"
>
{{ segment.title }}
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
<!-- Regular segment nav buttons -->
<button
v-else
class="whitespace-nowrap border-b-2 px-3 py-2.5 text-sm font-medium transition-colors duration-150"
:class="
activeId === segment.id
? 'border-primary-400 text-slate-900 dark:text-slate-100'
: 'border-transparent text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100'
"
@click="handleClick(segment.id)"
>
{{ segment.title }}
</button>
</li>
</ul>
</nav>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { Segment } from "@/types/segment";
defineProps<{
segment: Segment;
}>();
</script>
<template>
<audio
:src="segment.content"
controls
class="w-full"
>
Your browser does not support the audio element.
</audio>
</template>

View file

@ -0,0 +1,28 @@
<script setup lang="ts">
import { computed } from "vue";
import type { Segment } from "@/types/segment";
const props = defineProps<{
segment: Segment;
}>();
const images = computed(() => {
const meta = props.segment.metadata;
if (Array.isArray(meta.images)) {
return meta.images as string[];
}
return [];
});
</script>
<template>
<div class="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4">
<img
v-for="(src, index) in images"
:key="index"
:src="src"
:alt="`${segment.title} image ${index + 1}`"
class="aspect-square w-full rounded object-cover"
/>
</div>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { Segment } from "@/types/segment";
defineProps<{
segment: Segment;
}>();
</script>
<template>
<iframe
:src="segment.content"
:title="segment.title"
class="h-[600px] w-full rounded border-none"
sandbox="allow-scripts allow-same-origin allow-popups"
allow="fullscreen"
/>
</template>

View file

@ -0,0 +1,15 @@
<script setup lang="ts">
import { computed } from "vue";
import { marked } from "marked";
import type { Segment } from "@/types/segment";
const props = defineProps<{
segment: Segment;
}>();
const html = computed(() => marked.parse(props.segment.content) as string);
</script>
<template>
<div class="prose max-w-none dark:prose-invert" v-html="html" />
</template>

View file

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { Segment } from "@/types/segment";
defineProps<{
segment: Segment;
}>();
</script>
<template>
<iframe
:src="segment.content"
class="h-[600px] w-full rounded border-none"
:title="segment.title"
/>
</template>

View file

@ -0,0 +1,139 @@
<script setup lang="ts">
import { ref, watch, computed } from "vue";
import type { Segment } from "@/types/segment";
import { useAdmin } from "@/composables/useAdmin";
import SegmentRenderer from "@/components/segments/SegmentRenderer.vue";
import SegmentEditor from "@/components/admin/SegmentEditor.vue";
import draggable from "vuedraggable";
const props = defineProps<{
segments: Segment[];
}>();
// Filter out link segments from content rendering (they only appear in nav)
const contentSegments = computed(() => props.segments.filter((s) => s.type !== "link"));
const emit = defineEmits<{
save: [id: string, updates: { title?: string; content?: string; metadata?: Record<string, unknown> }];
delete: [id: string];
reorder: [ids: string[]];
}>();
const { isAdmin } = useAdmin();
const editingId = ref<string | null>(null);
// Local copy for draggable
const localSegments = ref<Segment[]>([...props.segments]);
watch(
() => props.segments,
(val) => {
localSegments.value = [...val];
},
{ deep: true }
);
function toggleEdit(id: string) {
editingId.value = editingId.value === id ? null : id;
}
function handleSave(
id: string,
updates: { title?: string; content?: string; metadata?: Record<string, unknown> }
) {
editingId.value = null;
emit("save", id, updates);
}
function handleDelete(id: string) {
editingId.value = null;
emit("delete", id);
}
function onDragEnd() {
const ids = localSegments.value.map((s) => s.id);
emit("reorder", ids);
}
</script>
<template>
<div class="space-y-6 p-6">
<!-- Admin mode: draggable with edit controls -->
<draggable
v-if="isAdmin"
v-model="localSegments"
item-key="id"
handle=".drag-handle"
class="space-y-6"
@end="onDragEnd"
>
<template #item="{ element: segment }: { element: Segment }">
<section
:id="segment.id"
class="rounded-lg bg-white p-6 shadow-md dark:bg-slate-800 dark:shadow-slate-950/30"
>
<div class="flex items-start gap-3">
<!-- Drag handle: grip dots -->
<button
class="drag-handle mt-1 cursor-grab text-slate-300 transition-colors hover:text-primary-300 active:cursor-grabbing dark:text-slate-600 dark:hover:text-primary-400"
title="Drag to reorder"
>
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<circle cx="9" cy="6" r="1.5" />
<circle cx="15" cy="6" r="1.5" />
<circle cx="9" cy="12" r="1.5" />
<circle cx="15" cy="12" r="1.5" />
<circle cx="9" cy="18" r="1.5" />
<circle cx="15" cy="18" r="1.5" />
</svg>
</button>
<div class="min-w-0 flex-1">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-xl font-semibold text-slate-900 dark:text-slate-100">{{ segment.title }}</h2>
<!-- Edit pencil icon -->
<button
class="rounded p-1 text-slate-300 transition-colors hover:text-primary-300 dark:text-slate-600 dark:hover:text-primary-400"
title="Edit segment"
@click="toggleEdit(segment.id)"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</button>
</div>
<!-- Link segments show URL instead of content -->
<p v-if="segment.type === 'link'" class="text-sm text-slate-400">
External link: <a :href="segment.content" target="_blank" rel="noopener noreferrer" class="text-primary-500 underline">{{ segment.content }}</a>
</p>
<SegmentRenderer v-else :segment="segment" />
<!-- Inline editor -->
<SegmentEditor
v-if="editingId === segment.id"
:segment="segment"
@save="(updates) => handleSave(segment.id, updates)"
@cancel="editingId = null"
@delete="handleDelete(segment.id)"
/>
</div>
</div>
</section>
</template>
</draggable>
<!-- Read-only mode: plain rendering (link segments excluded) -->
<template v-else>
<section
v-for="segment in contentSegments"
:key="segment.id"
:id="segment.id"
class="rounded-lg bg-white p-6 shadow-md dark:bg-slate-800 dark:shadow-slate-950/30"
>
<h2 class="mb-4 text-xl font-semibold text-slate-900 dark:text-slate-100">{{ segment.title }}</h2>
<SegmentRenderer :segment="segment" />
</section>
</template>
</div>
</template>

View file

@ -0,0 +1,22 @@
<script setup lang="ts">
import type { Segment } from "@/types/segment";
import MarkdownSegment from "@/components/segments/MarkdownSegment.vue";
import PdfSegment from "@/components/segments/PdfSegment.vue";
import VideoSegment from "@/components/segments/VideoSegment.vue";
import AudioSegment from "@/components/segments/AudioSegment.vue";
import IframeSegment from "@/components/segments/IframeSegment.vue";
import GallerySegment from "@/components/segments/GallerySegment.vue";
defineProps<{
segment: Segment;
}>();
</script>
<template>
<MarkdownSegment v-if="segment.type === 'markdown'" :segment="segment" />
<PdfSegment v-else-if="segment.type === 'pdf'" :segment="segment" />
<VideoSegment v-else-if="segment.type === 'video'" :segment="segment" />
<AudioSegment v-else-if="segment.type === 'audio'" :segment="segment" />
<IframeSegment v-else-if="segment.type === 'iframe'" :segment="segment" />
<GallerySegment v-else-if="segment.type === 'gallery'" :segment="segment" />
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { Segment } from "@/types/segment";
defineProps<{
segment: Segment;
}>();
</script>
<template>
<video
:src="segment.content"
controls
class="w-full rounded"
>
Your browser does not support the video element.
</video>
</template>

View file

@ -0,0 +1,62 @@
import { ref, computed } from "vue";
import type { Ref, ComputedRef } from "vue";
const token: Ref<string | null> = ref(null);
const isAdmin: Ref<boolean> = ref(false);
const verifying: Ref<boolean> = ref(false);
async function verifyToken(t: string): Promise<boolean> {
try {
const res = await fetch("/api/auth/verify", {
headers: { Authorization: `Bearer ${t}` },
});
return res.ok;
} catch {
return false;
}
}
// Check sessionStorage on module load
const stored = sessionStorage.getItem("admin_token");
if (stored) {
verifying.value = true;
verifyToken(stored).then((valid) => {
if (valid) {
token.value = stored;
isAdmin.value = true;
} else {
sessionStorage.removeItem("admin_token");
}
verifying.value = false;
});
}
export function useAdmin() {
async function login(t: string): Promise<boolean> {
verifying.value = true;
const valid = await verifyToken(t);
if (valid) {
token.value = t;
isAdmin.value = true;
sessionStorage.setItem("admin_token", t);
}
verifying.value = false;
return valid;
}
function logout(): void {
token.value = null;
isAdmin.value = false;
sessionStorage.removeItem("admin_token");
}
const authHeaders = computed((): Record<string, string> =>
token.value ? { Authorization: `Bearer ${token.value}` } : {}
);
const authQuery: ComputedRef<string> = computed(() =>
token.value ? `?token=${encodeURIComponent(token.value)}` : ""
);
return { token, isAdmin, verifying, login, logout, authHeaders, authQuery };
}

View file

@ -0,0 +1,42 @@
import { ref } from "vue";
export function useAssetUpload() {
const uploading = ref(false);
const error = ref<string | null>(null);
async function upload(
file: File,
authHeaders: Record<string, string>
): Promise<string | null> {
uploading.value = true;
error.value = null;
try {
const formData = new FormData();
formData.append("file", file);
const res = await fetch("/api/assets/", {
method: "POST",
headers: authHeaders,
body: formData,
});
if (!res.ok) {
const body = await res.json().catch(() => ({ detail: "Upload failed" }));
const detail = (body as { detail?: string }).detail ?? "Upload failed";
error.value = detail;
return null;
}
const data = (await res.json()) as { filename: string };
return data.filename;
} catch (e) {
error.value = e instanceof Error ? e.message : "Upload failed";
return null;
} finally {
uploading.value = false;
}
}
return { uploading, error, upload };
}

View file

@ -0,0 +1,30 @@
import { ref, watch } from "vue";
const isDark = ref(false);
// Initialize from localStorage or system preference
const stored = localStorage.getItem("dark_mode");
if (stored !== null) {
isDark.value = stored === "true";
} else {
isDark.value = window.matchMedia("(prefers-color-scheme: dark)").matches;
}
// Apply class to <html>
function applyClass() {
document.documentElement.classList.toggle("dark", isDark.value);
}
applyClass();
watch(isDark, () => {
applyClass();
localStorage.setItem("dark_mode", String(isDark.value));
});
export function useDarkMode() {
function toggle() {
isDark.value = !isDark.value;
}
return { isDark, toggle };
}

View file

@ -0,0 +1,38 @@
import { ref, onMounted } from "vue";
import type { Segment, SiteInfo } from "@/types/segment";
export function useSegments() {
const segments = ref<Segment[]>([]);
const siteTitle = ref("Handin");
const loading = ref(true);
const error = ref<string | null>(null);
async function refresh(): Promise<void> {
loading.value = true;
error.value = null;
try {
const [siteRes, segmentsRes] = await Promise.all([
fetch("/api/site/"),
fetch("/api/segments/"),
]);
if (!siteRes.ok) throw new Error(`Site fetch failed: ${siteRes.status}`);
if (!segmentsRes.ok) throw new Error(`Segments fetch failed: ${segmentsRes.status}`);
const siteData = (await siteRes.json()) as SiteInfo;
const segmentsData = (await segmentsRes.json()) as Segment[];
siteTitle.value = siteData.title;
segments.value = segmentsData.sort((a, b) => a.sort_order - b.sort_order);
} catch (e) {
error.value = e instanceof Error ? e.message : "Unknown error";
} finally {
loading.value = false;
}
}
onMounted(() => {
void refresh();
});
return { segments, siteTitle, loading, error, refresh };
}

5
frontend/src/main.ts Normal file
View file

@ -0,0 +1,5 @@
import { createApp } from "vue";
import App from "./App.vue";
import "./style.css";
createApp(App).mount("#app");

15
frontend/src/style.css Normal file
View file

@ -0,0 +1,15 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--color-primary-50: #fffbeb;
--color-primary-100: #fff3c4;
--color-primary-200: #fce588;
--color-primary-300: #ffcd00;
--color-primary-400: #e6b800;
--color-primary-500: #cc9900;
--color-primary-600: #997300;
--color-primary-700: #664d00;
}

View file

@ -0,0 +1,16 @@
export type SegmentType = "markdown" | "pdf" | "video" | "audio" | "iframe" | "gallery" | "link";
export interface Segment {
id: string;
type: SegmentType;
sort_order: number;
title: string;
content: string;
metadata: Record<string, unknown>;
created_at: string;
updated_at: string;
}
export interface SiteInfo {
title: string;
}

5
frontend/src/types/vuedraggable.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
declare module "vuedraggable" {
import type { DefineComponent } from "vue";
const component: DefineComponent;
export default component;
}

View file

@ -0,0 +1,165 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import { useSegments } from "@/composables/useSegments";
import { useAdmin } from "@/composables/useAdmin";
import type { SegmentType } from "@/types/segment";
import AppShell from "@/components/layout/AppShell.vue";
import SegmentList from "@/components/segments/SegmentList.vue";
import AddSegmentModal from "@/components/admin/AddSegmentModal.vue";
const { segments, siteTitle, loading, error, refresh } = useSegments();
const { authHeaders, logout } = useAdmin();
const activeId = ref<string | null>(null);
const showAddModal = ref(false);
let observer: IntersectionObserver | null = null;
function handleNavigate(id: string) {
const el = document.getElementById(id);
if (el) {
el.scrollIntoView({ behavior: "smooth" });
activeId.value = id;
}
}
async function handleCreateSegment(data: {
type: SegmentType;
title: string;
content: string;
metadata?: Record<string, unknown>;
}) {
const res = await fetch("/api/segments/", {
method: "POST",
headers: { ...authHeaders.value, "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (res.ok) {
showAddModal.value = false;
await refresh();
}
}
async function handleSaveSegment(
id: string,
updates: { title?: string; content?: string; metadata?: Record<string, unknown> }
) {
if (Object.keys(updates).length === 0) return;
const res = await fetch(`/api/segments/${id}`, {
method: "PATCH",
headers: { ...authHeaders.value, "Content-Type": "application/json" },
body: JSON.stringify(updates),
});
if (res.ok) {
await refresh();
}
}
async function handleDeleteSegment(id: string) {
const res = await fetch(`/api/segments/${id}`, {
method: "DELETE",
headers: authHeaders.value,
});
if (res.ok) {
await refresh();
}
}
async function handleReorder(ids: string[]) {
const res = await fetch("/api/segments/reorder", {
method: "PUT",
headers: { ...authHeaders.value, "Content-Type": "application/json" },
body: JSON.stringify({ segment_ids: ids }),
});
if (res.ok) {
await refresh();
}
}
function handleLogout() {
logout();
}
onMounted(() => {
observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
activeId.value = entry.target.id;
}
}
},
{ rootMargin: "-20% 0px -60% 0px" }
);
const mutObs = new MutationObserver(() => {
for (const seg of segments.value) {
const el = document.getElementById(seg.id);
if (el) observer?.observe(el);
}
});
mutObs.observe(document.body, { childList: true, subtree: true });
onUnmounted(() => {
observer?.disconnect();
mutObs.disconnect();
});
});
</script>
<template>
<AppShell
:segments="segments"
:site-title="siteTitle"
:active-id="activeId"
@navigate="handleNavigate"
@add-segment="showAddModal = true"
@logout="handleLogout"
>
<!-- Loading state -->
<div v-if="loading" class="flex items-center justify-center py-32">
<svg
class="h-10 w-10 animate-spin text-primary-300"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</div>
<!-- Error state -->
<div v-else-if="error" class="flex items-center justify-center py-32">
<p class="text-lg text-red-500">{{ error }}</p>
</div>
<!-- Empty state -->
<div v-else-if="segments.length === 0" class="flex flex-col items-center justify-center py-32 text-slate-400 dark:text-slate-600">
<svg class="mb-4 h-16 w-16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<p class="text-lg">No content yet</p>
</div>
<!-- Segment list -->
<SegmentList
v-else
:segments="segments"
@save="handleSaveSegment"
@delete="handleDeleteSegment"
@reorder="handleReorder"
/>
</AppShell>
<!-- Add segment modal -->
<AddSegmentModal
v-if="showAddModal"
@create="handleCreateSegment"
@cancel="showAddModal = false"
/>
</template>

19
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"noEmit": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.vue", "env.d.ts"]
}

19
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,19 @@
import tailwindcss from "@tailwindcss/vite";
import vue from "@vitejs/plugin-vue";
import { resolve } from "path";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [vue(), tailwindcss()],
resolve: {
alias: {
"@": resolve(__dirname, "src"),
},
},
server: {
port: 5174,
proxy: {
"/api": process.env.API_URL ?? "http://localhost:9000",
},
},
});

View file

@ -0,0 +1,241 @@
# Slice 1: Database + Models
## Role
You are a backend Python developer implementing the SQLite persistence layer and Pydantic models for a FastAPI-based academic submission website. You follow TDD strictly: write all tests first, then implement until they pass.
## Objective
Create two production files (`database.py`, `models.py`) and one test file (`test_database.py`). All tests must pass. Both production files must pass `mypy --strict`.
## Coding Standards (mandatory)
- Python 3.12+ features (type parameter syntax, `StrEnum`, `list[X]` not `List[X]`)
- All function signatures must have type hints (params and return)
- Use `UUID` from `uuid` for ID fields, `datetime` from `datetime` for timestamps
- Max 50 lines per function; extract helpers if longer
- Guard clauses before logic (fail early, no nested conditionals)
- No `else` after `return`/`raise`
- Max 3 levels of indentation
- Imports grouped: stdlib → third-party → local (absolute imports only: `from app.module import ...`)
- No `Any` types. No `# type: ignore` without explanation
- Pydantic models use `PascalCase` + suffix convention (e.g., `SegmentCreateRequest`, `SegmentResponse`)
- Comments explain WHY, not WHAT
## Context: Existing Files
### `backend/app/config.py`
```python
from functools import lru_cache
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
admin_token: str
data_dir: str = "./data"
max_upload_bytes: int = 100_000_000
allowed_upload_types: str = "pdf,png,jpg,jpeg,gif,mp4,webm,mp3,wav,ogg,webp"
model_config = {"env_prefix": "HANDIN_"}
@lru_cache
def get_settings() -> Settings:
return Settings()
```
### `backend/tests/conftest.py`
```python
import os
import tempfile
from collections.abc import Generator
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
TEST_ADMIN_TOKEN = "test-secret-token"
@pytest.fixture
def tmp_data_dir() -> Generator[Path, None, None]:
with tempfile.TemporaryDirectory() as tmpdir:
data_dir = Path(tmpdir)
(data_dir / "assets").mkdir()
yield data_dir
@pytest.fixture
def _env_settings(tmp_data_dir: Path) -> Generator[None, None, None]:
original_env = os.environ.copy()
os.environ["HANDIN_ADMIN_TOKEN"] = TEST_ADMIN_TOKEN
os.environ["HANDIN_DATA_DIR"] = str(tmp_data_dir)
from app.config import get_settings
get_settings.cache_clear()
yield
os.environ.clear()
os.environ.update(original_env)
get_settings.cache_clear()
@pytest.fixture
def settings(_env_settings: None) -> "Settings": # noqa: F821
from app.config import Settings, get_settings
s = get_settings()
assert isinstance(s, Settings)
return s
@pytest.fixture
def db(tmp_data_dir: Path) -> Path:
"""Return path to an initialized SQLite database in the temp dir."""
from app.database import init_db
db_path = tmp_data_dir / "handin.db"
init_db(db_path)
return db_path
@pytest.fixture
def client(_env_settings: None) -> TestClient:
from app.main import app
return TestClient(app)
```
### `backend/app/main.py`
```python
from fastapi import FastAPI
app = FastAPI(title="Handin Website")
@app.get("/api/health")
def health_check() -> dict[str, str]:
return {"status": "ok"}
```
### Existing empty `__init__.py` files
- `backend/app/__init__.py`
- `backend/app/routes/__init__.py`
- `backend/app/services/__init__.py`
- `backend/tests/__init__.py`
## Step 1: Write Tests (`backend/tests/test_database.py`)
Write these tests FIRST, before any production code. Use the `db` and `tmp_data_dir` fixtures from conftest.
**Required test cases:**
1. **`test_init_db_creates_tables`** — Call `init_db` on a fresh path. Connect and verify both `site` and `segments` tables exist (query `sqlite_master`).
2. **`test_init_db_idempotent`** — Call `init_db` twice on the same path. No error raised. Tables still exist with correct schema.
3. **`test_wal_mode_enabled`** — After `init_db`, connect and run `PRAGMA journal_mode`. Assert the result is `"wal"`.
4. **`test_get_connection_busy_timeout`** — Call `get_connection`. Run `PRAGMA busy_timeout` on the returned connection. Assert the value is `5000`.
5. **`test_segments_table_schema`** — After init, verify the `segments` table has all expected columns: `id`, `type`, `sort_order`, `title`, `content`, `metadata`, `created_at`, `updated_at`.
6. **`test_site_table_schema`** — After init, verify the `site` table has columns: `key`, `value`.
**Test conventions:**
- Each test function takes `tmp_data_dir: Path` as parameter (from fixture)
- Tests call `init_db` and `get_connection` directly (imported from `app.database`)
- Use the `db` fixture where a pre-initialized database is needed
- No mocking — use real SQLite on temp directories
## Step 2: Implement `backend/app/database.py`
**Required functions:**
### `init_db(db_path: Path) -> None`
- Takes a `Path` to the SQLite database file
- Creates parent directories if they don't exist (`db_path.parent.mkdir(parents=True, exist_ok=True)`)
- Connects to SQLite at `db_path`
- Sets `PRAGMA journal_mode=WAL`
- Sets `PRAGMA busy_timeout=5000`
- Creates tables with `CREATE TABLE IF NOT EXISTS`:
```sql
CREATE TABLE IF NOT EXISTS site (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS segments (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
sort_order INTEGER NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
metadata TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_segments_order ON segments(sort_order);
```
- Closes the connection when done
### `get_connection(db_path: Path) -> sqlite3.Connection`
- Returns a `sqlite3.Connection` with `row_factory = sqlite3.Row`
- Sets `PRAGMA busy_timeout=5000` on each new connection
- Does NOT set WAL mode (that's a one-time setup in `init_db`)
## Step 3: Implement `backend/app/models.py`
**Required models:**
### Enums
```python
class SegmentType(StrEnum):
MARKDOWN = "markdown"
PDF = "pdf"
VIDEO = "video"
AUDIO = "audio"
IFRAME = "iframe"
GALLERY = "gallery"
```
### Domain Models
- **`Segment`** — `id: UUID`, `type: SegmentType`, `sort_order: int`, `title: str`, `content: str = ""`, `metadata: dict[str, object] = Field(default_factory=dict)`, `created_at: datetime`, `updated_at: datetime`
### Request Models
- **`SegmentCreateRequest`** — `type: SegmentType`, `title: str`, `content: str = ""`, `metadata: dict[str, object] = Field(default_factory=dict)`
- **`SegmentUpdateRequest`** — `title: str | None = None`, `content: str | None = None`, `metadata: dict[str, object] | None = None`
- **`ReorderRequest`** — `segment_ids: list[UUID]`
- **`SiteUpdateRequest`** — `title: str`
### Response Models
- **`SegmentResponse`** — `id: UUID`, `type: SegmentType`, `sort_order: int`, `title: str`, `content: str`, `metadata: dict[str, object]`, `created_at: datetime`, `updated_at: datetime`
- **`SiteResponse`** — `title: str`
Use `Field(default_factory=...)` where needed. Use `from __future__ import annotations` if it helps with forward references, but prefer Python 3.12+ syntax.
## Verification
After implementation, run these commands and ensure they all succeed:
```bash
uv run pytest backend/tests/test_database.py -v
uv run mypy --strict backend/app/models.py backend/app/database.py
uv run ruff check backend/app/models.py backend/app/database.py
```
All tests must pass. Zero mypy errors. Zero ruff errors.
## Files to Create/Modify
| Action | File |
|--------|------|
| CREATE | `backend/tests/test_database.py` |
| CREATE | `backend/app/database.py` |
| CREATE | `backend/app/models.py` |
Do NOT modify any other files. Do NOT modify `conftest.py`, `config.py`, or `main.py`.

View file

@ -0,0 +1,393 @@
# Slice 2: Segment & Site Service Layer
## Role
You are a backend Python developer implementing the service layer (business logic + persistence) for a FastAPI-based academic submission website. You follow TDD strictly: write all tests first, then implement until they pass.
## Objective
Create two production files (`services/segment_service.py`, `services/site_service.py`) and one test file (`tests/test_services.py`). All tests must pass. Both production files must pass `mypy --strict`.
## Coding Standards (mandatory)
- Python 3.12+ features (type parameter syntax, `StrEnum`, `list[X]` not `List[X]`)
- All function signatures must have type hints (params and return)
- Use `UUID` from `uuid` for ID fields, `datetime` from `datetime` for timestamps
- Max 50 lines per function; extract helpers if longer
- Guard clauses before logic (fail early, no nested conditionals)
- No `else` after `return`/`raise`
- Max 3 levels of indentation
- Imports grouped: stdlib → third-party → local (absolute imports only: `from app.module import ...`)
- No `Any` types. No `# type: ignore` without explanation
- Comments explain WHY, not WHAT
## Step 0: Verify Previous Slice
Before writing any new code, verify that the slice-1 implementation is healthy. Run:
```bash
uv run pytest backend/tests/ -v
uv run mypy --strict backend/app/database.py backend/app/models.py backend/app/config.py
uv run ruff check backend/
```
**All commands must exit cleanly with zero errors.** If any fail, fix the issues before proceeding. Do NOT continue to Step 1 with a broken baseline.
## Context: Existing Files
### `backend/app/config.py`
```python
from functools import lru_cache
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
admin_token: str
data_dir: str = "./data"
max_upload_bytes: int = 100_000_000
allowed_upload_types: str = "pdf,png,jpg,jpeg,gif,mp4,webm,mp3,wav,ogg,webp"
model_config = {"env_prefix": "HANDIN_"}
@lru_cache
def get_settings() -> Settings:
return Settings()
```
### `backend/app/database.py`
```python
import sqlite3
from pathlib import Path
def init_db(db_path: Path) -> None:
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db_path)
try:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
conn.execute("""
CREATE TABLE IF NOT EXISTS site (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS segments (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
sort_order INTEGER NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
metadata TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
""")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_segments_order ON segments(sort_order)"
)
conn.commit()
finally:
conn.close()
def get_connection(db_path: Path) -> sqlite3.Connection:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA busy_timeout=5000")
return conn
```
### `backend/app/models.py`
```python
from datetime import datetime
from enum import StrEnum
from uuid import UUID
from pydantic import BaseModel, Field
class SegmentType(StrEnum):
MARKDOWN = "markdown"
PDF = "pdf"
VIDEO = "video"
AUDIO = "audio"
IFRAME = "iframe"
GALLERY = "gallery"
class Segment(BaseModel):
id: UUID
type: SegmentType
sort_order: int
title: str
content: str = ""
metadata: dict[str, object] = Field(default_factory=dict)
created_at: datetime
updated_at: datetime
class SegmentCreateRequest(BaseModel):
type: SegmentType
title: str
content: str = ""
metadata: dict[str, object] = Field(default_factory=dict)
class SegmentUpdateRequest(BaseModel):
title: str | None = None
content: str | None = None
metadata: dict[str, object] | None = None
class ReorderRequest(BaseModel):
segment_ids: list[UUID]
class SiteUpdateRequest(BaseModel):
title: str
class SegmentResponse(BaseModel):
id: UUID
type: SegmentType
sort_order: int
title: str
content: str
metadata: dict[str, object]
created_at: datetime
updated_at: datetime
class SiteResponse(BaseModel):
title: str
```
### `backend/tests/conftest.py`
```python
import os
import tempfile
from collections.abc import Generator
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from app.config import Settings
TEST_ADMIN_TOKEN = "test-secret-token"
@pytest.fixture
def tmp_data_dir() -> Generator[Path, None, None]:
with tempfile.TemporaryDirectory() as tmpdir:
data_dir = Path(tmpdir)
(data_dir / "assets").mkdir()
yield data_dir
@pytest.fixture
def _env_settings(tmp_data_dir: Path) -> Generator[None, None, None]:
original_env = os.environ.copy()
os.environ["HANDIN_ADMIN_TOKEN"] = TEST_ADMIN_TOKEN
os.environ["HANDIN_DATA_DIR"] = str(tmp_data_dir)
from app.config import get_settings
get_settings.cache_clear()
yield
os.environ.clear()
os.environ.update(original_env)
get_settings.cache_clear()
@pytest.fixture
def settings(_env_settings: None) -> Settings:
from app.config import get_settings
s = get_settings()
assert isinstance(s, Settings)
return s
@pytest.fixture
def db(tmp_data_dir: Path) -> Path:
"""Return path to an initialized SQLite database in the temp dir."""
from app.database import init_db
db_path = tmp_data_dir / "handin.db"
init_db(db_path)
return db_path
@pytest.fixture
def client(_env_settings: None) -> TestClient:
from app.main import app
return TestClient(app)
```
### `backend/app/main.py`
```python
from fastapi import FastAPI
app = FastAPI(title="Handin Website")
@app.get("/api/health")
def health_check() -> dict[str, str]:
return {"status": "ok"}
```
### `pyproject.toml` (relevant sections)
```toml
[tool.mypy]
strict = true
python_version = "3.12"
mypy_path = "backend"
packages = ["app"]
plugins = ["pydantic.mypy"]
```
### Existing empty `__init__.py` files
- `backend/app/__init__.py`
- `backend/app/routes/__init__.py`
- `backend/app/services/__init__.py`
- `backend/tests/__init__.py`
## Step 1: Write Tests (`backend/tests/test_services.py`)
Write these tests FIRST, before any production code. Use the `db` fixture from conftest.
**Required test cases for segment service:**
1. **`test_create_segment`** — Create a segment via `create_segment(db_path, request)`. Assert it returns a `Segment` with a valid UUID, `sort_order == 0`, matching title/type/content, and `created_at == updated_at`.
2. **`test_create_segment_auto_increments_sort_order`** — Create three segments. Assert their `sort_order` values are `0`, `1`, `2` respectively.
3. **`test_list_segments_empty`** — On a fresh DB, `list_segments` returns an empty list.
4. **`test_list_segments_ordered`** — Create three segments, then list. Assert they come back in `sort_order` ascending order.
5. **`test_get_segment_found`** — Create a segment, then retrieve it by ID with `get_segment`. Assert it matches.
6. **`test_get_segment_not_found`** — Call `get_segment` with a random UUID. Assert it returns `None`.
7. **`test_update_segment_title`** — Create a segment, then update only its title via `update_segment(db_path, id, request)`. Assert title changed, content unchanged, and `updated_at > created_at`.
8. **`test_update_segment_not_found`** — Call `update_segment` with a random UUID. Assert it returns `None`.
9. **`test_delete_segment`** — Create a segment, delete it with `delete_segment(db_path, id)`. Assert returns `True`. Assert `get_segment` returns `None`.
10. **`test_delete_segment_not_found`** — Call `delete_segment` with a random UUID. Assert returns `False`.
11. **`test_reorder_segments`** — Create three segments (A, B, C). Call `reorder_segments(db_path, [C.id, A.id, B.id])`. List segments and assert the new sort order is C=0, A=1, B=2.
12. **`test_reorder_segments_invalid_ids`** — Call `reorder_segments` with a list containing a non-existent UUID. Assert it raises `ValueError`.
**Required test cases for site service:**
13. **`test_get_site_title_default`** — On a fresh DB, `get_site_title(db_path)` returns `"Untitled Site"`.
14. **`test_update_and_get_site_title`** — Call `update_site_title(db_path, "My Course")`. Then call `get_site_title`. Assert it returns `"My Course"`.
15. **`test_update_site_title_overwrites`** — Update title twice with different values. Assert `get_site_title` returns the second value.
**Test conventions:**
- Each test function takes `db: Path` as parameter (from fixture)
- Import functions from `app.services.segment_service` and `app.services.site_service`
- No mocking — use real SQLite on temp directories
- Use `time.sleep(0.01)` before update operations to ensure `updated_at` differs from `created_at`
## Step 2: Implement `backend/app/services/segment_service.py`
**Required functions:**
### `create_segment(db_path: Path, request: SegmentCreateRequest) -> Segment`
- Generate a new `uuid4()` for the segment ID
- Compute `sort_order` as the current max `sort_order + 1` (or `0` if no segments exist)
- Set `created_at` and `updated_at` to `datetime.now(UTC)` formatted as ISO 8601 strings
- Insert into the `segments` table (serialize `metadata` as JSON)
- Return a `Segment` model instance
### `list_segments(db_path: Path) -> list[Segment]`
- Query all segments ordered by `sort_order ASC`
- Deserialize `metadata` from JSON string to dict
- Return a list of `Segment` model instances
### `get_segment(db_path: Path, segment_id: UUID) -> Segment | None`
- Query by ID
- Return `Segment` if found, `None` otherwise
- Deserialize `metadata` from JSON string to dict
### `update_segment(db_path: Path, segment_id: UUID, request: SegmentUpdateRequest) -> Segment | None`
- Fetch existing segment first; return `None` if not found
- Only update fields that are not `None` in the request
- Always update `updated_at` to `datetime.now(UTC)`
- If `metadata` is provided, serialize it as JSON
- Return the updated `Segment`
### `delete_segment(db_path: Path, segment_id: UUID) -> bool`
- Delete the row by ID
- Return `True` if a row was deleted, `False` otherwise
### `reorder_segments(db_path: Path, segment_ids: list[UUID]) -> list[Segment]`
- Validate that all provided IDs exist in the database; raise `ValueError` if any are missing
- Update `sort_order` for each segment to match its index in the provided list
- Update `updated_at` for all reordered segments
- Use a transaction (all updates succeed or none do)
- Return the newly ordered list of segments
**Helper function (private):**
### `_row_to_segment(row: sqlite3.Row) -> Segment`
- Convert a `sqlite3.Row` to a `Segment` model instance
- Parse `metadata` from JSON string
- Parse `created_at` and `updated_at` from ISO 8601 strings
- Parse `id` from string to `UUID`
- Parse `type` from string to `SegmentType`
## Step 3: Implement `backend/app/services/site_service.py`
**Required functions:**
### `get_site_title(db_path: Path) -> str`
- Query the `site` table for `key = "title"`
- Return the value if found, `"Untitled Site"` if not
### `update_site_title(db_path: Path, title: str) -> str`
- Upsert (INSERT OR REPLACE) the `title` key in the `site` table
- Return the new title
## Verification
After implementation, run these commands and ensure they all succeed:
```bash
uv run pytest backend/tests/test_services.py -v
uv run mypy --strict backend/app/services/segment_service.py backend/app/services/site_service.py
uv run ruff check backend/app/services/segment_service.py backend/app/services/site_service.py
```
Then run the **full** verification suite to ensure nothing is broken and all files pass strict checks:
```bash
uv run pytest backend/tests/ -v
uv run mypy --strict backend/app/ backend/tests/
uv run ruff check backend/
```
All tests must pass. Zero mypy errors across the entire backend. Zero ruff errors.
## Files to Create/Modify
| Action | File |
|--------|------|
| CREATE | `backend/tests/test_services.py` |
| CREATE | `backend/app/services/segment_service.py` |
| CREATE | `backend/app/services/site_service.py` |
Do NOT modify any other files. Do NOT modify `conftest.py`, `config.py`, `main.py`, `database.py`, or `models.py`.

View file

@ -0,0 +1,670 @@
# Slice 3: Auth + API Routes + Concurrency
## Role
You are a backend Python developer implementing the auth dependency, all API route handlers, and concurrency tests for a FastAPI-based academic submission website. You follow TDD strictly: write all tests first, then implement until they pass.
## Objective
Create five production files (`auth.py`, `routes/auth.py`, `routes/segments.py`, `routes/site.py`) and modify one (`main.py`). Create two test files (`tests/test_routes.py`, `tests/test_concurrent.py`). All tests must pass. All production files must pass `mypy --strict`.
## Coding Standards (mandatory)
- Python 3.12+ features (type parameter syntax, `StrEnum`, `list[X]` not `List[X]`)
- All function signatures must have type hints (params and return)
- Use `UUID` from `uuid` for ID fields, `datetime` from `datetime` for timestamps
- Max 50 lines per function; extract helpers if longer
- Guard clauses before logic (fail early, no nested conditionals)
- No `else` after `return`/`raise`
- Max 3 levels of indentation
- Imports grouped: stdlib → third-party → local (absolute imports only: `from app.module import ...`)
- No `Any` types. No `# type: ignore` without explanation
- Comments explain WHY, not WHAT
## Step 0: Verify Previous Slices
Before writing any new code, verify that slices 1 and 2 are healthy. Run:
```bash
uv run pytest backend/tests/ -v
uv run mypy --strict backend/app/
uv run ruff check backend/
```
**All commands must exit cleanly with zero errors.** If any fail, fix the issues before proceeding. Do NOT continue to Step 1 with a broken baseline.
## Context: Existing Files
### `backend/app/config.py`
```python
from functools import lru_cache
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
admin_token: str
data_dir: str = "./data"
max_upload_bytes: int = 100_000_000
allowed_upload_types: str = "pdf,png,jpg,jpeg,gif,mp4,webm,mp3,wav,ogg,webp"
model_config = {"env_prefix": "HANDIN_"}
@lru_cache
def get_settings() -> Settings:
return Settings()
```
### `backend/app/database.py`
```python
import sqlite3
from pathlib import Path
def init_db(db_path: Path) -> None:
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db_path)
try:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
conn.execute("""
CREATE TABLE IF NOT EXISTS site (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS segments (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
sort_order INTEGER NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
metadata TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
""")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_segments_order ON segments(sort_order)"
)
conn.commit()
finally:
conn.close()
def get_connection(db_path: Path) -> sqlite3.Connection:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA busy_timeout=5000")
return conn
```
### `backend/app/models.py`
```python
from datetime import datetime
from enum import StrEnum
from uuid import UUID
from pydantic import BaseModel, Field
class SegmentType(StrEnum):
MARKDOWN = "markdown"
PDF = "pdf"
VIDEO = "video"
AUDIO = "audio"
IFRAME = "iframe"
GALLERY = "gallery"
class Segment(BaseModel):
id: UUID
type: SegmentType
sort_order: int
title: str
content: str = ""
metadata: dict[str, object] = Field(default_factory=dict)
created_at: datetime
updated_at: datetime
class SegmentCreateRequest(BaseModel):
type: SegmentType
title: str
content: str = ""
metadata: dict[str, object] = Field(default_factory=dict)
class SegmentUpdateRequest(BaseModel):
title: str | None = None
content: str | None = None
metadata: dict[str, object] | None = None
class ReorderRequest(BaseModel):
segment_ids: list[UUID]
class SiteUpdateRequest(BaseModel):
title: str
class SegmentResponse(BaseModel):
id: UUID
type: SegmentType
sort_order: int
title: str
content: str
metadata: dict[str, object]
created_at: datetime
updated_at: datetime
class SiteResponse(BaseModel):
title: str
```
### `backend/app/services/segment_service.py`
```python
import json
import sqlite3
from datetime import UTC, datetime
from pathlib import Path
from uuid import UUID, uuid4
from app.database import get_connection
from app.models import Segment, SegmentCreateRequest, SegmentType, SegmentUpdateRequest
def _row_to_segment(row: sqlite3.Row) -> Segment:
return Segment(
id=UUID(row["id"]),
type=SegmentType(row["type"]),
sort_order=row["sort_order"],
title=row["title"],
content=row["content"],
metadata=json.loads(row["metadata"]),
created_at=datetime.fromisoformat(row["created_at"]),
updated_at=datetime.fromisoformat(row["updated_at"]),
)
def create_segment(db_path: Path, request: SegmentCreateRequest) -> Segment:
conn = get_connection(db_path)
try:
row = conn.execute(
"SELECT COALESCE(MAX(sort_order) + 1, 0) AS next_order FROM segments"
).fetchone()
sort_order: int = row["next_order"] if row else 0
segment_id = uuid4()
now = datetime.now(UTC).isoformat()
metadata_json = json.dumps(request.metadata)
cols = "id, type, sort_order, title, content, metadata, created_at, updated_at"
conn.execute(
f"INSERT INTO segments ({cols}) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(str(segment_id), request.type.value, sort_order, request.title,
request.content, metadata_json, now, now),
)
conn.commit()
return Segment(
id=segment_id,
type=request.type,
sort_order=sort_order,
title=request.title,
content=request.content,
metadata=request.metadata,
created_at=datetime.fromisoformat(now),
updated_at=datetime.fromisoformat(now),
)
finally:
conn.close()
def list_segments(db_path: Path) -> list[Segment]:
conn = get_connection(db_path)
try:
rows = conn.execute(
"SELECT * FROM segments ORDER BY sort_order ASC"
).fetchall()
return [_row_to_segment(row) for row in rows]
finally:
conn.close()
def get_segment(db_path: Path, segment_id: UUID) -> Segment | None:
conn = get_connection(db_path)
try:
row = conn.execute(
"SELECT * FROM segments WHERE id = ?", (str(segment_id),)
).fetchone()
if row is None:
return None
return _row_to_segment(row)
finally:
conn.close()
def update_segment(
db_path: Path, segment_id: UUID, request: SegmentUpdateRequest
) -> Segment | None:
conn = get_connection(db_path)
try:
existing = conn.execute(
"SELECT * FROM segments WHERE id = ?", (str(segment_id),)
).fetchone()
if existing is None:
return None
now = datetime.now(UTC).isoformat()
title = request.title if request.title is not None else existing["title"]
content = request.content if request.content is not None else existing["content"]
metadata_json = (
json.dumps(request.metadata) if request.metadata is not None
else existing["metadata"]
)
conn.execute(
"""UPDATE segments SET title = ?, content = ?, metadata = ?, updated_at = ?
WHERE id = ?""",
(title, content, metadata_json, now, str(segment_id)),
)
conn.commit()
return get_segment(db_path, segment_id)
finally:
conn.close()
def delete_segment(db_path: Path, segment_id: UUID) -> bool:
conn = get_connection(db_path)
try:
cursor = conn.execute(
"DELETE FROM segments WHERE id = ?", (str(segment_id),)
)
conn.commit()
return cursor.rowcount > 0
finally:
conn.close()
def reorder_segments(db_path: Path, segment_ids: list[UUID]) -> list[Segment]:
conn = get_connection(db_path)
try:
existing_ids = {
row["id"]
for row in conn.execute("SELECT id FROM segments").fetchall()
}
requested_ids = {str(sid) for sid in segment_ids}
missing = requested_ids - existing_ids
if missing:
raise ValueError(f"Segment IDs not found: {missing}")
now = datetime.now(UTC).isoformat()
for index, sid in enumerate(segment_ids):
conn.execute(
"UPDATE segments SET sort_order = ?, updated_at = ? WHERE id = ?",
(index, now, str(sid)),
)
conn.commit()
return list_segments(db_path)
finally:
conn.close()
```
### `backend/app/services/site_service.py`
```python
from pathlib import Path
from app.database import get_connection
def get_site_title(db_path: Path) -> str:
conn = get_connection(db_path)
try:
row = conn.execute(
"SELECT value FROM site WHERE key = ?", ("title",)
).fetchone()
if row is None:
return "Untitled Site"
return str(row["value"])
finally:
conn.close()
def update_site_title(db_path: Path, title: str) -> str:
conn = get_connection(db_path)
try:
conn.execute(
"INSERT OR REPLACE INTO site (key, value) VALUES (?, ?)",
("title", title),
)
conn.commit()
return title
finally:
conn.close()
```
### `backend/app/main.py`
```python
from fastapi import FastAPI
app = FastAPI(title="Handin Website")
@app.get("/api/health")
def health_check() -> dict[str, str]:
return {"status": "ok"}
```
### `backend/tests/conftest.py`
```python
import os
import tempfile
from collections.abc import Generator
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from app.config import Settings
TEST_ADMIN_TOKEN = "test-secret-token"
@pytest.fixture
def tmp_data_dir() -> Generator[Path, None, None]:
with tempfile.TemporaryDirectory() as tmpdir:
data_dir = Path(tmpdir)
(data_dir / "assets").mkdir()
yield data_dir
@pytest.fixture
def _env_settings(tmp_data_dir: Path) -> Generator[None, None, None]:
original_env = os.environ.copy()
os.environ["HANDIN_ADMIN_TOKEN"] = TEST_ADMIN_TOKEN
os.environ["HANDIN_DATA_DIR"] = str(tmp_data_dir)
from app.config import get_settings
get_settings.cache_clear()
yield
os.environ.clear()
os.environ.update(original_env)
get_settings.cache_clear()
@pytest.fixture
def settings(_env_settings: None) -> Settings:
from app.config import get_settings
s = get_settings()
assert isinstance(s, Settings)
return s
@pytest.fixture
def db(tmp_data_dir: Path) -> Path:
"""Return path to an initialized SQLite database in the temp dir."""
from app.database import init_db
db_path = tmp_data_dir / "handin.db"
init_db(db_path)
return db_path
@pytest.fixture
def client(_env_settings: None) -> TestClient:
from app.main import app
return TestClient(app)
```
### `pyproject.toml` (relevant sections)
```toml
[tool.mypy]
strict = true
python_version = "3.12"
mypy_path = "backend"
packages = ["app"]
plugins = ["pydantic.mypy"]
```
### Existing empty `__init__.py` files
- `backend/app/__init__.py`
- `backend/app/routes/__init__.py`
- `backend/app/services/__init__.py`
- `backend/tests/__init__.py`
## Architecture Notes
- The routes layer is thin: parse request → call service → return response
- Authentication uses a shared token (`Settings.admin_token`) checked via a FastAPI dependency
- Auth accepts token via **two methods**: `?token=` query param (checked first) OR `Authorization: Bearer <token>` header (fallback)
- All admin endpoints (create, update, delete, reorder segments; update site title) require auth
- Public endpoints (list segments, get segment, get site title, health) require no authentication
- The database path is derived from `Settings.data_dir` via a FastAPI dependency
- The `main.py` lifespan must call `init_db()` to ensure the database exists on startup
- Register the `/reorder` route **before** `/{segment_id}` to avoid path conflict
## Step 1: Write Tests
Write ALL tests FIRST, before any production code. Use the `client` fixture from conftest.
### `backend/tests/test_routes.py`
**Helper constants at module level:**
```python
from tests.conftest import TEST_ADMIN_TOKEN
BEARER_HEADERS = {"Authorization": f"Bearer {TEST_ADMIN_TOKEN}"}
```
**Segment route tests:**
1. **`test_list_segments_empty`** — `GET /api/segments` returns `200` with an empty JSON list.
2. **`test_create_segment`** — `POST /api/segments` with `BEARER_HEADERS` and body `{"type": "markdown", "title": "Intro"}`. Assert `201`, response has `id`, `title == "Intro"`, `type == "markdown"`, `sort_order == 0`.
3. **`test_create_segment_unauthorized`** — `POST /api/segments` without auth headers. Assert `401`.
4. **`test_create_segment_wrong_token`** — `POST /api/segments` with `Authorization: Bearer wrong-token`. Assert `401`.
5. **`test_create_segment_via_query_param`** — `POST /api/segments?token={TEST_ADMIN_TOKEN}` (no headers). Assert `201`.
6. **`test_get_segment`** — Create a segment via POST, then `GET /api/segments/{id}`. Assert `200` and matching data.
7. **`test_get_segment_not_found`** — `GET /api/segments/{random_uuid}`. Assert `404`.
8. **`test_update_segment`** — Create a segment, then `PATCH /api/segments/{id}` with `BEARER_HEADERS` and `{"title": "Updated"}`. Assert `200` and `title == "Updated"`.
9. **`test_update_segment_not_found`** — `PATCH /api/segments/{random_uuid}` with `BEARER_HEADERS`. Assert `404`.
10. **`test_update_segment_unauthorized`** — `PATCH /api/segments/{id}` without auth. Assert `401`.
11. **`test_delete_segment`** — Create a segment, then `DELETE /api/segments/{id}` with `BEARER_HEADERS`. Assert `204`. Confirm `GET /api/segments/{id}` returns `404`.
12. **`test_delete_segment_not_found`** — `DELETE /api/segments/{random_uuid}` with `BEARER_HEADERS`. Assert `404`.
13. **`test_delete_segment_unauthorized`** — `DELETE /api/segments/{id}` without auth. Assert `401`.
14. **`test_reorder_segments`** — Create three segments (A, B, C). `PUT /api/segments/reorder` with `BEARER_HEADERS` and `{"segment_ids": [C.id, A.id, B.id]}`. Assert `200`. List segments and verify new order is C, A, B.
15. **`test_reorder_segments_invalid_ids`** — `PUT /api/segments/reorder` with `BEARER_HEADERS` and a non-existent UUID in the list. Assert `400`.
16. **`test_reorder_segments_unauthorized`** — `PUT /api/segments/reorder` without auth. Assert `401`.
**Site route tests:**
17. **`test_get_site_title_default`** — `GET /api/site`. Assert `200` and `title == "Untitled Site"`.
18. **`test_update_site_title`** — `PUT /api/site` with `BEARER_HEADERS` and `{"title": "My Course"}`. Assert `200` and `title == "My Course"`. Then `GET /api/site` confirms it.
19. **`test_update_site_title_unauthorized`** — `PUT /api/site` without auth. Assert `401`.
**Auth route tests:**
20. **`test_auth_verify_valid_bearer`** — `GET /api/auth/verify` with `BEARER_HEADERS`. Assert `200` and body `{"valid": true}`.
21. **`test_auth_verify_valid_query_param`** — `GET /api/auth/verify?token={TEST_ADMIN_TOKEN}`. Assert `200` and body `{"valid": true}`.
22. **`test_auth_verify_invalid`** — `GET /api/auth/verify` with wrong token. Assert `401`.
23. **`test_auth_verify_no_token`** — `GET /api/auth/verify` with no auth. Assert `401`.
**Health test:**
24. **`test_health`** — `GET /api/health`. Assert `200` and `status == "ok"`.
**Test conventions:**
- Each test function takes `client: TestClient` as parameter (from fixture)
- No mocking — use real HTTP calls via TestClient against real SQLite on temp directories
- Create segments via `client.post(...)` within tests rather than calling service functions directly
### `backend/tests/test_concurrent.py`
Use the `client` fixture. Import `TEST_ADMIN_TOKEN` from conftest.
1. **`test_concurrent_creates`** — Use `concurrent.futures.ThreadPoolExecutor` to fire 10 concurrent `POST /api/segments` requests (all with auth). Assert all 10 return `201`. Assert `GET /api/segments` returns 10 segments with 10 unique IDs and `sort_order` values `0` through `9` (no gaps, no duplicates).
2. **`test_concurrent_create_and_reorder`** — Create 3 segments sequentially. Then concurrently: create 2 more segments AND reorder the original 3. Assert no errors (all requests return 2xx). Assert final DB state is consistent (5 total segments, all have valid `sort_order` values).
**Test conventions:**
- Use `concurrent.futures.ThreadPoolExecutor(max_workers=10)` for thread-based concurrency
- Each thread gets its own `TestClient` call (the underlying SQLite handles concurrency via WAL + busy_timeout)
## Step 2: Implement `backend/app/auth.py`
**Single function (used as FastAPI dependency):**
### `require_admin(request: Request) -> None`
- First check for `token` query parameter in `request.query_params`
- If not present, check `Authorization` header for `Bearer <token>` format
- Compare extracted token against `get_settings().admin_token`
- Raise `HTTPException(status_code=401, detail="Invalid or missing token")` if:
- No token found in either location
- Token doesn't match
- Import `Request` from `fastapi`, `HTTPException` from `fastapi`
## Step 3: Implement `backend/app/routes/auth.py`
Create a FastAPI `APIRouter` with `prefix="/api/auth"` and `tags=["auth"]`.
### `GET /verify``dict[str, bool]`
- Depends on `require_admin` (if the dependency passes, token is valid)
- Returns `{"valid": True}`
## Step 4: Implement `backend/app/routes/segments.py`
Create a FastAPI `APIRouter` with `prefix="/api/segments"` and `tags=["segments"]`.
**Shared dependency (define in this file):**
### `get_db_path() -> Path`
- A FastAPI dependency (use `Depends`)
- Reads `data_dir` from `get_settings()`
- Computes `db_path = Path(data_dir) / "handin.db"`
- Calls `init_db(db_path)` to ensure the database is initialized
- Returns the `db_path`
**Endpoints (register `/reorder` BEFORE `/{segment_id}`):**
### `GET /``list[SegmentResponse]`
- Public (no auth required)
- Calls `segment_service.list_segments(db_path)`
- Returns `200` with list of `SegmentResponse`
### `POST /``SegmentResponse`
- Admin only (`Depends(require_admin)`)
- Accepts `SegmentCreateRequest` as JSON body
- Calls `segment_service.create_segment(db_path, request)`
- Returns `201` with the created `SegmentResponse`
### `PUT /reorder``list[SegmentResponse]`
- Admin only
- Accepts `ReorderRequest` as JSON body
- Calls `segment_service.reorder_segments(db_path, request.segment_ids)`
- Catches `ValueError` and returns `400` with error detail
- Returns `200` with the reordered list on success
### `GET /{segment_id}``SegmentResponse`
- Public (no auth required)
- Calls `segment_service.get_segment(db_path, segment_id)`
- Returns `200` if found, raises `HTTPException(404)` if not
### `PATCH /{segment_id}``SegmentResponse`
- Admin only
- Accepts `SegmentUpdateRequest` as JSON body
- Calls `segment_service.update_segment(db_path, segment_id, request)`
- Returns `200` if found and updated, raises `HTTPException(404)` if not
### `DELETE /{segment_id}``None`
- Admin only
- Calls `segment_service.delete_segment(db_path, segment_id)`
- Returns `204` if deleted, raises `HTTPException(404)` if not found
## Step 5: Implement `backend/app/routes/site.py`
Create a FastAPI `APIRouter` with `prefix="/api/site"` and `tags=["site"]`.
Reuse `get_db_path` from `segments.py` (or extract to a shared location if preferred).
### `GET /``SiteResponse`
- Public (no auth required)
- Calls `site_service.get_site_title(db_path)`
- Returns `200` with `SiteResponse`
### `PUT /``SiteResponse`
- Admin only (`Depends(require_admin)`)
- Accepts `SiteUpdateRequest` as JSON body
- Calls `site_service.update_site_title(db_path, request.title)`
- Returns `200` with `SiteResponse`
## Step 6: Modify `backend/app/main.py`
Update `main.py` to:
- Add a **lifespan** context manager that calls `init_db()` on startup (compute `db_path` from `get_settings().data_dir`)
- Import and register all three routers: `auth_router`, `segments_router`, `site_router`
- Keep the existing `/api/health` endpoint
## Verification
After implementation, run these commands and ensure they all succeed:
```bash
uv run pytest backend/tests/test_routes.py -v
uv run pytest backend/tests/test_concurrent.py -v
uv run mypy --strict backend/app/auth.py backend/app/routes/auth.py backend/app/routes/segments.py backend/app/routes/site.py backend/app/main.py
uv run ruff check backend/app/auth.py backend/app/routes/ backend/app/main.py
```
Then run the **full** verification suite to ensure nothing is broken:
```bash
uv run pytest backend/tests/ -v
uv run mypy --strict backend/app/ backend/tests/
uv run ruff check backend/
```
All tests must pass (~40 total: 6 database + 15 service + 24 route + 2 concurrency). Zero mypy errors. Zero ruff errors.
## Files to Create/Modify
| Action | File |
|--------|------|
| CREATE | `backend/tests/test_routes.py` |
| CREATE | `backend/tests/test_concurrent.py` |
| CREATE | `backend/app/auth.py` |
| CREATE | `backend/app/routes/auth.py` |
| CREATE | `backend/app/routes/segments.py` |
| CREATE | `backend/app/routes/site.py` |
| MODIFY | `backend/app/main.py` |
Do NOT modify any other files. Do NOT modify `conftest.py`, `config.py`, `database.py`, `models.py`, or the service files.

View file

@ -0,0 +1,499 @@
# Slice 4: Asset Service + Routes + Docker
## Role
You are a backend Python developer implementing the asset upload/delete service, asset API routes, and Docker deployment for a FastAPI-based academic submission website. You follow TDD strictly: write all tests first, then implement until they pass.
## Objective
Create three production files (`services/asset_service.py`, `routes/assets.py`, `Dockerfile`, `docker-compose.yml`) and modify one (`main.py`). Create one test file (`tests/test_assets.py`). All tests must pass. All production files must pass `mypy --strict`.
## Coding Standards (mandatory)
- Python 3.12+ features (type parameter syntax, `StrEnum`, `list[X]` not `List[X]`)
- All function signatures must have type hints (params and return)
- Use `UUID` from `uuid` for ID fields, `datetime` from `datetime` for timestamps
- Max 50 lines per function; extract helpers if longer
- Guard clauses before logic (fail early, no nested conditionals)
- No `else` after `return`/`raise`
- Max 3 levels of indentation
- Imports grouped: stdlib → third-party → local (absolute imports only: `from app.module import ...`)
- No `Any` types. No `# type: ignore` without explanation
- Comments explain WHY, not WHAT
- Use `Annotated[..., Depends(...)]` type aliases to satisfy ruff B008 (no function calls in default arguments)
## Step 0: Verify Previous Slices
Before writing any new code, verify that slices 13 are healthy. Run:
```bash
uv run pytest backend/tests/ -v
uv run mypy --strict backend/app/
uv run ruff check backend/
```
**All commands must exit cleanly with zero errors.** If any fail, fix the issues before proceeding. Do NOT continue to Step 1 with a broken baseline.
## Context: Existing Files
### `backend/app/config.py`
```python
from functools import lru_cache
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
admin_token: str
data_dir: str = "./data"
max_upload_bytes: int = 100_000_000
allowed_upload_types: str = "pdf,png,jpg,jpeg,gif,mp4,webm,mp3,wav,ogg,webp"
model_config = {"env_prefix": "HANDIN_"}
@lru_cache
def get_settings() -> Settings:
return Settings()
```
### `backend/app/database.py`
```python
import sqlite3
from pathlib import Path
def init_db(db_path: Path) -> None:
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db_path)
try:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
conn.execute("""
CREATE TABLE IF NOT EXISTS site (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS segments (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
sort_order INTEGER NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
metadata TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
""")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_segments_order ON segments(sort_order)"
)
conn.commit()
finally:
conn.close()
def get_connection(db_path: Path) -> sqlite3.Connection:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA busy_timeout=5000")
return conn
```
### `backend/app/auth.py`
```python
from fastapi import HTTPException, Request
from app.config import get_settings
def require_admin(request: Request) -> None:
token = request.query_params.get("token")
if token is None:
auth_header = request.headers.get("authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header.removeprefix("Bearer ")
if token is None or token != get_settings().admin_token:
raise HTTPException(status_code=401, detail="Invalid or missing token")
```
### `backend/app/routes/segments.py` (for `get_db_path` and type alias patterns)
```python
import threading
from pathlib import Path
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Response
from app.auth import require_admin
from app.config import get_settings
from app.database import init_db
from app.models import (
ReorderRequest,
SegmentCreateRequest,
SegmentResponse,
SegmentUpdateRequest,
)
from app.services import segment_service
router = APIRouter(prefix="/api/segments", tags=["segments"])
# Serialize writes to prevent sort_order race conditions in SQLite
_write_lock = threading.Lock()
def get_db_path() -> Path:
data_dir = get_settings().data_dir
db_path = Path(data_dir) / "handin.db"
init_db(db_path)
return db_path
DbPath = Annotated[Path, Depends(get_db_path)]
Admin = Annotated[None, Depends(require_admin)]
# ... endpoints follow using DbPath and Admin type aliases ...
```
### `backend/app/main.py`
```python
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from app.config import get_settings
from app.database import init_db
from app.routes.auth import router as auth_router
from app.routes.segments import router as segments_router
from app.routes.site import router as site_router
@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
db_path = Path(get_settings().data_dir) / "handin.db"
init_db(db_path)
yield
app = FastAPI(title="Handin Website", lifespan=lifespan)
app.include_router(auth_router)
app.include_router(segments_router)
app.include_router(site_router)
@app.get("/api/health")
def health_check() -> dict[str, str]:
return {"status": "ok"}
```
### `backend/tests/conftest.py`
```python
import os
import tempfile
from collections.abc import Generator
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from app.config import Settings
TEST_ADMIN_TOKEN = "test-secret-token"
@pytest.fixture
def tmp_data_dir() -> Generator[Path, None, None]:
with tempfile.TemporaryDirectory() as tmpdir:
data_dir = Path(tmpdir)
(data_dir / "assets").mkdir()
yield data_dir
@pytest.fixture
def _env_settings(tmp_data_dir: Path) -> Generator[None, None, None]:
original_env = os.environ.copy()
os.environ["HANDIN_ADMIN_TOKEN"] = TEST_ADMIN_TOKEN
os.environ["HANDIN_DATA_DIR"] = str(tmp_data_dir)
from app.config import get_settings
get_settings.cache_clear()
yield
os.environ.clear()
os.environ.update(original_env)
get_settings.cache_clear()
@pytest.fixture
def settings(_env_settings: None) -> Settings:
from app.config import get_settings
s = get_settings()
assert isinstance(s, Settings)
return s
@pytest.fixture
def db(tmp_data_dir: Path) -> Path:
"""Return path to an initialized SQLite database in the temp dir."""
from app.database import init_db
db_path = tmp_data_dir / "handin.db"
init_db(db_path)
return db_path
@pytest.fixture
def client(_env_settings: None) -> TestClient:
from app.main import app
return TestClient(app)
```
### `pyproject.toml`
```toml
[project]
name = "handin-website"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"fastapi==0.135.1",
"uvicorn[standard]==0.41.0",
"pydantic==2.12.5",
"pydantic-settings==2.13.1",
"python-multipart==0.0.22",
]
[dependency-groups]
dev = [
"pytest==9.0.2",
"httpx==0.28.1",
"ruff==0.15.4",
"mypy==1.19.1",
]
[tool.pytest.ini_options]
testpaths = ["backend/tests"]
[tool.ruff]
target-version = "py312"
line-length = 99
src = ["backend"]
[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"]
[tool.ruff.lint.isort]
known-first-party = ["app"]
[tool.mypy]
strict = true
python_version = "3.12"
mypy_path = "backend"
packages = ["app"]
plugins = ["pydantic.mypy"]
```
### Existing empty `__init__.py` files
- `backend/app/__init__.py`
- `backend/app/routes/__init__.py`
- `backend/app/services/__init__.py`
- `backend/tests/__init__.py`
## Architecture Notes
- Assets are uploaded as multipart files and stored as `data/assets/<uuid>.<ext>`
- Original filename is NOT used on disk (prevents path traversal and collisions)
- The extension is extracted from the original filename, validated against `Settings.allowed_upload_types`
- File size is validated against `Settings.max_upload_bytes`
- Assets are served via FastAPI's `StaticFiles` mount at `/api/assets`
- Upload and delete endpoints require admin auth
- The asset service is a pure function layer — it receives `assets_dir: Path` and operates on the filesystem
- Docker: multi-stage build (Node frontend build → Python backend), single container serves everything
## Step 1: Write Tests
Write ALL tests FIRST, before any production code. Use the `client` fixture from conftest.
### `backend/tests/test_assets.py`
**Helper constants at module level:**
```python
from tests.conftest import TEST_ADMIN_TOKEN
BEARER_HEADERS = {"Authorization": f"Bearer {TEST_ADMIN_TOKEN}"}
```
**Asset route tests:**
1. **`test_upload_asset`** — `POST /api/assets` with `BEARER_HEADERS` and a multipart file upload (`file` field, filename `test.pdf`, content `b"fake pdf content"`, content type `application/pdf`). Assert `201`. Response JSON has `filename` key. The `filename` value should NOT equal `test.pdf` (it's UUID-based). The `filename` should end with `.pdf`.
2. **`test_upload_asset_file_exists_on_disk`** — Upload a file via POST. Extract the returned `filename`. Use the `client` fixture's app settings to derive the assets directory (`Path(get_settings().data_dir) / "assets"`). Assert the file exists on disk at `assets_dir / filename`. Assert the file content matches what was uploaded.
3. **`test_upload_asset_disallowed_type`** — Upload a file with filename `malware.exe`. Assert `415` (Unsupported Media Type).
4. **`test_upload_asset_too_large`** — Upload a file larger than `max_upload_bytes`. Since the default is 100MB and creating that in a test is impractical, temporarily set `HANDIN_MAX_UPLOAD_BYTES` to a small value (e.g. `"100"`) in the test environment, clear and rebuild settings, then upload a file larger than 100 bytes. Assert `413` (Request Entity Too Large). Restore the environment after the test. Alternatively, use a fixture or monkeypatch approach — the key is that the test must verify the size limit works.
5. **`test_upload_asset_unauthorized`** — `POST /api/assets` without auth headers. Assert `401`.
6. **`test_delete_asset`** — Upload a file, extract the `filename`. Then `DELETE /api/assets/{filename}` with `BEARER_HEADERS`. Assert `204`. Verify the file no longer exists on disk.
7. **`test_delete_asset_not_found`** — `DELETE /api/assets/nonexistent.pdf` with `BEARER_HEADERS`. Assert `404`.
8. **`test_delete_asset_unauthorized`** — `DELETE /api/assets/somefile.pdf` without auth. Assert `401`.
9. **`test_serve_asset`** — Upload a file via the API. Then `GET /api/assets/{filename}` (no auth needed — public). Assert `200`. Assert the response body matches the uploaded content.
**Test conventions:**
- Each test function takes `client: TestClient` as parameter (from fixture)
- No mocking — use real HTTP calls via TestClient against real filesystem on temp directories
- Use `from app.config import get_settings` within tests where you need `data_dir`
## Step 2: Implement `backend/app/services/asset_service.py`
Two functions:
### `save_asset(assets_dir: Path, filename: str, content: bytes, allowed_types: str, max_bytes: int) -> str`
- Extract extension from `filename` (lowercase, without the dot)
- Validate extension is in `allowed_types` (comma-separated string) — raise `ValueError("Unsupported file type")` if not
- Validate `len(content)` does not exceed `max_bytes` — raise `ValueError("File too large")` if it does
- Generate a UUID-based filename: `f"{uuid4()}.{ext}"`
- Ensure `assets_dir` exists (`mkdir(parents=True, exist_ok=True)`)
- Write `content` to `assets_dir / new_filename`
- Return `new_filename`
### `delete_asset(assets_dir: Path, filename: str) -> bool`
- Construct full path: `assets_dir / filename`
- Validate the resolved path is within `assets_dir` (prevent path traversal) — return `False` if not
- If the file exists, delete it and return `True`
- Return `False` if the file doesn't exist
## Step 3: Implement `backend/app/routes/assets.py`
Create a FastAPI `APIRouter` with `prefix="/api/assets"` and `tags=["assets"]`.
**Shared dependency:**
### `get_assets_dir() -> Path`
- Reads `data_dir` from `get_settings()`
- Returns `Path(data_dir) / "assets"`
Use `Annotated` type aliases for dependencies (same pattern as `segments.py`).
**Endpoints:**
### `POST /``dict[str, str]` (status 201)
- Admin only (`Depends(require_admin)`)
- Accepts `file: UploadFile` parameter
- Reads the file content: `content = await file.read()`
- Calls `asset_service.save_asset(assets_dir, file.filename or "upload", content, settings.allowed_upload_types, settings.max_upload_bytes)`
- Catches `ValueError` — if message contains "File too large" return `413`, if "Unsupported file type" return `415`
- Returns `{"filename": saved_filename}` with status `201`
### `DELETE /{filename}``Response` (status 204)
- Admin only
- Calls `asset_service.delete_asset(assets_dir, filename)`
- Returns `204` if deleted, raises `HTTPException(404)` if not found
## Step 4: Modify `backend/app/main.py`
Update `main.py` to:
- Import and register the `assets_router`
- Mount `StaticFiles` at `/api/assets` to serve uploaded files. This mount must be registered **after** the assets API router (so POST/DELETE are handled by the router, and GET falls through to StaticFiles)
- The lifespan should also ensure the `assets` directory exists on startup (`(Path(data_dir) / "assets").mkdir(parents=True, exist_ok=True)`)
**Important:** The `StaticFiles` mount for assets serves files from `{data_dir}/assets/`. The API router handles POST and DELETE at `/api/assets`. To avoid conflicts, mount `StaticFiles` at a path like `/api/assets/file` or use a different strategy. One clean approach: register the assets API router with routes for upload (`POST /api/assets`) and delete (`DELETE /api/assets/{filename}`), then mount `StaticFiles(directory=assets_path)` at `/api/assets/file` for serving. Alternatively, keep the router at `/api/assets` and mount static files at `/api/assets` — FastAPI checks routers first, so `POST` and `DELETE` will be handled by the router, while `GET` requests for specific files will fall through to the static mount. Test which approach works and go with it.
## Step 5: Create `Dockerfile`
Multi-stage Dockerfile at the project root:
### Stage 1: Frontend build
```dockerfile
FROM node:20-alpine AS frontend-build
WORKDIR /build
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ .
RUN npm run build
```
### Stage 2: Python backend + built frontend
```dockerfile
FROM python:3.12-slim AS production
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY backend/ backend/
COPY --from=frontend-build /build/dist static/
EXPOSE 8000
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--app-dir", "backend"]
```
## Step 6: Create `docker-compose.yml`
```yaml
services:
web:
build: .
ports:
- "8000:8000"
environment:
- HANDIN_ADMIN_TOKEN=${HANDIN_ADMIN_TOKEN:?Set HANDIN_ADMIN_TOKEN}
- HANDIN_DATA_DIR=/data
volumes:
- handin-data:/data
volumes:
handin-data:
```
## Verification
After implementation, run these commands and ensure they all succeed:
```bash
uv run pytest backend/tests/test_assets.py -v
uv run mypy --strict backend/app/services/asset_service.py backend/app/routes/assets.py backend/app/main.py
uv run ruff check backend/app/services/asset_service.py backend/app/routes/assets.py backend/app/main.py
```
Then run the **full** verification suite to ensure nothing is broken:
```bash
uv run pytest backend/tests/ -v
uv run mypy --strict backend/app/ backend/tests/
uv run ruff check backend/
```
All tests must pass (~56 total: 6 database + 15 service + 24 route + 2 concurrency + 9 asset). Zero mypy errors. Zero ruff errors.
Optionally verify Docker builds (only if Docker is available):
```bash
docker compose build
```
## Files to Create/Modify
| Action | File |
|--------|------|
| CREATE | `backend/tests/test_assets.py` |
| CREATE | `backend/app/services/asset_service.py` |
| CREATE | `backend/app/routes/assets.py` |
| CREATE | `Dockerfile` |
| CREATE | `docker-compose.yml` |
| MODIFY | `backend/app/main.py` |

View file

@ -0,0 +1,323 @@
# Slice 5: Frontend Shell + Segment Renderers
## Role
You are a frontend TypeScript/Vue developer building the read-only public UI for a FastAPI-based academic submission website. The backend is complete — you are building the Vue 3 / Tailwind CSS v4 frontend that consumes its API.
## Objective
Create the read-only frontend shell: sidebar navigation, responsive layout, data fetching composable, TypeScript types, and all segment renderers. No admin functionality in this slice. All files must pass `vue-tsc --noEmit` (strict TypeScript).
## Architecture Reference
Read [PLAN.md](../PLAN.md) before starting. It contains the full architecture context: color system, layout specs, component hierarchy, and design decisions. Use it as the source of truth for any details not covered in this prompt.
## UI Decision-Making Policy
**When you encounter any ambiguity in visual design, layout, spacing, interaction patterns, or component behavior — use `AskUserQuestion` BEFORE implementing.** This is the prioritized method of resolution. Do not guess or pick defaults for subjective UI choices. Examples of when to ask:
- Exact spacing/padding values not specified in the prompt
- Animation/transition details (duration, easing, direction)
- Empty state presentation (what to show when no segments exist)
- Icon choices (hamburger style, close button style)
- Typography sizing not explicitly stated
- Any "or" choices in the prompt (e.g., "`<iframe>` or `<embed>`")
- Hover/focus state details beyond what's specified
Only proceed without asking when the prompt or PLAN.md gives an unambiguous, specific instruction.
## Coding Standards (mandatory)
- Vue 3 `<script setup lang="ts">` single-file components
- Strict TypeScript — no `any`, all props/emits typed
- Tailwind CSS v4 utility classes (no separate CSS files per component)
- Composables use `ref`/`computed`/`onMounted` from Vue — no Options API
- Components are small and focused — one responsibility per file
- Use `@/` path alias for imports (configured in vite.config.ts as `src/`)
- No external state management library — use composables with reactive state
## Step 0: Verify Existing Setup
Before writing any code, verify the frontend builds:
```bash
cd frontend && npm run build
```
Must exit cleanly. If it fails, fix before proceeding.
## Context: Backend API
The backend is running at `http://localhost:8000` (proxied via Vite dev server at `/api`).
### API Endpoints Used by This Slice
```
GET /api/site → { "title": string }
GET /api/segments → Array of SegmentResponse
GET /api/assets/{file} → Static file (binary)
```
### SegmentResponse Shape (from backend `models.py`)
```json
{
"id": "uuid-string",
"type": "markdown" | "pdf" | "video" | "audio" | "iframe" | "gallery",
"sort_order": 0,
"title": "Section Title",
"content": "markdown text or URL or empty",
"metadata": {},
"created_at": "2025-01-01T00:00:00",
"updated_at": "2025-01-01T00:00:00"
}
```
**Content conventions by type:**
- `markdown``content` is raw markdown text
- `pdf``content` is the asset URL (e.g., `/api/assets/uuid.pdf`)
- `video``content` is the asset URL (e.g., `/api/assets/uuid.mp4`)
- `audio``content` is the asset URL (e.g., `/api/assets/uuid.mp3`)
- `iframe``content` is the embed URL (any external URL)
- `gallery``metadata` contains `{ "images": ["/api/assets/uuid.png", ...] }`
### Existing Frontend Files
**`frontend/src/style.css`** (already has theme):
```css
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@theme {
--color-primary-50: #fffbeb;
--color-primary-100: #fff3c4;
--color-primary-200: #fce588;
--color-primary-300: #ffcd00;
--color-primary-400: #e6b800;
--color-primary-500: #cc9900;
--color-primary-600: #997300;
--color-primary-700: #664d00;
}
```
**`frontend/vite.config.ts`** — has `@` alias to `src/`, API proxy to `localhost:8000`.
**`frontend/src/main.ts`** — mounts `App.vue` at `#app`.
**`frontend/src/App.vue`** — placeholder, will be replaced.
## UI Design Reference
### Color System
- Primary: golden yellow `#ffcd00` (primary-300) for accents and active states
- Neutrals: Tailwind `slate` — dark sidebar (`slate-900`), light content area (`slate-50`)
- Active nav item: `primary-300` left border + subtle bg highlight
### Layout — Sidebar Navigation
**Desktop (≥1024px):**
- Fixed left sidebar, `w-64`, `bg-slate-900`, full height
- Sidebar header: site title styled in `primary-300`, font bold
- Nav items: segment titles as anchor links, `text-slate-300` default, `text-white` on hover
- Active segment: `border-l-2 border-primary-300 bg-slate-800` + `text-white`
- Main content: `ml-64`, scrollable, segments as full-width cards on `bg-slate-50`
**Tablet (7681023px):**
- Sidebar off-screen by default, toggle button to slide it in as overlay
**Mobile (<768px):**
- No sidebar visible. `MobileHeader` with hamburger icon at top
- Hamburger opens slide-out overlay nav (full-height, `bg-slate-900`)
- Close button or tap-outside to dismiss
- Segments stack vertically, full-width
## Step 1: Create TypeScript Types
### `frontend/src/types/segment.ts`
```typescript
export type SegmentType = "markdown" | "pdf" | "video" | "audio" | "iframe" | "gallery";
export interface Segment {
id: string;
type: SegmentType;
sort_order: number;
title: string;
content: string;
metadata: Record<string, unknown>;
created_at: string;
updated_at: string;
}
export interface SiteInfo {
title: string;
}
```
## Step 2: Create Data Fetching Composable
### `frontend/src/composables/useSegments.ts`
Exports a composable function `useSegments()` that returns:
- `segments: Ref<Segment[]>` — list of segments, ordered by `sort_order`
- `siteTitle: Ref<string>` — site title (default: `"Handin"`)
- `loading: Ref<boolean>` — true while initial fetch is in progress
- `error: Ref<string | null>` — error message if fetch fails
- `refresh: () => Promise<void>` — re-fetch both endpoints
On mount (`onMounted`), fetch both `GET /api/site` and `GET /api/segments` in parallel using `Promise.all`. Use native `fetch()` — no axios.
## Step 3: Create Layout Components
### `frontend/src/components/layout/AppShell.vue`
Top-level layout component. Structure:
- Contains `SidebarNav` (desktop) and `MobileHeader` (mobile)
- Main content area as a `<slot />`
- Uses CSS breakpoints: sidebar visible `lg:block`, hidden below `lg`
- Mobile header visible below `lg`, hidden at `lg+`
### `frontend/src/components/layout/SidebarNav.vue`
Props:
- `segments: Segment[]`
- `siteTitle: string`
- `activeId: string | null`
Emits:
- `navigate(id: string)` — when a nav item is clicked
Renders:
- Site title in header area (`text-primary-300 font-bold text-lg`)
- List of segment titles as clickable items
- Active item has left border accent and bg highlight
- Scroll overflow if many segments
### `frontend/src/components/layout/MobileHeader.vue`
Props:
- `siteTitle: string`
- `segments: Segment[]`
- `activeId: string | null`
Manages its own open/closed state for the slide-out menu. Contains:
- Top bar with site title and hamburger button
- Slide-out overlay with same nav items as `SidebarNav`
- Click outside or close button dismisses the overlay
## Step 4: Create Segment Renderers
### `frontend/src/components/segments/SegmentRenderer.vue`
Props: `segment: Segment`
A switch component that renders the correct sub-renderer based on `segment.type`. Use a `v-if`/`v-else-if` chain or a dynamic component approach.
### `frontend/src/components/segments/SegmentList.vue`
Props: `segments: Segment[]`
Renders a vertical list of segments, each wrapped in a card-like container. Each segment card:
- Has an `id` attribute matching the segment ID (for anchor scroll)
- White background, rounded, subtle shadow
- Title as heading, then the renderer below
### Individual Renderers
Each takes a `segment: Segment` prop:
**`MarkdownSegment.vue`**
- Render `segment.content` as HTML using `marked` library
- Wrap output in a `prose` class div (Tailwind typography plugin)
- Use `v-html` with the parsed markdown
**`PdfSegment.vue`**
- Render an `<iframe>` or `<embed>` pointing to `segment.content` (the PDF URL)
- Full-width, reasonable height (e.g., `h-[600px]` or `aspect-[4/3]`)
**`VideoSegment.vue`**
- Render a `<video>` element with `controls`, `src` pointing to `segment.content`
- Full-width, responsive
**`AudioSegment.vue`**
- Render an `<audio>` element with `controls`, `src` pointing to `segment.content`
- Full-width
**`IframeSegment.vue`**
- Render an `<iframe>` with `src` from `segment.content`
- Full-width, reasonable height, border-none
- Add `sandbox` and `allow` attributes for security
**`GallerySegment.vue`**
- Read image URLs from `segment.metadata.images` (cast to `string[]`)
- Render as a responsive grid of `<img>` elements
- Grid: 2 columns on mobile, 3 on tablet, 4 on desktop
- Images have `object-cover`, rounded corners
## Step 5: Create HomePage View
### `frontend/src/views/HomePage.vue`
Wires everything together:
- Uses `useSegments()` composable
- Tracks `activeId` (string or null) — updates on scroll or nav click
- Shows loading spinner/skeleton while `loading` is true
- Shows error message if `error` is set
- Renders `AppShell` with `SegmentList` in the main slot
**Scroll tracking:** Use `IntersectionObserver` to detect which segment is currently visible and update `activeId` for sidebar highlighting.
## Step 6: Update App.vue
Replace the placeholder `App.vue` with:
```vue
<script setup lang="ts">
import HomePage from "@/views/HomePage.vue";
</script>
<template>
<HomePage />
</template>
```
## Verification
After implementation, run:
```bash
cd frontend && npm run build
```
Must succeed with zero errors. Then verify visually:
```bash
cd frontend && npm run dev
```
Check:
1. Sidebar renders with site title and segment nav items (populate via backend or verify empty state)
2. Desktop: sidebar fixed left, content scrolls independently
3. Mobile (narrow viewport): hamburger menu, slide-out nav works
4. Each segment type renders correctly when data is present
5. Active segment highlights in sidebar on scroll
6. No TypeScript errors (`npm run type-check`)
## Files to Create/Modify
| Action | File |
|--------|------|
| CREATE | `frontend/src/types/segment.ts` |
| CREATE | `frontend/src/composables/useSegments.ts` |
| CREATE | `frontend/src/components/layout/AppShell.vue` |
| CREATE | `frontend/src/components/layout/SidebarNav.vue` |
| CREATE | `frontend/src/components/layout/MobileHeader.vue` |
| CREATE | `frontend/src/components/segments/SegmentRenderer.vue` |
| CREATE | `frontend/src/components/segments/SegmentList.vue` |
| CREATE | `frontend/src/components/segments/MarkdownSegment.vue` |
| CREATE | `frontend/src/components/segments/PdfSegment.vue` |
| CREATE | `frontend/src/components/segments/VideoSegment.vue` |
| CREATE | `frontend/src/components/segments/AudioSegment.vue` |
| CREATE | `frontend/src/components/segments/IframeSegment.vue` |
| CREATE | `frontend/src/components/segments/GallerySegment.vue` |
| CREATE | `frontend/src/views/HomePage.vue` |
| MODIFY | `frontend/src/App.vue` |

366
prompts/slice-6-admin-ui.md Normal file
View file

@ -0,0 +1,366 @@
# Slice 6: Admin UI
## Role
You are a frontend TypeScript/Vue developer adding admin functionality to an existing read-only academic submission website. The backend API and read-only frontend are complete — you are adding token-based authentication, inline editing, asset upload, and drag-and-drop reorder.
## Objective
Create the full admin experience: token entry, inline segment editing (create/update/delete), asset uploading, and drag-and-drop segment reorder. Admin controls must be invisible when no token is active. All files must pass `vue-tsc --noEmit` (strict TypeScript).
## Architecture Reference
Read [PLAN.md](../PLAN.md) for full architecture context. **However, note these deviations from PLAN.md that were made during Slice 5 implementation:**
### Layout Change (IMPORTANT)
The layout was changed from a **sidebar** to a **horizontal top bar with tabs**:
- **Title bar:** `bg-primary-300` (golden yellow) with dark text, full width at top.
- **Tab navigation:** Horizontal tab bar below the title, with `border-b-2 border-primary-400` on the active tab. Visible on `md+` screens.
- **Mobile:** Hamburger dropdown (not slide-out sidebar). Opens a vertical list from the top with backdrop.
- **Content area:** Centered `max-w-4xl` on white background.
The component **file names** from PLAN.md are preserved but their implementations differ:
- `SidebarNav.vue` is actually a **horizontal tab bar** (desktop).
- `MobileHeader.vue` is a **hamburger dropdown** (mobile).
- `AppShell.vue` composes the title bar + tab nav + mobile header + main content slot.
### Backend Route Trailing Slashes
Backend collection routes use **trailing slashes**. Always include them in fetch URLs:
- `GET /api/site/` — not `/api/site`
- `GET /api/segments/` — not `/api/segments`
- `POST /api/segments/` — not `/api/segments`
- `POST /api/assets/` — not `/api/assets`
- `PUT /api/segments/reorder` — no trailing slash (path route, not collection)
- `GET /api/auth/verify` — no trailing slash
- `PATCH /api/segments/{id}` — no trailing slash (uses PATCH, not PUT)
- `DELETE /api/segments/{id}` — no trailing slash
- `DELETE /api/assets/{filename}` — no trailing slash
Omitting trailing slashes on collection routes causes **307 redirects** which break in the Docker proxy setup (CORS errors).
## UI Decision-Making Policy
**When you encounter any ambiguity in visual design, layout, spacing, interaction patterns, or component behavior — use `AskUserQuestion` BEFORE implementing.** Do not guess or pick defaults for subjective UI choices. Examples:
- How the "Add Segment" form should look (modal vs inline vs dropdown)
- Editor layout for different segment types
- Confirmation dialogs for delete actions
- Token input styling and placement
- Admin toolbar positioning relative to the top bar
- Drag handle icon/style
Only proceed without asking when this prompt gives an unambiguous, specific instruction.
## Coding Standards (mandatory)
- Vue 3 `<script setup lang="ts">` single-file components
- Strict TypeScript — no `any`, all props/emits typed
- Tailwind CSS v4 utility classes (no separate CSS files per component)
- Composables use `ref`/`computed`/`onMounted` from Vue — no Options API
- Components are small and focused — one responsibility per file
- Use `@/` path alias for imports (configured in vite.config.ts as `src/`)
- No external state management library — use composables with reactive state
- Use native `fetch()` — no axios
## Step 0: Verify Existing Setup
```bash
cd frontend && npm run build
```
Must exit cleanly. If it fails, fix before proceeding.
## Context: Backend API
### Auth Mechanism
- Single shared token set via `HANDIN_ADMIN_TOKEN` env var (in `.env` file, value: `changeme`).
- Token accepted via `?token=<token>` query param OR `Authorization: Bearer <token>` header.
- `GET /api/auth/verify` — returns `{ "valid": true }` if token is valid, 401 otherwise.
- All write endpoints require the token (POST, PATCH, PUT, DELETE on segments/assets/site).
### API Endpoints Used by This Slice
```
AUTH:
GET /api/auth/verify → { "valid": true } or 401
SEGMENTS (admin):
POST /api/segments/ → SegmentResponse (201)
Body: { "type": SegmentType, "title": string, "content"?: string, "metadata"?: object }
PATCH /api/segments/{id} → SegmentResponse
Body: { "title"?: string, "content"?: string, "metadata"?: object }
NOTE: PATCH not PUT — only send changed fields
DELETE /api/segments/{id} → 204 No Content
PUT /api/segments/reorder → SegmentResponse[]
Body: { "segment_ids": string[] }
ASSETS (admin):
POST /api/assets/ → { "filename": "uuid.ext" } (201)
Body: multipart/form-data with "file" field
DELETE /api/assets/{filename} → 204 No Content
SITE (admin):
PUT /api/site/ → { "title": string }
Body: { "title": string }
```
### Existing Frontend Types
```typescript
// frontend/src/types/segment.ts
export type SegmentType = "markdown" | "pdf" | "video" | "audio" | "iframe" | "gallery";
export interface Segment {
id: string;
type: SegmentType;
sort_order: number;
title: string;
content: string;
metadata: Record<string, unknown>;
created_at: string;
updated_at: string;
}
export interface SiteInfo {
title: string;
}
```
### Existing Composable: useSegments
```typescript
// frontend/src/composables/useSegments.ts
// Returns: { segments, siteTitle, loading, error, refresh }
// refresh() re-fetches both /api/site/ and /api/segments/
// Call refresh() after any admin mutation to sync the UI
```
### Existing Layout Structure
```
AppShell.vue
├── <header> — yellow title bar (bg-primary-300), shows siteTitle
├── SidebarNav.vue — horizontal tab bar (hidden below md)
├── MobileHeader.vue — hamburger dropdown (hidden at md+)
└── <main> — slot for page content (max-w-4xl centered)
```
### Existing Segment List
```vue
<!-- SegmentList.vue renders each segment as: -->
<section :id="segment.id" class="rounded-lg bg-white p-6 shadow-sm">
<h2>{{ segment.title }}</h2>
<SegmentRenderer :segment="segment" />
</section>
```
## Step 1: Create `useAdmin` Composable
### `frontend/src/composables/useAdmin.ts`
Manages admin authentication state. Exports a composable `useAdmin()` that returns:
- `token: Ref<string | null>` — current token (persisted in `sessionStorage`)
- `isAdmin: Ref<boolean>` — true when token is verified
- `verifying: Ref<boolean>` — true during verification request
- `login(token: string): Promise<boolean>` — verify token via `GET /api/auth/verify`, persist if valid
- `logout(): void` — clear token and admin state
- `authHeaders: ComputedRef<Record<string, string>>` — returns `{ "Authorization": "Bearer <token>" }` when authenticated, empty object otherwise
- `authQuery: ComputedRef<string>` — returns `?token=<token>` or empty string (useful for asset URLs)
On creation, check `sessionStorage` for an existing token and re-verify it silently.
**Important:** This composable must use module-level reactive state (defined outside the function) so that all components share the same auth state. The function just returns references to the shared state.
## Step 2: Create `useAssetUpload` Composable
### `frontend/src/composables/useAssetUpload.ts`
Handles file uploads. Exports `useAssetUpload()` that returns:
- `uploading: Ref<boolean>`
- `error: Ref<string | null>`
- `upload(file: File, authHeaders: Record<string, string>): Promise<string | null>` — uploads file to `POST /api/assets/`, returns the filename on success or null on failure
Use `FormData` with native `fetch()`. Set `error` on failure with the server's error message.
## Step 3: Create Admin Components
### `frontend/src/components/admin/TokenPrompt.vue`
The entry point for admin access. When no token is active:
- Show a small, subtle lock icon in the top bar (inside AppShell's header area)
- Clicking it reveals an inline token input field with a submit button
- On successful login, the input disappears and admin controls appear
Props: none (uses `useAdmin` composable directly).
Emits:
- `authenticated` — fired when login succeeds
### `frontend/src/components/admin/AdminToolbar.vue`
A thin bar shown below the tab nav when admin is authenticated. Contains:
- An "Editing" badge (subtle indicator)
- An "Add Segment" button that triggers segment creation
- A "Logout" button
Props: none (uses `useAdmin` composable directly).
Emits:
- `add-segment` — when "Add Segment" is clicked
- `logout` — when "Logout" is clicked
### `frontend/src/components/admin/SegmentEditor.vue`
Inline editor that appears below a segment card when editing. Handles:
- Editing title (text input)
- Editing content based on segment type:
- `markdown`: textarea for raw markdown
- `pdf`, `video`, `audio`: file upload (via AssetUploader) or URL input showing current asset
- `iframe`: URL text input
- `gallery`: file upload for multiple images, display current images with remove buttons
- Save and Cancel buttons
- Delete button (with confirmation)
Props:
- `segment: Segment`
Emits:
- `save(updates: { title?: string; content?: string; metadata?: Record<string, unknown> })` — partial update
- `cancel` — close editor
- `delete` — delete this segment
### `frontend/src/components/admin/AssetUploader.vue`
Drag-and-drop file upload zone. Features:
- Dashed border drop zone
- Click to select file
- Shows upload progress/status
- Returns the uploaded filename
Props:
- `accept?: string` — file type filter (e.g., `"application/pdf"`, `"image/*"`)
- `label?: string` — descriptive text inside the zone
Emits:
- `uploaded(filename: string)` — when upload completes successfully
## Step 4: Integrate Admin into Existing Components
### Modify `AppShell.vue`
- Add `TokenPrompt` to the title bar header (when not authenticated)
- Add `AdminToolbar` below the tab nav (when authenticated)
- Pass admin state down or let child components use `useAdmin` directly
### Modify `SegmentList.vue`
When admin is active:
- Show a small edit icon (pencil) on each segment card header (next to the title)
- Clicking the edit icon toggles `SegmentEditor` inline below that segment
- Show drag handles on each card for reorder (use `vuedraggable`)
- After drag-and-drop reorder, call `PUT /api/segments/reorder` with the new ID order, then `refresh()`
The `vuedraggable` package is already installed (`"vuedraggable": "4.1.0"` in package.json). Import it as:
```typescript
import draggable from "vuedraggable";
```
Note: `vuedraggable` 4.x may not have TypeScript declarations. If `vue-tsc` complains, create a type declaration file `frontend/src/types/vuedraggable.d.ts`:
```typescript
declare module "vuedraggable" {
import type { DefineComponent } from "vue";
const component: DefineComponent;
export default component;
}
```
### Modify `HomePage.vue`
- Wire up the "Add Segment" flow: when `AdminToolbar` emits `add-segment`, show a creation form (could be a simple modal or inline form at the top/bottom of the segment list)
- After creating a segment, call `refresh()` to reload the list
- After editing/deleting a segment, call `refresh()`
- Handle logout: call `useAdmin().logout()`
## Step 5: Segment Creation Flow
When "Add Segment" is clicked, the user needs to:
1. Choose a segment type (dropdown or button group)
2. Enter a title
3. Provide initial content (type-dependent: text for markdown, URL for iframe, file upload for pdf/video/audio, etc.)
4. Submit → `POST /api/segments/` with auth header → `refresh()`
## Step 6: Wire Up All Mutations
All admin mutations must:
1. Include auth headers from `useAdmin().authHeaders`
2. Call `useSegments().refresh()` after success to sync the UI
3. Handle errors gracefully (show error message, don't lose user input)
### Mutation summary:
| Action | Method | Endpoint | Auth | After |
|--------|--------|----------|------|-------|
| Create segment | POST | `/api/segments/` | Bearer header | refresh() |
| Update segment | PATCH | `/api/segments/{id}` | Bearer header | refresh() |
| Delete segment | DELETE | `/api/segments/{id}` | Bearer header | refresh() |
| Reorder segments | PUT | `/api/segments/reorder` | Bearer header | refresh() |
| Upload asset | POST | `/api/assets/` | Bearer header | use filename |
| Delete asset | DELETE | `/api/assets/{filename}` | Bearer header | refresh() |
| Update site title | PUT | `/api/site/` | Bearer header | refresh() |
## Verification
After implementation, run:
```bash
cd frontend && npm run build
```
Must succeed with zero errors. Then verify functionally:
```bash
cd frontend && npm run dev
```
Or with Docker:
```bash
docker compose up --build
# Visit http://localhost:5174
```
Check:
1. **No admin UI visible** without token — only the lock icon in the header
2. Click lock icon → enter token `changeme` → admin controls appear
3. Admin toolbar shows with "Add Segment" and "Logout" buttons
4. Create a markdown segment → appears in the list
5. Edit a segment's title and content → saves correctly
6. Delete a segment → removed from list (after confirmation)
7. Drag-and-drop reorder segments → order persists after refresh
8. Upload an asset (PDF, image) → segment displays it
9. Logout → all admin UI disappears, read-only view restored
10. No TypeScript errors (`npm run type-check`)
## Files to Create/Modify
| Action | File |
|--------|------|
| CREATE | `frontend/src/composables/useAdmin.ts` |
| CREATE | `frontend/src/composables/useAssetUpload.ts` |
| CREATE | `frontend/src/components/admin/TokenPrompt.vue` |
| CREATE | `frontend/src/components/admin/AdminToolbar.vue` |
| CREATE | `frontend/src/components/admin/SegmentEditor.vue` |
| CREATE | `frontend/src/components/admin/AssetUploader.vue` |
| CREATE | `frontend/src/types/vuedraggable.d.ts` (if needed for type-check) |
| MODIFY | `frontend/src/components/layout/AppShell.vue` |
| MODIFY | `frontend/src/components/segments/SegmentList.vue` |
| MODIFY | `frontend/src/views/HomePage.vue` |

214
prompts/slice-7-polish.md Normal file
View file

@ -0,0 +1,214 @@
# Slice 7: Polish + Final Validation
## Role
You are a senior developer performing a final quality sweep on an academic group submission website. The backend (FastAPI + SQLite) and frontend (Vue 3 + TypeScript + Tailwind CSS v4) are fully implemented through Slices 06. Your job is to run every verification check, fix any issues found, and ensure the app works end-to-end.
## Objective
Run the full verification suite, fix any failures, and validate the complete user flow. The app must pass all automated checks with zero errors and work correctly in Docker.
## Architecture Reference
Read [PLAN.md](../PLAN.md) for full architecture context. Key deviations from the original plan:
### Layout
The layout uses a **horizontal top bar with tabs** (not a sidebar):
- `AppShell.vue` — sticky header (title bar `bg-primary-300` + nav bar) + dark mode toggle + TokenPrompt + AdminToolbar + back-to-top button + main content slot
- `SidebarNav.vue` — horizontal tab bar (desktop, `md+`)
- `MobileHeader.vue` — hamburger dropdown (mobile, `<md`)
- The entire header (title bar + nav) is `sticky top-0 z-20` — always visible when scrolling
- A floating back-to-top button appears after scrolling 300px (bottom-right, `bg-primary-300`)
### Backend Routes
- Collection routes use **trailing slashes** (`/api/segments/`, `/api/site/`, `/api/assets/`)
- Single-resource routes have **no trailing slash** (`/api/segments/{id}`, `/api/auth/verify`)
- Segment updates use **PATCH** (not PUT) — partial updates only
### Segment Types
Seven types: `markdown`, `pdf`, `video`, `audio`, `iframe`, `gallery`, `link`.
- `link` is nav-only — appears in tab bar/mobile menu as an external `<a target="_blank">`, not in the content area.
### Site Title
- Configurable via `HANDIN_SITE_TITLE` env var (default: `"Untitled Site"`)
- Stored in `config.py` as `Settings.site_title`, passed to `site_service.get_site_title()` as `default` kwarg
- Can also be overridden at runtime via `PUT /api/site/` (stored in DB, takes precedence over env var)
### Admin UI
- Lock icon in title bar → inline token input → admin controls appear
- Header icons (lock, dark mode toggle) use `text-slate-700` in both light and dark mode (yellow header stays consistent)
- AdminToolbar below tabs: "Editing" badge + "Add Segment" (modal) + "Logout"
- Inline SegmentEditor below each card with type-aware editing
- Grip-dot drag handles for reorder via `vuedraggable`
- Custom styled delete confirmation modal (not browser `confirm()`)
## Step 1: Backend Verification
Run these checks and fix any failures:
```bash
# Tests
uv run pytest backend/tests/ -v
# Linting
uv run ruff check backend/
# Type checking
uv run mypy --strict backend/app/
```
All must pass with zero errors. If any tests fail:
1. Read the failing test to understand what it expects
2. Read the relevant source code
3. Fix the source (not the test) unless the test itself is wrong
4. Re-run until clean
### Known Backend Files
```
backend/app/
├── main.py
├── config.py # Settings: admin_token, site_title, data_dir, etc.
├── auth.py
├── models.py # SegmentType enum includes LINK = "link"
├── database.py
├── routes/
│ ├── auth.py
│ ├── segments.py
│ ├── site.py # Passes settings.site_title as default to service
│ └── assets.py
└── services/
├── segment_service.py
├── site_service.py # get_site_title(db_path, *, default=DEFAULT_SITE_TITLE)
└── asset_service.py
```
## Step 2: Frontend Verification
```bash
cd frontend && npm run build
```
This runs `vue-tsc --noEmit && vite build`. Must succeed with zero errors.
### Known Frontend Files
```
frontend/src/
├── composables/
│ ├── useSegments.ts
│ ├── useAdmin.ts # Module-level reactive state, sessionStorage
│ ├── useAssetUpload.ts
│ └── useDarkMode.ts # Class-based dark mode, localStorage persistence, system preference fallback
├── types/
│ ├── segment.ts # SegmentType includes "link"
│ └── vuedraggable.d.ts
├── components/
│ ├── layout/
│ │ ├── AppShell.vue # Sticky header, back-to-top button, scroll listener
│ │ ├── SidebarNav.vue # Actually horizontal tab bar
│ │ └── MobileHeader.vue # Hamburger dropdown
│ ├── segments/
│ │ ├── SegmentList.vue # Draggable (admin) or plain (read-only), filters out link segments from content
│ │ ├── SegmentRenderer.vue
│ │ ├── MarkdownSegment.vue
│ │ ├── PdfSegment.vue
│ │ ├── VideoSegment.vue
│ │ ├── AudioSegment.vue
│ │ ├── IframeSegment.vue
│ │ └── GallerySegment.vue
│ └── admin/
│ ├── TokenPrompt.vue
│ ├── AdminToolbar.vue
│ ├── SegmentEditor.vue
│ ├── AssetUploader.vue
│ └── AddSegmentModal.vue
└── views/
└── HomePage.vue
```
## Step 3: Docker Build + Smoke Test
```bash
docker compose up --build -d
```
Must build and start without errors. Then verify:
```bash
# Health check — site loads
curl -s http://localhost:8000/api/site/ | python3 -c "import sys,json; d=json.load(sys.stdin); print(d)"
# Auth verify works
curl -s http://localhost:8000/api/auth/verify -H "Authorization: Bearer changeme" | python3 -c "import sys,json; d=json.load(sys.stdin); assert d['valid']==True; print('Auth OK')"
# Create a test segment
curl -s -X POST http://localhost:8000/api/segments/ \
-H "Authorization: Bearer changeme" \
-H "Content-Type: application/json" \
-d '{"type":"markdown","title":"Test","content":"# Hello"}' | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'Created segment {d[\"id\"]}')"
# Create a link segment
curl -s -X POST http://localhost:8000/api/segments/ \
-H "Authorization: Bearer changeme" \
-H "Content-Type: application/json" \
-d '{"type":"link","title":"GitHub","content":"https://github.com"}' | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'Created link {d[\"id\"]}')"
# List segments
curl -s http://localhost:8000/api/segments/ | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'{len(d)} segments')"
# Frontend loads
curl -s http://localhost:8000/ | head -5
```
Clean up test data after verification:
```bash
docker compose down
```
## Step 4: Edge Case Review
Check these specific scenarios and fix if broken:
1. **Empty state**: With no segments, the page should show "No content yet" (not crash or show blank).
2. **Link segments in read-only mode**: Should appear in nav tabs but NOT in the content area.
3. **Link segments in admin mode**: Should appear in the draggable list with URL displayed, editable via SegmentEditor.
4. **Gallery segment with no images**: Should not crash — show empty state or just the upload zone.
5. **Admin toolbar visibility**: Must be completely hidden when not authenticated.
6. **Token persistence**: After page reload, if token was valid, admin state should restore from sessionStorage.
7. **Concurrent safety**: Two simultaneous segment creates should not corrupt sort_order.
8. **Site title env var**: With `HANDIN_SITE_TITLE=MyCourseName`, the default title should be "MyCourseName" (not "Untitled Site"). Once overridden via `PUT /api/site/`, the DB value takes precedence.
## Step 5: Visual Polish Check
Review the Tailwind classes in these components for consistency:
1. **Spacing**: All segment cards should have consistent padding (`p-6`), gap between cards (`space-y-6`).
2. **Colors**: Primary color usage should be consistent — `bg-primary-300` for brand, `primary-50`/`primary-100` for editor backgrounds, `primary-400` for hover states. The header bar keeps its yellow `bg-primary-300` in both light and dark mode (university branding).
3. **Typography**: Title bar uses `text-xl font-bold`, segment titles use `text-xl font-semibold`, body text uses default size.
4. **Responsive**: Tab bar hidden on mobile, mobile hamburger hidden on desktop. Content area is `max-w-6xl` centered.
5. **Shadows**: Segment cards use `shadow-md` (+ `dark:shadow-slate-950/30` in dark mode).
6. **Dark mode**: All components have `dark:` variant classes. Toggle is a sun/moon icon in the title bar. Uses class-based dark mode via `@custom-variant dark (&:where(.dark, .dark *))` in style.css. Preference stored in localStorage, falls back to system preference.
7. **Sticky header**: Entire header (title bar + nav) sticks to top on scroll. Back-to-top button appears after 300px scroll.
8. **Admin controls**: Edit pencil icons should be `text-slate-300 hover:text-primary-300` (muted by default). Drag handles should match.
9. **Header icons**: Lock icon and dark mode toggle use `text-slate-700` (no dark: overrides) since they sit on the always-yellow header.
## Verification Checklist
All of these must be true when you're done:
- [ ] `uv run pytest backend/tests/ -v` — all pass
- [ ] `uv run ruff check backend/` — clean
- [ ] `uv run mypy --strict backend/app/` — clean
- [ ] `cd frontend && npm run build` — zero errors
- [ ] `docker compose up --build` — builds and runs
- [ ] Dark mode toggle works, persists across reload, respects system preference on first visit
- [ ] Header bar stays yellow (`bg-primary-300`) in both light and dark mode
- [ ] Sticky header stays visible when scrolling, back-to-top button appears
- [ ] `HANDIN_SITE_TITLE` env var sets the default site title
- [ ] Read-only mode shows zero admin UI (only lock icon + dark mode toggle in header)
- [ ] Admin mode: all CRUD operations work
- [ ] Link segments appear in nav only
- [ ] Drag-and-drop reorder persists after refresh
## Files You May Modify
Any file in the project. Prefer minimal, targeted fixes. Do not refactor working code unnecessarily.

40
pyproject.toml Normal file
View file

@ -0,0 +1,40 @@
[project]
name = "handin-website"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"fastapi==0.135.1",
"uvicorn[standard]==0.41.0",
"pydantic==2.12.5",
"pydantic-settings==2.13.1",
"python-multipart==0.0.22",
]
[dependency-groups]
dev = [
"pytest==9.0.2",
"httpx==0.28.1",
"ruff==0.15.4",
"mypy==1.19.1",
]
[tool.pytest.ini_options]
testpaths = ["backend/tests"]
[tool.ruff]
target-version = "py312"
line-length = 99
src = ["backend"]
[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"]
[tool.ruff.lint.isort]
known-first-party = ["app"]
[tool.mypy]
strict = true
python_version = "3.12"
mypy_path = "backend"
packages = ["app"]
plugins = ["pydantic.mypy"]

1
ref/.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1 @@
buy_me_a_coffee: justinzeus

14
ref/.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,14 @@
version: 2
updates:
- package-ecosystem: pip
directory: /
schedule:
interval: weekly
- package-ecosystem: npm
directory: /frontend
schedule:
interval: weekly
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly

BIN
ref/.github/logo-dark.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

197
ref/.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,197 @@
name: CI
on:
pull_request:
push:
branches:
- main
jobs:
repo-hygiene:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Enforce no generated artifacts
run: ./scripts/check_no_generated_artifacts.sh
- name: Enforce env contract parity
run: python3 scripts/check_env_contract.py
lint:
runs-on: ubuntu-latest
needs: [repo-hygiene]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- uses: astral-sh/setup-uv@v4
- run: uv sync --extra dev
- name: Ruff check
run: uv run ruff check .
- name: Ruff format check
run: uv run ruff format --check .
- name: Mypy
run: uv run mypy app/ --ignore-missing-imports
test:
runs-on: ubuntu-latest
needs:
- repo-hygiene
- lint
services:
postgres:
image: postgres:15
env:
POSTGRES_DB: scholar
POSTGRES_USER: scholar
POSTGRES_PASSWORD: scholar
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U scholar -d scholar"
--health-interval 5s
--health-timeout 5s
--health-retries 20
env:
DATABASE_URL: postgresql+asyncpg://scholar:scholar@localhost:5432/scholar
SCHOLAR_IMAGE_UPLOAD_DIR: /tmp/scholarr_uploads/scholar_images
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Setup uv
uses: astral-sh/setup-uv@v4
- name: Install dependencies
run: uv sync --extra dev
- name: Migration smoke
run: uv run alembic upgrade head
- name: Unit tests
run: uv run pytest tests/unit
- name: Integration tests
run: uv run pytest -m integration
frontend-quality:
runs-on: ubuntu-latest
needs:
- repo-hygiene
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: API contract drift check
run: python3 scripts/check_frontend_api_contract.py
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install frontend dependencies
working-directory: frontend
run: npm ci
- name: Frontend theme token policy
working-directory: frontend
run: npm run check:theme-tokens
- name: Frontend typecheck
working-directory: frontend
run: npm run typecheck
- name: Frontend unit tests
working-directory: frontend
run: npm run test:run
- name: Frontend build
working-directory: frontend
run: npm run build
docs-quality:
runs-on: ubuntu-latest
needs:
- repo-hygiene
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install docs dependencies
run: npm ci --prefix docs/website
- name: Docs build
run: npm --prefix docs/website run build
docker-publish:
runs-on: ubuntu-latest
needs:
- repo-hygiene
- test
- frontend-quality
- docs-quality
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Derive image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: justinzeus/scholarr
tags: |
type=raw,value=latest
type=sha,format=short,prefix=sha-
- name: Build and push multi-arch image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
target: prod
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

26
ref/.github/workflows/codeql.yml vendored Normal file
View file

@ -0,0 +1,26 @@
name: CodeQL
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: "30 5 * * 1"
jobs:
analyze:
runs-on: ubuntu-latest
permissions:
security-events: write
contents: read
strategy:
matrix:
language: [python, javascript]
steps:
- uses: actions/checkout@v4
- uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
- uses: github/codeql-action/autobuild@v3
- uses: github/codeql-action/analyze@v3

57
ref/.github/workflows/docs-pages.yml vendored Normal file
View file

@ -0,0 +1,57 @@
name: Docs Pages
on:
workflow_dispatch:
push:
branches:
- main
paths:
- docs/**
- .github/workflows/docs-pages.yml
permissions:
contents: read
concurrency:
group: docs-pages
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install docs dependencies
run: npm ci --prefix docs/website
- name: Build docs site
run: npm --prefix docs/website run build
- name: Upload pages artifact
uses: actions/upload-pages-artifact@v3
with:
path: docs/website/build
deploy:
needs: build
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

32
ref/.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,32 @@
name: Release
on:
push:
branches: [main]
jobs:
release:
runs-on: ubuntu-latest
if: github.repository == 'JustinZeus/scholarr'
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- uses: astral-sh/setup-uv@v4
- run: uv sync --extra dev
- name: Lint
run: uv run ruff check
- name: Test
run: uv run pytest
- name: Semantic Release
id: release
run: uv run semantic-release publish
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -0,0 +1,54 @@
name: Scheduled Probes
on:
workflow_dispatch:
schedule:
- cron: "20 6 * * *"
jobs:
fixture-probes:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_DB: scholar
POSTGRES_USER: scholar
POSTGRES_PASSWORD: scholar
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U scholar -d scholar"
--health-interval 5s
--health-timeout 5s
--health-retries 20
env:
DATABASE_URL: postgresql+asyncpg://scholar:scholar@localhost:5432/scholar
SCHOLAR_IMAGE_UPLOAD_DIR: /tmp/scholarr_uploads/scholar_images
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Setup uv
uses: astral-sh/setup-uv@v4
- name: Install dependencies
run: uv sync --extra dev
- name: Fixture-backed parser unit probes
run: >-
uv run pytest
tests/unit/test_scholar_parser.py
tests/unit/test_scholar_search_safety.py
- name: Fixture-backed integration probe
run: >-
uv run pytest -m integration
tests/integration/test_fixture_probe_runs.py

67
ref/Dockerfile Normal file
View file

@ -0,0 +1,67 @@
FROM node:20-alpine AS frontend-builder
WORKDIR /frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
FROM python:3.12-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
UV_LINK_MODE=copy \
UV_PROJECT_ENVIRONMENT=/opt/venv \
PATH="/opt/venv/bin:$PATH"
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/astral-sh/uv:0.6.5 /uv /uvx /bin/
COPY pyproject.toml uv.lock README.md ./
COPY app ./app
COPY alembic.ini ./alembic.ini
COPY alembic ./alembic
COPY scripts ./scripts
RUN uv sync --frozen --extra dev
FROM base AS dev
ENTRYPOINT ["/bin/sh", "/app/scripts/entrypoint.sh"]
FROM python:3.12-slim AS prod
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
UV_LINK_MODE=copy \
UV_PROJECT_ENVIRONMENT=/opt/venv \
PATH="/opt/venv/bin:$PATH" \
APP_RELOAD=0 \
UVICORN_WORKERS=1 \
FRONTEND_ENABLED=1 \
FRONTEND_DIST_DIR=/app/frontend/dist
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/astral-sh/uv:0.6.5 /uv /uvx /bin/
COPY pyproject.toml uv.lock README.md ./
COPY app ./app
COPY alembic.ini ./alembic.ini
COPY alembic ./alembic
RUN uv sync --frozen
COPY scripts ./scripts
COPY --from=frontend-builder /frontend/dist /app/frontend/dist
ENTRYPOINT ["/bin/sh", "/app/scripts/entrypoint.sh"]

149
ref/README.md Normal file
View file

@ -0,0 +1,149 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset=".github/logo-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="frontend/public/scholar_logo.png" />
<img src="frontend/public/scholar_logo.png" alt="Scholarr" width="120" />
</picture>
# Scholarr
**Self-hosted academic publication tracker.**
Track Google Scholar profiles, discover new papers automatically,
resolve open-access PDFs, and stay on top of the literature you care about.
[![CI](https://img.shields.io/github/actions/workflow/status/justinzeus/scholarr/ci.yml?style=for-the-badge)](https://github.com/JustinZeus/scholarr/actions/workflows/ci.yml)
[![CodeQL](https://img.shields.io/github/actions/workflow/status/justinzeus/scholarr/codeql.yml?style=for-the-badge&label=CodeQL)](https://github.com/JustinZeus/scholarr/actions/workflows/codeql.yml)
[![Release](https://img.shields.io/github/v/release/justinzeus/scholarr?style=for-the-badge)](https://github.com/JustinZeus/scholarr/releases)
[![Docker Pulls](https://img.shields.io/docker/pulls/justinzeus/scholarr?style=for-the-badge&logo=docker)](https://hub.docker.com/r/justinzeus/scholarr)
[![Python](https://img.shields.io/badge/python-3.12+-3776AB?style=for-the-badge&logo=python&logoColor=white)](https://www.python.org/)
[![License](https://img.shields.io/github/license/justinzeus/scholarr?style=for-the-badge)](LICENSE)
[![Docs](https://img.shields.io/badge/docs-scholarr-2e8555?style=for-the-badge)](https://justinzeus.github.io/scholarr/)
</div>
---
<!-- TODO: Replace these placeholders with actual screenshots.
Drop PNG/JPEG files into docs/assets/ and update the paths below.
Recommended screenshots:
1. Dashboard / publications list (light or dark mode)
2. Scholar profile view
3. Run detail with progress
Ideal dimensions: 1200-1400px wide, PNG or WebP.
-->
<p align="center">
<img src="docs/assets/screenshot-dashboard.png" alt="Publications dashboard" width="720" />
</p>
<!--
<p align="center">
<img src="docs/assets/screenshot-scholars.png" alt="Scholar profiles" width="360" />
<img src="docs/assets/screenshot-run.png" alt="Ingestion run" width="360" />
</p>
-->
## Why Scholarr?
Most researchers track new papers by manually checking Google Scholar, setting up email alerts, or juggling RSS feeds. Scholarr replaces all of that with a single self-hosted service:
- **Add scholars once** -- by profile URL, Scholar ID, or name search
- **Publications appear automatically** -- a background scheduler scrapes profiles on a configurable interval
- **Open-access PDFs are resolved for you** -- Unpaywall and arXiv are queried automatically when a DOI is found
- **Everything is deduplicated** -- publications are global records; no duplicates across scholars
- **Your data stays yours** -- fully self-hosted, export/import your entire library at any time
## Features
| | |
|---|---|
| **Automated Ingestion** | Background scheduler with configurable intervals, continuation queue, and multi-page pagination |
| **Identifier Resolution** | Cross-references arXiv, Crossref, and OpenAlex to gather DOIs, arXiv IDs, PMIDs |
| **PDF Discovery** | Resolves open-access PDFs via Unpaywall API and arXiv, with automatic retry queue |
| **Scrape Safety** | Rate limiting, cooldowns, and backoff strategies that prevent IP bans -- these are safety floors, not optional |
| **Multi-User** | Session-based auth, admin user management, user-scoped scholar tracking |
| **Theming** | 7 color presets with light/dark mode, tokenized component system |
| **Import / Export** | Portable scholar data with full publication and read-state preservation |
| **Single Container** | FastAPI backend + Vue 3 frontend ship as one Docker image |
## Quick Start
```bash
# 1. Clone and configure
git clone https://github.com/JustinZeus/scholarr.git
cd scholarr
cp .env.example .env
# 2. Set required secrets in .env
# POSTGRES_PASSWORD=<secure-password>
# SESSION_SECRET_KEY=<random-32-char-string>
# 3. Start
docker compose up -d
# 4. Open http://localhost:8000
```
To bootstrap an admin account on first run, add to `.env`:
```bash
BOOTSTRAP_ADMIN_ON_START=1
BOOTSTRAP_ADMIN_EMAIL=admin@example.com
BOOTSTRAP_ADMIN_PASSWORD=<secure-password>
```
## How It Works
```mermaid
graph LR
UI[Vue 3 Dashboard] <-->|REST + SSE| API[FastAPI]
API --> Scheduler[Scheduler]
Scheduler -->|Scrape HTML| Scholar[Google Scholar]
Scholar -->|Parse & Deduplicate| DB[(PostgreSQL)]
Scholar -.->|Identify| Ext[arXiv / Crossref / OpenAlex]
Ext --> DB
DB -->|DOIs| PDF[PDF Resolution]
PDF -->|Unpaywall / arXiv| DB
API <--> DB
```
## Tech Stack
| Layer | Technology |
|-------|------------|
| Backend | Python 3.12, FastAPI, SQLAlchemy 2.0 (async), Alembic |
| Frontend | TypeScript, Vue 3, Vite, Tailwind CSS |
| Database | PostgreSQL 15 |
| Infrastructure | Multi-stage Docker, Docker Compose |
## Documentation
Full documentation: **[justinzeus.github.io/scholarr](https://justinzeus.github.io/scholarr/)**
| Section | Covers |
|---------|--------|
| [User Guide](docs/user/overview.md) | Installation, configuration, all environment variables |
| [Developer Guide](docs/developer/overview.md) | Architecture, local dev, contributing, testing |
| [Operations](docs/operations/overview.md) | Deployment, database runbook, scrape safety |
| [API Reference](docs/reference/api.md) | Envelope spec, all endpoints, DTO contracts |
## Contributing
Scholarr uses [conventional commits](https://www.conventionalcommits.org/) and [semantic versioning](https://semver.org/). See the [contributing guide](docs/developer/contributing.md) for PR process and code standards.
```bash
# Dev environment
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
# Run tests (always in containers)
docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app \
python -m pytest
```
## License
See [LICENSE](LICENSE) for details.

8
scripts/entrypoint.sh Executable file
View file

@ -0,0 +1,8 @@
#!/bin/sh
set -e
exec uvicorn app.main:app \
--host 0.0.0.0 \
--port "${PORT:-8000}" \
--app-dir backend \
"$@"

749
uv.lock generated Normal file
View file

@ -0,0 +1,749 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
[[package]]
name = "certifi"
version = "2026.2.25"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
]
[[package]]
name = "click"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "fastapi"
version = "0.135.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e7/7b/f8e0211e9380f7195ba3f3d40c292594fd81ba8ec4629e3854c353aaca45/fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd", size = 394962, upload-time = "2026-03-01T18:18:29.369Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999, upload-time = "2026-03-01T18:18:30.831Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "handin-website"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "fastapi" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "python-multipart" },
{ name = "uvicorn", extra = ["standard"] },
]
[package.dev-dependencies]
dev = [
{ name = "httpx" },
{ name = "mypy" },
{ name = "pytest" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "fastapi", specifier = "==0.135.1" },
{ name = "pydantic", specifier = "==2.12.5" },
{ name = "pydantic-settings", specifier = "==2.13.1" },
{ name = "python-multipart", specifier = "==0.0.22" },
{ name = "uvicorn", extras = ["standard"], specifier = "==0.41.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "httpx", specifier = "==0.28.1" },
{ name = "mypy", specifier = "==1.19.1" },
{ name = "pytest", specifier = "==9.0.2" },
{ name = "ruff", specifier = "==0.15.4" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httptools"
version = "0.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" },
{ url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" },
{ url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" },
{ url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" },
{ url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" },
{ url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" },
{ url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" },
{ url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" },
{ url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" },
{ url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" },
{ url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" },
{ url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" },
{ url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" },
{ url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" },
{ url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" },
{ url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" },
{ url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" },
{ url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" },
{ url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" },
{ url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" },
{ url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "librt"
version = "0.8.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" },
{ url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" },
{ url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" },
{ url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" },
{ url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" },
{ url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" },
{ url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" },
{ url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" },
{ url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" },
{ url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" },
{ url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" },
{ url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" },
{ url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" },
{ url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" },
{ url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" },
{ url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" },
{ url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" },
{ url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" },
{ url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" },
{ url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" },
{ url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" },
{ url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" },
{ url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" },
{ url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" },
{ url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" },
{ url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" },
{ url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" },
{ url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" },
{ url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" },
{ url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" },
{ url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" },
{ url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" },
{ url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" },
{ url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" },
{ url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" },
{ url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" },
{ url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" },
{ url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" },
{ url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" },
{ url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" },
{ url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" },
{ url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" },
{ url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" },
{ url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" },
{ url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" },
{ url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" },
{ url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" },
{ url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" },
{ url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" },
{ url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" },
{ url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" },
{ url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" },
]
[[package]]
name = "mypy"
version = "1.19.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
{ name = "mypy-extensions" },
{ name = "pathspec" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" },
{ url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" },
{ url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" },
{ url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" },
{ url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" },
{ url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" },
{ url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" },
{ url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" },
{ url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" },
{ url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" },
{ url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" },
{ url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" },
{ url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" },
{ url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" },
{ url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" },
{ url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" },
{ url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" },
{ url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" },
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "pathspec"
version = "1.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pydantic"
version = "2.12.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
]
[[package]]
name = "pydantic-core"
version = "2.41.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
{ url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
{ url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
{ url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
{ url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
{ url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
{ url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
{ url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
{ url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
{ url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
{ url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
{ url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
{ url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
{ url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
{ url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
]
[[package]]
name = "pydantic-settings"
version = "2.13.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "python-multipart"
version = "0.0.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "ruff"
version = "0.15.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/da/31/d6e536cdebb6568ae75a7f00e4b4819ae0ad2640c3604c305a0428680b0c/ruff-0.15.4.tar.gz", hash = "sha256:3412195319e42d634470cc97aa9803d07e9d5c9223b99bcb1518f0c725f26ae1", size = 4569550, upload-time = "2026-02-26T20:04:14.959Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f2/82/c11a03cfec3a4d26a0ea1e571f0f44be5993b923f905eeddfc397c13d360/ruff-0.15.4-py3-none-linux_armv6l.whl", hash = "sha256:a1810931c41606c686bae8b5b9a8072adac2f611bb433c0ba476acba17a332e0", size = 10453333, upload-time = "2026-02-26T20:04:20.093Z" },
{ url = "https://files.pythonhosted.org/packages/ce/5d/6a1f271f6e31dffb31855996493641edc3eef8077b883eaf007a2f1c2976/ruff-0.15.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5a1632c66672b8b4d3e1d1782859e98d6e0b4e70829530666644286600a33992", size = 10853356, upload-time = "2026-02-26T20:04:05.808Z" },
{ url = "https://files.pythonhosted.org/packages/b1/d8/0fab9f8842b83b1a9c2bf81b85063f65e93fb512e60effa95b0be49bfc54/ruff-0.15.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4386ba2cd6c0f4ff75252845906acc7c7c8e1ac567b7bc3d373686ac8c222ba", size = 10187434, upload-time = "2026-02-26T20:03:54.656Z" },
{ url = "https://files.pythonhosted.org/packages/85/cc/cc220fd9394eff5db8d94dec199eec56dd6c9f3651d8869d024867a91030/ruff-0.15.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2496488bdfd3732747558b6f95ae427ff066d1fcd054daf75f5a50674411e75", size = 10535456, upload-time = "2026-02-26T20:03:52.738Z" },
{ url = "https://files.pythonhosted.org/packages/fa/0f/bced38fa5cf24373ec767713c8e4cadc90247f3863605fb030e597878661/ruff-0.15.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f1c4893841ff2d54cbda1b2860fa3260173df5ddd7b95d370186f8a5e66a4ac", size = 10287772, upload-time = "2026-02-26T20:04:08.138Z" },
{ url = "https://files.pythonhosted.org/packages/2b/90/58a1802d84fed15f8f281925b21ab3cecd813bde52a8ca033a4de8ab0e7a/ruff-0.15.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:820b8766bd65503b6c30aaa6331e8ef3a6e564f7999c844e9a547c40179e440a", size = 11049051, upload-time = "2026-02-26T20:04:03.53Z" },
{ url = "https://files.pythonhosted.org/packages/d2/ac/b7ad36703c35f3866584564dc15f12f91cb1a26a897dc2fd13d7cb3ae1af/ruff-0.15.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9fb74bab47139c1751f900f857fa503987253c3ef89129b24ed375e72873e85", size = 11890494, upload-time = "2026-02-26T20:04:10.497Z" },
{ url = "https://files.pythonhosted.org/packages/93/3d/3eb2f47a39a8b0da99faf9c54d3eb24720add1e886a5309d4d1be73a6380/ruff-0.15.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f80c98765949c518142b3a50a5db89343aa90f2c2bf7799de9986498ae6176db", size = 11326221, upload-time = "2026-02-26T20:04:12.84Z" },
{ url = "https://files.pythonhosted.org/packages/ff/90/bf134f4c1e5243e62690e09d63c55df948a74084c8ac3e48a88468314da6/ruff-0.15.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451a2e224151729b3b6c9ffb36aed9091b2996fe4bdbd11f47e27d8f2e8888ec", size = 11168459, upload-time = "2026-02-26T20:04:00.969Z" },
{ url = "https://files.pythonhosted.org/packages/b5/e5/a64d27688789b06b5d55162aafc32059bb8c989c61a5139a36e1368285eb/ruff-0.15.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8f157f2e583c513c4f5f896163a93198297371f34c04220daf40d133fdd4f7f", size = 11104366, upload-time = "2026-02-26T20:03:48.099Z" },
{ url = "https://files.pythonhosted.org/packages/f1/f6/32d1dcb66a2559763fc3027bdd65836cad9eb09d90f2ed6a63d8e9252b02/ruff-0.15.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:917cc68503357021f541e69b35361c99387cdbbf99bd0ea4aa6f28ca99ff5338", size = 10510887, upload-time = "2026-02-26T20:03:45.771Z" },
{ url = "https://files.pythonhosted.org/packages/ff/92/22d1ced50971c5b6433aed166fcef8c9343f567a94cf2b9d9089f6aa80fe/ruff-0.15.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e9737c8161da79fd7cfec19f1e35620375bd8b2a50c3e77fa3d2c16f574105cc", size = 10285939, upload-time = "2026-02-26T20:04:22.42Z" },
{ url = "https://files.pythonhosted.org/packages/e6/f4/7c20aec3143837641a02509a4668fb146a642fd1211846634edc17eb5563/ruff-0.15.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:291258c917539e18f6ba40482fe31d6f5ac023994ee11d7bdafd716f2aab8a68", size = 10765471, upload-time = "2026-02-26T20:03:58.924Z" },
{ url = "https://files.pythonhosted.org/packages/d0/09/6d2f7586f09a16120aebdff8f64d962d7c4348313c77ebb29c566cefc357/ruff-0.15.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3f83c45911da6f2cd5936c436cf86b9f09f09165f033a99dcf7477e34041cbc3", size = 11263382, upload-time = "2026-02-26T20:04:24.424Z" },
{ url = "https://files.pythonhosted.org/packages/1b/fa/2ef715a1cd329ef47c1a050e10dee91a9054b7ce2fcfdd6a06d139afb7ec/ruff-0.15.4-py3-none-win32.whl", hash = "sha256:65594a2d557d4ee9f02834fcdf0a28daa8b3b9f6cb2cb93846025a36db47ef22", size = 10506664, upload-time = "2026-02-26T20:03:50.56Z" },
{ url = "https://files.pythonhosted.org/packages/d0/a8/c688ef7e29983976820d18710f955751d9f4d4eb69df658af3d006e2ba3e/ruff-0.15.4-py3-none-win_amd64.whl", hash = "sha256:04196ad44f0df220c2ece5b0e959c2f37c777375ec744397d21d15b50a75264f", size = 11651048, upload-time = "2026-02-26T20:04:17.191Z" },
{ url = "https://files.pythonhosted.org/packages/3e/0a/9e1be9035b37448ce2e68c978f0591da94389ade5a5abafa4cf99985d1b2/ruff-0.15.4-py3-none-win_arm64.whl", hash = "sha256:60d5177e8cfc70e51b9c5fad936c634872a74209f934c1e79107d11787ad5453", size = 10966776, upload-time = "2026-02-26T20:03:56.908Z" },
]
[[package]]
name = "starlette"
version = "0.52.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "uvicorn"
version = "0.41.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" },
]
[package.optional-dependencies]
standard = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "httptools" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
{ name = "watchfiles" },
{ name = "websockets" },
]
[[package]]
name = "uvloop"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" },
{ url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" },
{ url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" },
{ url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" },
{ url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" },
{ url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
]
[[package]]
name = "watchfiles"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" },
{ url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" },
{ url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" },
{ url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" },
{ url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" },
{ url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" },
{ url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" },
{ url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" },
{ url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" },
{ url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" },
{ url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" },
{ url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" },
{ url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" },
{ url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" },
{ url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" },
{ url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" },
{ url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" },
{ url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" },
{ url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" },
{ url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" },
{ url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" },
{ url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" },
{ url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" },
{ url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" },
{ url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" },
{ url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" },
{ url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" },
{ url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" },
{ url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" },
{ url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" },
{ url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" },
{ url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" },
{ url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" },
{ url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" },
{ url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" },
{ url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" },
{ url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" },
{ url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" },
{ url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" },
{ url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" },
{ url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" },
{ url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" },
{ url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" },
{ url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" },
{ url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" },
{ url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" },
{ url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" },
{ url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" },
{ url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" },
{ url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" },
{ url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" },
{ url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" },
{ url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" },
{ url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" },
{ url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" },
{ url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" },
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
]
[[package]]
name = "websockets"
version = "16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" },
{ url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" },
{ url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" },
{ url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" },
{ url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" },
{ url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" },
{ url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" },
{ url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" },
{ url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" },
{ url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" },
{ url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" },
{ url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" },
{ url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" },
{ url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" },
{ url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" },
{ url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" },
{ url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" },
{ url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" },
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
]