From 07020e5d6e3d28ca72700ca3ea034dcc0839218d Mon Sep 17 00:00:00 2001 From: Anish Sahoo Date: Mon, 19 Oct 2026 18:42:25 -0400 Subject: [PATCH 1/6] feat: add python react starter --- .agents/rules/backend.md | 38 + .agents/rules/frontend.md | 21 + .editorconfig | 12 + .env.example | 20 + .gitattributes | 12 + .github/workflows/ci.yml | 63 + .gitignore | 20 + AGENTS.md | 43 + CLAUDE.md | 1 + README.md | 150 ++ backend/.dockerignore | 4 + backend/Dockerfile | 47 + backend/alembic.ini | 43 + backend/migrations/env.py | 43 + backend/migrations/script.py.mako | 24 + .../20261019_1817_create_notes_table.py | 44 + backend/openapi.json | 97 + backend/pyproject.toml | 36 + backend/src/app/__init__.py | 0 backend/src/app/core/__init__.py | 0 backend/src/app/core/db.py | 18 + backend/src/app/core/http_client.py | 16 + backend/src/app/core/logging_config.py | 77 + backend/src/app/core/middleware.py | 64 + backend/src/app/core/settings.py | 15 + backend/src/app/export_openapi.py | 31 + backend/src/app/main.py | 44 + backend/src/app/models/__init__.py | 6 + backend/src/app/models/base.py | 5 + backend/src/app/models/note.py | 16 + backend/src/app/routes/__init__.py | 7 + backend/src/app/routes/health.py | 12 + backend/src/app/routes/notes.py | 14 + backend/src/app/schemas/__init__.py | 0 backend/src/app/schemas/health.py | 7 + backend/src/app/schemas/note.py | 11 + backend/uv.lock | 615 +++++++ compose.override.yaml | 50 + compose.yaml | 69 + frontend/.dockerignore | 2 + frontend/.oxfmtrc.json | 4 + frontend/.oxlintrc.json | 17 + frontend/Dockerfile | 35 + frontend/index.html | 12 + frontend/nginx.conf | 50 + frontend/openapi-ts.config.ts | 8 + frontend/package.json | 32 + frontend/pnpm-lock.yaml | 1607 +++++++++++++++++ frontend/src/App.tsx | 10 + frontend/src/client/client.gen.ts | 16 + frontend/src/client/client/client.gen.ts | 277 +++ frontend/src/client/client/index.ts | 27 + frontend/src/client/client/types.gen.ts | 218 +++ frontend/src/client/client/utils.gen.ts | 316 ++++ frontend/src/client/core/auth.gen.ts | 48 + .../src/client/core/bodySerializer.gen.ts | 82 + frontend/src/client/core/params.gen.ts | 178 ++ .../src/client/core/pathSerializer.gen.ts | 171 ++ .../src/client/core/queryKeySerializer.gen.ts | 117 ++ .../src/client/core/serverSentEvents.gen.ts | 242 +++ frontend/src/client/core/types.gen.ts | 110 ++ frontend/src/client/core/utils.gen.ts | 140 ++ frontend/src/client/index.ts | 4 + frontend/src/client/sdk.gen.ts | 29 + frontend/src/client/types.gen.ts | 67 + frontend/src/components/NoteList.tsx | 41 + frontend/src/main.tsx | 13 + frontend/tsconfig.app.json | 28 + frontend/tsconfig.json | 4 + frontend/tsconfig.node.json | 20 + frontend/vite.config.ts | 22 + mise.toml | 178 ++ 72 files changed, 5920 insertions(+) create mode 100644 .agents/rules/backend.md create mode 100644 .agents/rules/frontend.md create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 120000 CLAUDE.md create mode 100644 README.md create mode 100644 backend/.dockerignore create mode 100644 backend/Dockerfile create mode 100644 backend/alembic.ini create mode 100644 backend/migrations/env.py create mode 100644 backend/migrations/script.py.mako create mode 100644 backend/migrations/versions/20261019_1817_create_notes_table.py create mode 100644 backend/openapi.json create mode 100644 backend/pyproject.toml create mode 100644 backend/src/app/__init__.py create mode 100644 backend/src/app/core/__init__.py create mode 100644 backend/src/app/core/db.py create mode 100644 backend/src/app/core/http_client.py create mode 100644 backend/src/app/core/logging_config.py create mode 100644 backend/src/app/core/middleware.py create mode 100644 backend/src/app/core/settings.py create mode 100644 backend/src/app/export_openapi.py create mode 100644 backend/src/app/main.py create mode 100644 backend/src/app/models/__init__.py create mode 100644 backend/src/app/models/base.py create mode 100644 backend/src/app/models/note.py create mode 100644 backend/src/app/routes/__init__.py create mode 100644 backend/src/app/routes/health.py create mode 100644 backend/src/app/routes/notes.py create mode 100644 backend/src/app/schemas/__init__.py create mode 100644 backend/src/app/schemas/health.py create mode 100644 backend/src/app/schemas/note.py create mode 100644 backend/uv.lock create mode 100644 compose.override.yaml create mode 100644 compose.yaml create mode 100644 frontend/.dockerignore create mode 100644 frontend/.oxfmtrc.json create mode 100644 frontend/.oxlintrc.json create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/openapi-ts.config.ts create mode 100644 frontend/package.json create mode 100644 frontend/pnpm-lock.yaml create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/client/client.gen.ts create mode 100644 frontend/src/client/client/client.gen.ts create mode 100644 frontend/src/client/client/index.ts create mode 100644 frontend/src/client/client/types.gen.ts create mode 100644 frontend/src/client/client/utils.gen.ts create mode 100644 frontend/src/client/core/auth.gen.ts create mode 100644 frontend/src/client/core/bodySerializer.gen.ts create mode 100644 frontend/src/client/core/params.gen.ts create mode 100644 frontend/src/client/core/pathSerializer.gen.ts create mode 100644 frontend/src/client/core/queryKeySerializer.gen.ts create mode 100644 frontend/src/client/core/serverSentEvents.gen.ts create mode 100644 frontend/src/client/core/types.gen.ts create mode 100644 frontend/src/client/core/utils.gen.ts create mode 100644 frontend/src/client/index.ts create mode 100644 frontend/src/client/sdk.gen.ts create mode 100644 frontend/src/client/types.gen.ts create mode 100644 frontend/src/components/NoteList.tsx create mode 100644 frontend/src/main.tsx create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 mise.toml diff --git a/.agents/rules/backend.md b/.agents/rules/backend.md new file mode 100644 index 0000000..41eb9b7 --- /dev/null +++ b/.agents/rules/backend.md @@ -0,0 +1,38 @@ +--- +paths: + - "backend/**" +--- + +# Backend rules + +FastAPI, SQLAlchemy and Alembic rules for `backend/`, on top of the root +`AGENTS.md`. Copy the shape of the notes example for a new resource: +`models/note.py`, `schemas/note.py`, `routes/notes.py`. + +## Where code goes + +- `routes/`: HTTP handlers. One file per resource, added to `api_router` in + `routes/__init__.py`. Every route lives under `/api/v1`. +- `models/`: SQLAlchemy tables, one file per table. Import each one in + `models/__init__.py`, or Alembic will not see it. +- `schemas/`: pydantic request and response models, one file per resource. +- `core/`: app-wide setup (settings, database, logging, middleware, HTTP + client). Feature code does not go here. + +## Rules + +- Routes are `async def` and use `SessionDep` for the database. A blocking + call (sync DB driver, `requests`, `time.sleep`) stalls every other request, + including `/api/v1/health`. Use `HttpClientDep` to call other services. +- Every route declares a pydantic response model as its return type. Never + return a SQLAlchemy model or a bare `dict`. +- Change the schema only by editing models, then run + `mise run db:revision "what changed"`. Read the generated file before + committing it. Never edit a migration that is already on `main`. +- End every `ORDER BY` with a unique column, usually `id`, so rows come back in + the same order every time. +- Log with `logging.getLogger(__name__)`. Routine success is `debug`. Do not + log passwords, tokens or full request bodies. +- New environment variables go in `core/settings.py` and `.env.example`. +- Code must pass `mypy --strict`. Avoid `Any` and `# type: ignore`. If you need + one, add a comment saying why. diff --git a/.agents/rules/frontend.md b/.agents/rules/frontend.md new file mode 100644 index 0000000..e11892b --- /dev/null +++ b/.agents/rules/frontend.md @@ -0,0 +1,21 @@ +--- +paths: + - "frontend/**" +--- + +# Frontend rules + +React and TypeScript rules for `frontend/`, on top of the root `AGENTS.md`. + +- Call the backend only through the generated client in `src/client` + (`listNotes()` and friends). Do not hand-write `fetch` calls or response + types for the API. +- Never edit `src/client` by hand. Change the backend, then run + `mise run client:generate`. +- Use relative URLs (`/api/v1/...`). Vite forwards them locally and nginx does + the same when deployed, so the app never needs a backend hostname. +- Import from `src` with the `@/` alias: `import { App } from "@/App"`. +- No `any`, no `as` casts to silence the compiler, no non-null `!`. Fix the + type instead. +- One component per file, named after the component. +- Handle the loading and error states of every API call, not just success. diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f1ec07e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.py] +indent_size = 4 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..30113e4 --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +# Copy to .env before running anything: cp .env.example .env +# .env is gitignored. These defaults are fine for local development. + +# Postgres login and database name. Docker Compose creates the database with +# these. On Coolify, SERVICE_* variables are generated for you per environment. +SERVICE_USER_POSTGRES=app +SERVICE_PASSWORD_POSTGRES=app +POSTGRES_DB=app + +# Port Postgres is published on at localhost. Change it if something else on +# your machine already uses 5432, and change it in DATABASE_URL below too. +POSTGRES_PORT=5432 + +# Used by commands you run on your machine (mise run db:migrate, db:revision), +# which reach Postgres through the port above. The backend container builds +# its own URL from the values above. +DATABASE_URL=postgresql://app:app@localhost:5432/app + +# DEBUG, INFO, WARNING or ERROR. +LOG_LEVEL=INFO diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..231021d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +# Force LF everywhere. Files are executed inside Linux containers; +# CRLF line endings from Windows checkouts break shell scripts and config. +* text=auto eol=lf + +*.png binary +*.jpg binary +*.ico binary +*.woff2 binary + +# Generated from the backend's contract. Collapsed in GitHub diffs. +frontend/src/client/** linguist-generated=true +backend/openapi.json linguist-generated=true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e070a23 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +# Every step calls the same mise task you run locally, so `mise run check` +# passing on your machine means these pass too. +jobs: + backend_lint: + name: backend / lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: jdx/mise-action@v5 + with: + install_args: python uv ruff + - run: uv sync --locked --directory backend + - run: mise run lint:backend + - run: mise run typecheck:backend + - run: mise run contract:check + env: + # Settings require a URL to import the app. Nothing connects to it. + DATABASE_URL: postgresql://ci:ci@localhost:5432/ci + + backend_format: + name: backend / format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: jdx/mise-action@v5 + with: + install_args: ruff + - run: mise run fmt:backend + + frontend_lint: + name: frontend / lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: jdx/mise-action@v5 + with: + install_args: node pnpm + - run: pnpm install --frozen-lockfile --dir frontend + - run: mise run lint:frontend + - run: mise run typecheck:frontend + - run: mise run client:check + + frontend_format: + name: frontend / format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: jdx/mise-action@v5 + with: + install_args: node pnpm + - run: pnpm install --frozen-lockfile --dir frontend + - run: mise run fmt:frontend diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d26d92f --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +**/node_modules/ +**/dist/ +*.tsbuildinfo + +**/.venv/ +**/__pycache__/ +**/*.egg-info/ +**/.mypy_cache/ +**/.ruff_cache/ +**/.pytest_cache/ + +.env +.env.* +!.env.example + +.DS_Store +*.log +.idea/ +.vscode/* +!.vscode/extensions.json diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0eb6ef8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,43 @@ +# Working with agents + +Rules for anyone writing code in this repo, human or AI agent. Coding agents +(Claude Code, Codex, Cursor, Copilot) read this file automatically. +`CLAUDE.md` is a symlink to it. + +This is a starting point. Add a rule when you catch yourself correcting the +same mistake twice. + +Before changing `backend/`, read `.agents/rules/backend.md`. Before changing +`frontend/`, read `.agents/rules/frontend.md`. + +## Before you change anything + +- Read the code you are about to change, and the code that calls it. +- Make the smallest change that solves the problem. +- Follow the patterns already in the repo. If one looks wrong, ask before + replacing it. + +## Adding code and dependencies + +- Do not add a dependency for something the standard library or an existing + dependency already does. Say why when you add one. +- Do not add abstractions, config options or files the task does not need. +- Two similar blocks of code are fine. Extract a shared function on the third. + +## Writing code + +- Names carry the meaning. If a variable needs a comment to explain what it + holds, rename it. +- Comments explain why, not what. Delete comments that restate the code. +- Use guard clauses and early returns instead of nested `if`s. +- Validate input once, where it enters the system (request bodies, query + params). Trust it after that. +- Do not swallow errors. If you cannot handle one, let it surface. +- Delete code nothing uses, including commented-out code. + +## Finishing + +- Run `mise run check` and fix what it reports. It runs the same checks as CI. +- If you changed a route or a response schema, run `mise run client:generate` + and commit the regenerated files. +- Say what you ran and what you did not. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..76cf84c --- /dev/null +++ b/README.md @@ -0,0 +1,150 @@ +# Python React Starter + +A starting point for a web app with a React frontend, a FastAPI backend and a +Postgres database. Docker Compose runs all three. [mise](https://mise.jdx.dev) +installs the tools and runs every command. + +``` +browser ──> web (React + Vite, :5173) ──/api/v1──> backend (FastAPI, :8000) ──> postgres (:5432) +``` + +## Quickstart + +You need [Docker](https://docs.docker.com/get-docker/) and +[mise](https://mise.jdx.dev/getting-started.html). + +```sh +cp .env.example .env # local settings; the defaults work +mise install # installs the pinned Node, pnpm, Python, uv and ruff +mise run install # installs packages on your machine, for your editor and checks +mise run dev # starts postgres, backend and web with hot reload +``` + +Open http://localhost:5173 and click **Load notes**. The first migration +creates the `notes` table with a few example rows, so you'll see them right away. + +The API docs are at http://localhost:8000/api/v1/docs. + +## Layout + +``` +backend/ + src/app/ + main.py creates the FastAPI app, middleware and routers + routes/ HTTP handlers, one file per resource, all under /api/v1 + models/ SQLAlchemy tables, one file per table + schemas/ pydantic request and response models, one file per resource + core/ + settings.py environment variables + db.py async engine and the SessionDep dependency + http_client.py shared httpx client (HttpClientDep) for calling other services + logging_config.py log format (text locally, JSON when deployed) and request ids + middleware.py logs one line per request + migrations/ Alembic migrations + openapi.json the API contract, generated from the routes +frontend/ + src/App.tsx the page layout + src/components/ React components, one per file + src/client/ typed API client, generated from backend/openapi.json + nginx.conf web server for deployed environments +compose.yaml the deployed stack +compose.override.yaml local settings, merged on top of compose.yaml +mise.toml tool versions and every task +AGENTS.md rules for humans and coding agents; .agents/rules has more +``` + +## Tasks + +Run `mise tasks` for the full list. + +| Task | What it does | +| --- | --- | +| `mise run dev` | Start everything with hot reload | +| `mise run down` | Stop everything (database data is kept) | +| `mise run logs [service]` | Tail logs | +| `mise run check` | Run every check CI runs: format, lint, typecheck, and a stale-client check | +| `mise run fmt:fix` / `lint:fix` | Fix formatting and lint issues | +| `mise run client:generate` | Export the API contract and regenerate the frontend client | +| `mise run db:revision "add users"` | Generate a migration from changes to `models/` | +| `mise run db:migrate` | Apply migrations (the backend also does this on start) | +| `mise run db:rollback` | Undo the latest migration | +| `mise run db:psql` | Open a SQL shell | +| `mise run db:reset` | Delete the local database and start fresh | +| `mise run deploy:check` | Build the deployed stack and check that every container is healthy | + +## How the frontend talks to the backend + +The backend's routes and pydantic models are the contract. When you change one: + +1. `mise run client:generate` writes `backend/openapi.json` and regenerates + `frontend/src/client`. +2. The frontend calls the new function, e.g. `listNotes()`. It is fully typed, + so `tsc` catches a field the backend renamed or removed. +3. Commit both. CI fails if either one is out of date + (`mise run contract:check` and `mise run client:check`). + +The frontend always calls relative URLs like `/api/v1/notes`. Locally, Vite +forwards `/api` to the backend container. When deployed, nginx does the same. +The browser only ever sees one origin, so there is no CORS to configure. + +## Changing the database + +The models in `backend/src/app/models/` are the source of truth for the +schema. To add a table or column, or change one: + +1. Edit a model, or add a new file like `models/user.py` and import it in + `models/__init__.py`. Alembic can't see a model that isn't imported there. +2. `mise run db:revision "add "`. Alembic compares your models with the + database and writes a migration to `backend/migrations/versions/`. Read it + before committing. If nothing changed, it writes nothing. +3. `mise run db:migrate`, or restart the backend. + +## Make it yours + +Rename the project: + +- `name:` in `compose.yaml`, and the `-p python-react-starter-deploy` project + name in the `deploy:check` task in `mise.toml` +- `name` and `description` in `backend/pyproject.toml`, then run + `uv lock --directory backend` +- `title` in `backend/src/app/main.py` +- `name` in `frontend/package.json` +- `` in `frontend/index.html` and the heading in `frontend/src/App.tsx` +- this README + +Remove the notes example once you have your own feature: + +- delete `backend/src/app/routes/notes.py` and its lines in `routes/__init__.py` +- delete `models/note.py` (and its import in `models/__init__.py`) and + `schemas/note.py` +- run `mise run db:revision "drop notes"` to generate a migration that drops + the table +- run `mise run client:generate`, delete `frontend/src/components/NoteList.tsx` + and remove it from `App.tsx` + +## Deploying + +`compose.yaml` is the deployed stack, and it works as-is on platforms that read +a Compose file, such as Coolify. It builds the `deploy` stage of each +Dockerfile: + +- `backend` applies migrations, then runs uvicorn without reload, as a non-root + user, with JSON logs. +- `web` builds the frontend into static files and serves them with nginx + (`frontend/nginx.conf`). nginx forwards `/api/` to the backend, falls back to + `index.html` for client-side routes, caches `/assets/` for a year, and logs + each request as JSON. + +Run `mise run deploy:check` before your first deploy to build that stack +locally and check that it comes up healthy. + +## Troubleshooting + +- **Port already in use.** Another app is using 5173, 8000 or 5432. For + Postgres, set `POSTGRES_PORT` in `.env` and change the port in + `DATABASE_URL` to match. For the others, change the left side of the port in + `compose.override.yaml`. +- **`not set. Create your .env file`.** Run `cp .env.example .env`. +- **The frontend can't find a package you just added.** The container has its + own `node_modules`. Run `docker compose restart web`, which reinstalls on + start. The same goes for `backend` after adding a Python package. diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..6ba05fa --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,4 @@ +.venv +**/__pycache__ +.mypy_cache +.ruff_cache diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..0a8508d --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,47 @@ +# syntax=docker/dockerfile:1 + +# Python and uv versions are pinned here and in mise.toml. Keep them in sync. +FROM python:3.14.8-slim AS base +COPY --from=ghcr.io/astral-sh/uv:0.13.1 /uv /usr/local/bin/uv +WORKDIR /app +# The virtualenv lives outside /app so the local bind mount never hides it and +# never clashes with the .venv on your machine. +ENV UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_PYTHON_DOWNLOADS=never \ + UV_PROJECT_ENVIRONMENT=/opt/venv \ + PATH=/opt/venv/bin:$PATH \ + PYTHONUNBUFFERED=1 + +# --------------------------------------------------------------------------- +# local: your laptop only, selected by compose.override.yaml. Source code comes +# from the bind mount. On startup: install packages, apply migrations, then run +# the server with hot reload. +# --------------------------------------------------------------------------- +FROM base AS local +EXPOSE 8000 +CMD ["sh", "-c", "uv sync --locked && alembic upgrade head && exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload --no-access-log"] + +# --------------------------------------------------------------------------- +# build: install runtime dependencies and the app into /opt/venv +# --------------------------------------------------------------------------- +FROM base AS build +COPY pyproject.toml uv.lock ./ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked --no-dev --no-install-project +COPY src src +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked --no-dev --no-editable + +# --------------------------------------------------------------------------- +# deploy: every deployed environment, selected by compose.yaml. Applies +# pending migrations, then starts. +# --------------------------------------------------------------------------- +FROM base AS deploy +RUN useradd --system --uid 10001 --no-create-home --shell /usr/sbin/nologin app +COPY --from=build /opt/venv /opt/venv +COPY alembic.ini ./ +COPY migrations migrations +USER app +EXPOSE 8000 +CMD ["sh", "-c", "alembic upgrade head && exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --no-access-log"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..17004f1 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,43 @@ +[alembic] +script_location = %(here)s/migrations +prepend_sys_path = src +file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(slug)s +# The database URL comes from app.settings (DATABASE_URL), not from this file. + +[post_write_hooks] +hooks = ruff_format +ruff_format.type = exec +ruff_format.executable = ruff +ruff_format.options = format REVISION_SCRIPT_FILENAME + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s diff --git a/backend/migrations/env.py b/backend/migrations/env.py new file mode 100644 index 0000000..5a4ed9f --- /dev/null +++ b/backend/migrations/env.py @@ -0,0 +1,43 @@ +import asyncio +from logging.config import fileConfig + +from alembic import context +from alembic.operations.ops import MigrationScript +from alembic.runtime.migration import MigrationContext +from sqlalchemy import Connection + +from app.core.db import engine +from app.models import Base + +if context.config.config_file_name is not None: + fileConfig(context.config.config_file_name) + + +def skip_empty_revision( + _context: MigrationContext, _revision: object, directives: list[MigrationScript] +) -> None: + """Migrations come from model changes. With no changes, write nothing.""" + upgrade_ops = directives[0].upgrade_ops + if upgrade_ops is not None and upgrade_ops.is_empty(): + directives.clear() + print("No model changes found, so no migration was created.") + + +def run_migrations(connection: Connection) -> None: + # Autogenerate compares the database against every model on this metadata. + context.configure( + connection=connection, + target_metadata=Base.metadata, + process_revision_directives=skip_empty_revision, + ) + with context.begin_transaction(): + context.run_migrations() + + +async def main() -> None: + async with engine.connect() as connection: + await connection.run_sync(run_migrations) + await engine.dispose() + + +asyncio.run(main()) diff --git a/backend/migrations/script.py.mako b/backend/migrations/script.py.mako new file mode 100644 index 0000000..dc8cca5 --- /dev/null +++ b/backend/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +${imports if imports else ""} +revision: str = ${repr(up_revision)} +down_revision: str | None = ${repr(down_revision)} +branch_labels: str | Sequence[str] | None = ${repr(branch_labels)} +depends_on: str | Sequence[str] | None = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/migrations/versions/20261019_1817_create_notes_table.py b/backend/migrations/versions/20261019_1817_create_notes_table.py new file mode 100644 index 0000000..213c849 --- /dev/null +++ b/backend/migrations/versions/20261019_1817_create_notes_table.py @@ -0,0 +1,44 @@ +"""create notes table + +Revision ID: 153e5fa1eaa3 +Revises: +Create Date: 2026-10-19 18:17:01.250453 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "153e5fa1eaa3" +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + notes = op.create_table( + "notes", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("body", sa.Text(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + ) + # Example rows, so "Load notes" shows something on a fresh database. + op.bulk_insert( + notes, + [ + {"body": "Hello from Postgres!"}, + {"body": "This note was inserted by the first Alembic migration."}, + {"body": "Edit the models in backend/src/app/models to change the schema."}, + ], + ) + + +def downgrade() -> None: + op.drop_table("notes") diff --git a/backend/openapi.json b/backend/openapi.json new file mode 100644 index 0000000..8790be2 --- /dev/null +++ b/backend/openapi.json @@ -0,0 +1,97 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Starter API", + "version": "0.1.0" + }, + "paths": { + "/api/v1/health": { + "get": { + "tags": [ + "health" + ], + "summary": "Get Health", + "operationId": "get_health", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthResponse" + } + } + } + } + } + } + }, + "/api/v1/notes": { + "get": { + "tags": [ + "notes" + ], + "summary": "List Notes", + "operationId": "list_notes", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/NoteResponse" + }, + "type": "array", + "title": "Response List Notes" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "HealthResponse": { + "properties": { + "status": { + "type": "string", + "const": "ok", + "title": "Status" + } + }, + "type": "object", + "required": [ + "status" + ], + "title": "HealthResponse" + }, + "NoteResponse": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "body": { + "type": "string", + "title": "Body" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + } + }, + "type": "object", + "required": [ + "id", + "body", + "created_at" + ], + "title": "NoteResponse" + } + } + } +} diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..bd3aba6 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "app" +version = "0.1.0" +description = "FastAPI backend for the starter" +requires-python = ">=3.14,<3.15" +dependencies = [ + "alembic==1.20.0", + "fastapi==0.149.0", + "httpx==0.28.1", + "psycopg[binary]==3.3.6", + "pydantic==2.14.1", + "pydantic-settings==2.16.0", + "sqlalchemy[asyncio]==2.1.5", + "uvicorn==0.54.0", +] + +[dependency-groups] +dev = ["mypy==2.4.0"] + +[build-system] +requires = ["hatchling==1.32.4"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/app"] + +[tool.ruff] +src = ["src"] + +[tool.ruff.lint] +extend-select = ["I", "UP", "B"] + +[tool.mypy] +strict = true +files = ["src", "migrations"] +plugins = ["pydantic.mypy"] diff --git a/backend/src/app/__init__.py b/backend/src/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/app/core/__init__.py b/backend/src/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/app/core/db.py b/backend/src/app/core/db.py new file mode 100644 index 0000000..2b03556 --- /dev/null +++ b/backend/src/app/core/db.py @@ -0,0 +1,18 @@ +from collections.abc import AsyncIterator +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.core.settings import settings + +engine = create_async_engine(settings.database_url) +SessionLocal = async_sessionmaker(engine, expire_on_commit=False) + + +async def get_session() -> AsyncIterator[AsyncSession]: + async with SessionLocal() as session: + yield session + + +SessionDep = Annotated[AsyncSession, Depends(get_session)] diff --git a/backend/src/app/core/http_client.py b/backend/src/app/core/http_client.py new file mode 100644 index 0000000..cfe15d4 --- /dev/null +++ b/backend/src/app/core/http_client.py @@ -0,0 +1,16 @@ +from typing import Annotated + +import httpx +from fastapi import Depends, Request + + +def get_http_client(request: Request) -> httpx.AsyncClient: + """The app-wide client opened in main.lifespan. Reusing one client keeps + connections pooled across requests.""" + client: httpx.AsyncClient = request.app.state.http_client + return client + + +# Add `client: HttpClientDep` to a route to call another service: +# response = await client.get("https://example.com/api") +HttpClientDep = Annotated[httpx.AsyncClient, Depends(get_http_client)] diff --git a/backend/src/app/core/logging_config.py b/backend/src/app/core/logging_config.py new file mode 100644 index 0000000..87a82a2 --- /dev/null +++ b/backend/src/app/core/logging_config.py @@ -0,0 +1,77 @@ +import json +import logging +import logging.config +from contextvars import ContextVar +from datetime import UTC, datetime +from typing import Literal + +# Set per request by RequestLoggingMiddleware, so every log line written while +# handling a request (from any module) carries the same id. +request_id_var: ContextVar[str] = ContextVar("request_id", default="-") + +# Attributes every LogRecord has. Anything else was passed with `extra=`. +# uvicorn adds color_message, an ANSI-colored copy of the message. +_STANDARD_ATTRS = set(vars(logging.makeLogRecord({}))) | { + "message", + "request_id", + "color_message", +} + + +class RequestIdFilter(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + record.request_id = request_id_var.get() + return True + + +class JsonFormatter(logging.Formatter): + """One JSON object per line, including any `extra=` fields.""" + + def format(self, record: logging.LogRecord) -> str: + entry: dict[str, object] = { + "time": datetime.fromtimestamp(record.created, UTC).isoformat(), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + "request_id": getattr(record, "request_id", "-"), + } + for key, value in vars(record).items(): + if key not in _STANDARD_ATTRS: + entry[key] = value + if record.exc_info: + entry["exception"] = self.formatException(record.exc_info) + return json.dumps(entry, default=str) + + +def configure_logging(level: str, log_format: Literal["text", "json"]) -> None: + """Send app and uvicorn logs through one handler on stdout.""" + logging.config.dictConfig( + { + "version": 1, + "disable_existing_loggers": False, + "filters": {"request_id": {"()": RequestIdFilter}}, + "formatters": { + "text": { + "format": "%(asctime)s %(levelname)-7s [%(request_id)s] " + "%(name)s: %(message)s" + }, + "json": {"()": JsonFormatter}, + }, + "handlers": { + "stdout": { + "class": "logging.StreamHandler", + "stream": "ext://sys.stdout", + "formatter": log_format, + "filters": ["request_id"], + } + }, + "root": {"level": level, "handlers": ["stdout"]}, + "loggers": { + # Re-route uvicorn's own loggers to the root handler above. + "uvicorn": {"handlers": [], "propagate": True}, + "uvicorn.error": {"handlers": [], "propagate": True}, + # RequestLoggingMiddleware replaces uvicorn's access log. + "uvicorn.access": {"handlers": [], "propagate": False}, + }, + } + ) diff --git a/backend/src/app/core/middleware.py b/backend/src/app/core/middleware.py new file mode 100644 index 0000000..a74edb6 --- /dev/null +++ b/backend/src/app/core/middleware.py @@ -0,0 +1,64 @@ +import logging +import time +import uuid + +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +from app.core.logging_config import request_id_var + +logger = logging.getLogger("app.requests") + +# Docker polls this every few seconds; logging it would bury real requests. +QUIET_PATHS = {"/api/v1/health"} + + +class RequestLoggingMiddleware: + """Give each request an id and log one line when it finishes. + + Written as plain ASGI middleware rather than `@app.middleware("http")`, + which wraps responses in a way that breaks streaming and context variables. + """ + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + request_id = uuid.uuid4().hex + request_id_var.set(request_id) + start = time.perf_counter() + status = 500 + + async def send_with_request_id(message: Message) -> None: + nonlocal status + if message["type"] == "http.response.start": + status = message["status"] + message.setdefault("headers", []).append( + (b"x-request-id", request_id.encode()) + ) + await send(message) + + try: + await self.app(scope, receive, send_with_request_id) + finally: + # On an unhandled error, status stays 500 and uvicorn logs the + # traceback right after this line, with the same request id. + if scope["path"] not in QUIET_PATHS: + duration_ms = round((time.perf_counter() - start) * 1000, 1) + logger.log( + logging.ERROR if status >= 500 else logging.INFO, + "%s %s %d %.1fms", + scope["method"], + scope["path"], + status, + duration_ms, + extra={ + "method": scope["method"], + "path": scope["path"], + "status": status, + "duration_ms": duration_ms, + }, + ) diff --git a/backend/src/app/core/settings.py b/backend/src/app/core/settings.py new file mode 100644 index 0000000..67ddffb --- /dev/null +++ b/backend/src/app/core/settings.py @@ -0,0 +1,15 @@ +from typing import Literal + +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + """Read from environment variables, e.g. DATABASE_URL and LOG_LEVEL.""" + + database_url: str + log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO" + # "json" for deployed environments, so a log service can parse each line. + log_format: Literal["text", "json"] = "text" + + +settings = Settings() diff --git a/backend/src/app/export_openapi.py b/backend/src/app/export_openapi.py new file mode 100644 index 0000000..088f941 --- /dev/null +++ b/backend/src/app/export_openapi.py @@ -0,0 +1,31 @@ +"""Write the OpenAPI contract to backend/openapi.json. + +The frontend generates its API client from that file, so run +`mise run client:generate` after changing a route or schema. With `--check`, +fail instead of writing if the file is out of date (used by CI). +""" + +import json +import sys +from pathlib import Path + +from app.main import app + +OUTPUT = Path(__file__).resolve().parents[2] / "openapi.json" + + +def main() -> None: + contract = json.dumps(app.openapi(), indent=2) + "\n" + + if "--check" not in sys.argv: + OUTPUT.write_text(contract) + print(f"wrote {OUTPUT}") + return + + if not OUTPUT.exists() or OUTPUT.read_text() != contract: + sys.exit("backend/openapi.json is out of date. Run: mise run client:generate") + print("backend/openapi.json is up to date") + + +if __name__ == "__main__": + main() diff --git a/backend/src/app/main.py b/backend/src/app/main.py new file mode 100644 index 0000000..232994a --- /dev/null +++ b/backend/src/app/main.py @@ -0,0 +1,44 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +import httpx +from fastapi import FastAPI +from fastapi.routing import APIRoute + +from app.core.db import engine +from app.core.logging_config import configure_logging +from app.core.middleware import RequestLoggingMiddleware +from app.core.settings import settings +from app.routes import api_router + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + async with httpx.AsyncClient(timeout=10) as http_client: + app.state.http_client = http_client + yield + await engine.dispose() + + +def operation_id(route: APIRoute) -> str: + """Use the handler's name as the OpenAPI operationId, so the generated + frontend client gets `listNotes()` instead of `listNotesApiV1NotesGet()`.""" + return route.name + + +def create_app() -> FastAPI: + configure_logging(settings.log_level, settings.log_format) + + app = FastAPI( + title="Starter API", + lifespan=lifespan, + docs_url="/api/v1/docs", + openapi_url="/api/v1/openapi.json", + generate_unique_id_function=operation_id, + ) + app.add_middleware(RequestLoggingMiddleware) + app.include_router(api_router) + return app + + +app = create_app() diff --git a/backend/src/app/models/__init__.py b/backend/src/app/models/__init__.py new file mode 100644 index 0000000..696307e --- /dev/null +++ b/backend/src/app/models/__init__.py @@ -0,0 +1,6 @@ +# Import every model here. Alembic only sees tables whose model has been +# imported, so a model missing from this list gets no migration. +from app.models.base import Base +from app.models.note import Note + +__all__ = ["Base", "Note"] diff --git a/backend/src/app/models/base.py b/backend/src/app/models/base.py new file mode 100644 index 0000000..fa2b68a --- /dev/null +++ b/backend/src/app/models/base.py @@ -0,0 +1,5 @@ +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass diff --git a/backend/src/app/models/note.py b/backend/src/app/models/note.py new file mode 100644 index 0000000..0f1097d --- /dev/null +++ b/backend/src/app/models/note.py @@ -0,0 +1,16 @@ +from datetime import datetime + +from sqlalchemy import DateTime, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base + + +class Note(Base): + __tablename__ = "notes" + + id: Mapped[int] = mapped_column(primary_key=True) + body: Mapped[str] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) diff --git a/backend/src/app/routes/__init__.py b/backend/src/app/routes/__init__.py new file mode 100644 index 0000000..a5df793 --- /dev/null +++ b/backend/src/app/routes/__init__.py @@ -0,0 +1,7 @@ +from fastapi import APIRouter + +from app.routes import health, notes + +api_router = APIRouter(prefix="/api/v1") +api_router.include_router(health.router) +api_router.include_router(notes.router) diff --git a/backend/src/app/routes/health.py b/backend/src/app/routes/health.py new file mode 100644 index 0000000..e815392 --- /dev/null +++ b/backend/src/app/routes/health.py @@ -0,0 +1,12 @@ +from fastapi import APIRouter + +from app.schemas.health import HealthResponse + +router = APIRouter(tags=["health"]) + + +# Async and DB-free on purpose: it runs on the event loop, so it answers right +# away even while other requests are waiting on Postgres. +@router.get("/health") +async def get_health() -> HealthResponse: + return HealthResponse(status="ok") diff --git a/backend/src/app/routes/notes.py b/backend/src/app/routes/notes.py new file mode 100644 index 0000000..60fd6ee --- /dev/null +++ b/backend/src/app/routes/notes.py @@ -0,0 +1,14 @@ +from fastapi import APIRouter +from sqlalchemy import select + +from app.core.db import SessionDep +from app.models import Note +from app.schemas.note import NoteResponse + +router = APIRouter(prefix="/notes", tags=["notes"]) + + +@router.get("") +async def list_notes(session: SessionDep) -> list[NoteResponse]: + notes = await session.scalars(select(Note).order_by(Note.created_at, Note.id)) + return [NoteResponse.model_validate(note) for note in notes] diff --git a/backend/src/app/schemas/__init__.py b/backend/src/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/app/schemas/health.py b/backend/src/app/schemas/health.py new file mode 100644 index 0000000..9b69dec --- /dev/null +++ b/backend/src/app/schemas/health.py @@ -0,0 +1,7 @@ +from typing import Literal + +from pydantic import BaseModel + + +class HealthResponse(BaseModel): + status: Literal["ok"] diff --git a/backend/src/app/schemas/note.py b/backend/src/app/schemas/note.py new file mode 100644 index 0000000..652678f --- /dev/null +++ b/backend/src/app/schemas/note.py @@ -0,0 +1,11 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class NoteResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + body: str + created_at: datetime diff --git a/backend/uv.lock b/backend/uv.lock new file mode 100644 index 0000000..e4f4130 --- /dev/null +++ b/backend/uv.lock @@ -0,0 +1,615 @@ +version = 1 +revision = 5 +requires-python = "==3.14.*" + +[[package]] +name = "alembic" +version = "1.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/aa/02910bdb8e2f1444f6654d5b296cd827d126f82209050ee7b1000f92ac4b/alembic-1.20.0.tar.gz", hash = "sha256:db505480647bc60386c5369402f4a57a506b7539c9e9ef5e270d45cbbe4939bf", size = 2093272, upload-time = "2026-09-11T19:09:11.126Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/27/78a89b55b0904d222183164e079b4ca56208e94eff1d35ad1f1ad5be9b06/alembic-1.20.0-py3-none-any.whl", hash = "sha256:77eb101048d95f982c0353e9233404889dcd7a6fc244c107836c0e2fc9cf7d9d", size = 268719, upload-time = "2026-09-11T19:09:12.88Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "app" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "alembic" }, + { name = "fastapi" }, + { name = "httpx" }, + { name = "psycopg", extra = ["binary"] }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "sqlalchemy", extra = ["asyncio"] }, + { name = "uvicorn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, +] + +[package.metadata] +requires-dist = [ + { name = "alembic", specifier = "==1.20.0" }, + { name = "fastapi", specifier = "==0.149.0" }, + { name = "httpx", specifier = "==0.28.1" }, + { name = "psycopg", extras = ["binary"], specifier = "==3.3.6" }, + { name = "pydantic", specifier = "==2.14.1" }, + { name = "pydantic-settings", specifier = "==2.16.0" }, + { name = "sqlalchemy", extras = ["asyncio"], specifier = "==2.1.5" }, + { name = "uvicorn", specifier = "==0.54.0" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "mypy", specifier = "==2.4.0" }] + +[[package]] +name = "ast-serialize" +version = "0.13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/d5/5dc1ee8a80c3b20a0b63c61b43ba6bc53517faecd1def47bcd752d22268e/ast_serialize-0.13.1.tar.gz", hash = "sha256:401cc84e8888d4d76f2c8d296fa006ed1a5ff993b6aff90dd777c03973efd025", size = 955137, upload-time = "2026-10-17T00:04:17.183Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/a8/fbd136b9f70c59e75f59a8e974c84102976f1201828b4f886480ba956936/ast_serialize-0.13.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3e129d44b0cffbea24a7f40117ce49e30993c3d119e584d4817ebd6f87246456", size = 897306, upload-time = "2026-10-17T00:02:52.479Z" }, + { url = "https://files.pythonhosted.org/packages/50/3f/2ad51e9823a790e7a70fd781e9595ab3abeace27f3aefcaa1939457efd72/ast_serialize-0.13.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:523d45bab16ddaf1399c04c4fe81bbf180f81e33c2f7bcbe76be56b0f7311ab4", size = 1236082, upload-time = "2026-10-17T00:02:54.82Z" }, + { url = "https://files.pythonhosted.org/packages/c0/5c/8e959db5cc2237f9d4c560a8e2253c98f9746883fb4774651e4cda458901/ast_serialize-0.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:638333fb8ae72c62753f9e794fda20290883ddc65b0d85e26e08f8bb227567ad", size = 1221242, upload-time = "2026-10-17T00:02:56.395Z" }, + { url = "https://files.pythonhosted.org/packages/d6/05/ab1c56c3b7adcac88764bba609502d6b4989e449b2dd60eb478e1d20e369/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc34c670747d85d4232834665e247009413eaebe47c69eaf60a5c439aaecaed9", size = 1282105, upload-time = "2026-10-17T00:02:58.275Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f4/41981fcd2ddffe0760e5326be9dcbd2eda9478a8430a637e5123bd8fcb26/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8c8ebd419811c435812d242fb2a173e57dfede198c9ac9a0a584666ebc9b9331", size = 1287905, upload-time = "2026-10-17T00:03:00.068Z" }, + { url = "https://files.pythonhosted.org/packages/6a/43/b783addc5681153707121991b7b47892f7e9c8ba58a0d03b71a3b023ba47/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8899c038b1c15ae8b98e379057fb3ef398ceb0b21d1737a8156dd5a0923b7e51", size = 1541806, upload-time = "2026-10-17T00:03:01.503Z" }, + { url = "https://files.pythonhosted.org/packages/47/10/969db095038ee15270f65ad20d1129c5e4da605bf7e93b70ae640f1c8a9f/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e14c4de98f965647a74e6eaf37e20207124e45451ccd36e84a6f6ebab45e0372", size = 1309946, upload-time = "2026-10-17T00:03:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/06/dd/7c623d2fb0b868948952d1787e629a43b866f7d4caefd5445e20aefacec4/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f3e8a3eff69af640db985360fef35073fb46956dfc3e5c614cf00920d2746dd", size = 1302773, upload-time = "2026-10-17T00:03:04.378Z" }, + { url = "https://files.pythonhosted.org/packages/d4/c2/4d49469110a19a44fc6269f4fd59679bcf84df068f185f89b73bee4da19b/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:75d7750fe98f6f35a96c43aba8d98a2e623e22c778a0c17ab9ede92eb10f5d82", size = 1299579, upload-time = "2026-10-17T00:03:05.88Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9a/2d381f1f262dda15affc8ad643fba1619eb07e7d9f8bd904deffef183006/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:43e8ddd63e80fc280c2f790157386f27f9b9f6ccb9c4f8e803b863cbfb085769", size = 1355969, upload-time = "2026-10-17T00:03:07.321Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9c/2266794e6c25fa9ce51ce04a9bf4b7e330e3ac9351ba3fa14e487678acdb/ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9221e09496fb942901993c1812ee87950cc0ecbb514c7c0f1f5fa6d78f701ed8", size = 1460227, upload-time = "2026-10-17T00:03:08.733Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d6/14deb28e5d8f328f626a0778ea2cf98fe908f2bbcccd804cb6f449ee1782/ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:7a5c38bdb93669c596f94fb7204dd6ebe4fadb19058ac9a2faf2948c2a175f96", size = 1562691, upload-time = "2026-10-17T00:03:10.25Z" }, + { url = "https://files.pythonhosted.org/packages/f5/19/7a28c0e5ba54f0d3e054f847a2a549b748cb179ab433321a8dc1b39e398d/ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:acf4e9f055d75058ab01536da330648746f2f82b7697a85230547f2d5ebe895c", size = 1558669, upload-time = "2026-10-17T00:03:11.799Z" }, + { url = "https://files.pythonhosted.org/packages/18/9a/f9e2690eab58217f1d395a964a78efa7eaf813a1e38be8dfd9a8d45d0840/ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87427913c325adc811e81e7c9a757b061f47ef1e29114ac479aa0953a2687a98", size = 1665071, upload-time = "2026-10-17T00:03:13.284Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f3/ef5b159a0d7161b404f05e7ec21cf7735ee7edc10c000cd2cbbc7bb6b69a/ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:ad88921f95b92bc144d9ad059a0e16f8c1ae69f5239f2d3c6536fd2774f73dfe", size = 1473364, upload-time = "2026-10-17T00:03:14.563Z" }, + { url = "https://files.pythonhosted.org/packages/1d/87/6e8ac4a865124a12cc4f915bf1bcfac3bba426cd5f58d3712a873ee61012/ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f8b17e2921e56621bcb46792bd3e0b82ca3b85de78d5936e7b8174ba1758b0aa", size = 1500566, upload-time = "2026-10-17T00:03:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1e/29155f1621433008013ad5c940a03d41a676be0680c41527040472d37583/ast_serialize-0.13.1-cp314-cp314t-win32.whl", hash = "sha256:2f9a9ce8b0994f757b941f7f7fe384a6666b4162b941ebc5c248081baea18e47", size = 1128674, upload-time = "2026-10-17T00:03:17.481Z" }, + { url = "https://files.pythonhosted.org/packages/bb/64/2506da65143ea138de5822e9173e0b72320a2f70e23bd723f044033175f1/ast_serialize-0.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5d99910e6b9a9b9360bcca2ae99aee36cc912ce058df2a57d1c33099b6cb2593", size = 1163306, upload-time = "2026-10-17T00:03:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/6d/8f/c7cb22638899b0cf15908809c270560e46395e65604bc59e4b623ab9f405/ast_serialize-0.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:a789cbe14d0a0e9ec9b32a456e5abc81ad8799479c3cfd88a29e3442262aebe6", size = 1133775, upload-time = "2026-10-17T00:03:20.377Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e2/b0c140fe50fa39dd249e382f7733af6f816c90021fe7959a0b026f86237f/ast_serialize-0.13.1-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:23d768369b89d551077dc173da6892fad00443338417523b6732f4e8ba4a7ed5", size = 1236160, upload-time = "2026-10-17T00:03:21.945Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/a8a2ca21e3943555b48221f8fba546877a7ad39efaf6505506524967d7c4/ast_serialize-0.13.1-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:68f8829d114dab6c9db4a8bbb8d0e342958faee1b5e15a29022678df639960f8", size = 1222214, upload-time = "2026-10-17T00:03:23.462Z" }, + { url = "https://files.pythonhosted.org/packages/15/19/2f58f2ba64e4799b39de85c140aef09a03aa6ea53be75dd292a9d9b9d8ae/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e385b17d5bde892a6184003a9aff3518387908b73f8f5ea539313c07c8c0ede", size = 1282979, upload-time = "2026-10-17T00:03:24.695Z" }, + { url = "https://files.pythonhosted.org/packages/b1/af/ca6087fe84210c1ccd43e3182917bb70649fa1e69745a90c1d59ba08fea8/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:02686b281ec9b8e9614f6f0db27e12e4cac515f336c5d05b678bc9eb73ff2af5", size = 1288596, upload-time = "2026-10-17T00:03:26.04Z" }, + { url = "https://files.pythonhosted.org/packages/b0/2c/4ef964c763329a0347125e745ec0451ba4fbaceec9efe2ac729b7cd55f17/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a8ab2c2585b2e22e48cd4aff2136fce4eab4ced569b85f150ef9a5a5d6f34d49", size = 1545540, upload-time = "2026-10-17T00:03:27.52Z" }, + { url = "https://files.pythonhosted.org/packages/bb/85/aa961dbb75813cfa94de0ce9a16a9510c226b95f604c405421e727f44bb0/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c652d9f5d52d8b81eff3ff2c95c9dd0f281de45f6ad5d3fa95527550c396454a", size = 1309374, upload-time = "2026-10-17T00:03:29.091Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e6/f2b67c327d9f9533e41791804403ccc2dc457821195a2e51f0b587a90b90/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:121e1d08d58b75c15491cc3003609cbad71ac2b0016043c36d30a6b2032b6605", size = 1303466, upload-time = "2026-10-17T00:03:30.451Z" }, + { url = "https://files.pythonhosted.org/packages/73/1d/33f18b81d1bb1f5d682943ea92fda8130a6593627f12f6201668d40824fd/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:30cf89b6a6844542d116c758725d13de5b97b9410a51ee577715f761270d0c1c", size = 1300576, upload-time = "2026-10-17T00:03:32.102Z" }, + { url = "https://files.pythonhosted.org/packages/25/cb/64914144925f1a4abdd9217e717b122255c38fc5ebe4342f92052f3bacbc/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:732aaae9e498c973fc63fd7f308009b57c4afa6ff59d0b674d894e57d0bca01a", size = 1355960, upload-time = "2026-10-17T00:03:33.676Z" }, + { url = "https://files.pythonhosted.org/packages/ee/80/bd29379fab2bce19adc0bf9177002b11b23dfe8d8fecf409998b458b4624/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:d9c1a920b2aa0c1e016bed7721689717034ca0dba64ac3a3ff243b003d4e8dd8", size = 1459779, upload-time = "2026-10-17T00:03:35.09Z" }, + { url = "https://files.pythonhosted.org/packages/83/89/012dbe733de05ffdf8e40ca0f9fa5d417cb16ec93a74463c57c001914198/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:847cad4c22953823f5e76d0b126c6f9da670cfa45b1df41b9324e22189d77611", size = 1563399, upload-time = "2026-10-17T00:03:37.285Z" }, + { url = "https://files.pythonhosted.org/packages/69/37/ee4bcb0358bee38744eb0f18560446572d095fe3bf6b63d6f739d08a839c/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:aca07de3ca8e3d9f2b716c3c31f4f384fe4bd2d03e09912417cff7a3e08036f7", size = 1558808, upload-time = "2026-10-17T00:03:38.711Z" }, + { url = "https://files.pythonhosted.org/packages/54/1c/ff44c5355251d61ef22934c4430bb31a61de4781d25a992154aac9b8c82a/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:5db12d843d931b9d1f1519ce66b9cbfe72d8563b75c1204d255322254d1964dd", size = 1666324, upload-time = "2026-10-17T00:03:40.376Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bb/6da4bfc8aa3403e6447607131bf3463acaf954d990c6c8b6787c1902ddfe/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:fb8021f4dc3296de18737e3b8b34994411da725374d74a0b9eb32a738f2a23d0", size = 1474089, upload-time = "2026-10-17T00:03:41.715Z" }, + { url = "https://files.pythonhosted.org/packages/21/97/897e33bef2eefd880e6350e375cc66bc5bcd00a139be480fd655f5b3f095/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:0d764913374ba21df8cd618045b371ed104ce55c6276de7c59e554a4f5ba26fd", size = 1501542, upload-time = "2026-10-17T00:03:43.08Z" }, + { url = "https://files.pythonhosted.org/packages/8d/4c/821211ac83870c27e4412381b7c7838336456b40575bf840e4680b2018d9/ast_serialize-0.13.1-cp315-abi3.abi3t-win32.whl", hash = "sha256:2bad302ae2427454ef9dab71bd5c3ad67188860c39d36daee38f72031edb39c4", size = 1126118, upload-time = "2026-10-17T00:03:44.616Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/2b2d5e6c4fa352874a80618e742ffd32d249dee6c119f4233b51afc6b8f4/ast_serialize-0.13.1-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:032b09d86c785c3984e975378b7861f2a68ac44e22ba7b07b93a536b2524eeb8", size = 1163766, upload-time = "2026-10-17T00:03:45.986Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/d610cfcdabaa44fd62a54ca1fc57a7dba120643798b089a14914973562de/ast_serialize-0.13.1-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:929eda0c419e622ab49df3a4c45f81c57dcc10874bbad55d0470a4a03324dac3", size = 1134043, upload-time = "2026-10-17T00:03:48.453Z" }, + { url = "https://files.pythonhosted.org/packages/81/92/d705be88ff452b9ba0088b554a0cbd308fd01879da092b33fd2ad7d1aa60/ast_serialize-0.13.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:4bcf1d5f4eefecaa325a34ecf079b2741189e7d0821523f92cb343a389a67489", size = 1242416, upload-time = "2026-10-17T00:03:51.521Z" }, + { url = "https://files.pythonhosted.org/packages/60/c2/98370845a7f35cb8a25782e78284b48e01bf3128339881d48d613f304f8f/ast_serialize-0.13.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1b158f1c9e3a0d386913924c57995d6df78224868a4d0f215bdd41587c97b34a", size = 1229729, upload-time = "2026-10-17T00:03:52.825Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8b/2d7ffed76addd9d3fcf37fb171400942735c8912264c763561a85407f6a8/ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a6201b69ce42e919f2fd9a559138d5410b5d8afff7c3a6b1f500b0481ff7d8", size = 1292255, upload-time = "2026-10-17T00:03:54.148Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2a/bb9e0e719d89cc950fadfcfcc9cb4bc7640419c4228c852f031ab8abf440/ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6eb0be64c9b8f1ed26637eef345abe75ea90351c92c85a8aec7838b3af971011", size = 1298501, upload-time = "2026-10-17T00:03:55.559Z" }, + { url = "https://files.pythonhosted.org/packages/7a/32/13ce07dce48226f5bd9cfd8f708ec101a8be05aa430cda4918ce31ce8c82/ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f2427bddfca208e84de700044fadf9aef2769a70bae740a1bdabb89ee9e1117", size = 1553758, upload-time = "2026-10-17T00:03:56.952Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fb/9b51d186db0c20ca28cbaaefd50f03b573c89327b9a2627820cbce22196b/ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88df960e67184a71874e5b2ee014f87d1254a0dbc1f903c7bd7b1ab7733ecaef", size = 1318258, upload-time = "2026-10-17T00:03:58.418Z" }, + { url = "https://files.pythonhosted.org/packages/c5/fa/da311559dd3dae9af5825cf21660775a929b970f72e3786af3f2cd667944/ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f99786e0d62082a5b121b3437f3167562133711df22128de1e9886f57f0b226", size = 1313260, upload-time = "2026-10-17T00:04:00.194Z" }, + { url = "https://files.pythonhosted.org/packages/2b/11/97a4b9e5b4776d72f47fcd9a09a497bbf68ec8c1ae074307a2c51e410d85/ast_serialize-0.13.1-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:a01b515b25a03184eff4acef25b981ae007916379682e908fc8785b342a0bf3e", size = 1309992, upload-time = "2026-10-17T00:04:01.575Z" }, + { url = "https://files.pythonhosted.org/packages/2e/c4/62f85121a80cd451ef04f550cf0f7ea0ad9ff46cc4e9a5b9ae439b6527b1/ast_serialize-0.13.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:29422d7bcd9e7f39411571c36cec45ce8a7a9b3ad4b2e7aa64497c15a9e397f6", size = 1362715, upload-time = "2026-10-17T00:04:02.881Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fa/c5f4a545f72a7681e3295bab6e3600ee0862ec4391bed961fe9f735b67e0/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6ffb0f548a7dc126067fdfb7b2325ab5dfcdeecc29faf5e0f0461c96d6ebfa3d", size = 1469931, upload-time = "2026-10-17T00:04:04.181Z" }, + { url = "https://files.pythonhosted.org/packages/38/a9/65aed4f8fa7a001a7cfdc03e68e844517ab041ba6fb614b7d78a1d6ac056/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:2876444a5f87a7c76f371af3c07c2fda563a08bc8af52568df812e8aba4bf568", size = 1570945, upload-time = "2026-10-17T00:04:05.778Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8c/44b2da991cede79bfbf52089f33a5f0168945466b3d9cd38b5dcf08e7130/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:885a62ec6481ce58ab24c6357360d7a82023b62dd7e5e7ceccf59e9662a3272b", size = 1569978, upload-time = "2026-10-17T00:04:07.347Z" }, + { url = "https://files.pythonhosted.org/packages/46/e4/109b2a5beca700237767d5add920c2f8e1ba9bd5799b0faf143d0e9cf4ef/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:2015fd0165e463aac3b1aadc6a1fa1e5820c14ca950821f4fe02f8a3f6a388d6", size = 1676082, upload-time = "2026-10-17T00:04:08.7Z" }, + { url = "https://files.pythonhosted.org/packages/73/e0/7df70f6dbb11b5762da5416318f08316a1c51348e0d69ec79ca5f98f836a/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:eb41e1906502ac80d24ff343f45008552a3f5b5158607959fe6a07dbfc6eb037", size = 1483207, upload-time = "2026-10-17T00:04:10.162Z" }, + { url = "https://files.pythonhosted.org/packages/1f/bc/523aa1c08bd69b5654621beb3dc1a9035715a4a63762fb8d219d6fb3eb81/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ca9acc67f796aa2554fa3037dccc022a4b08b38b2ad153edfac9c9bb7cb1f3c6", size = 1512140, upload-time = "2026-10-17T00:04:11.589Z" }, + { url = "https://files.pythonhosted.org/packages/66/83/6607060659998f00afdea0d8375c1e6e2b7d515ab905090d9a5a02f48851/ast_serialize-0.13.1-cp39-abi3-win32.whl", hash = "sha256:4f98da562e5901d69c7dd30885594c5438f859edf354e9ac7517d188fb026c3a", size = 1131889, upload-time = "2026-10-17T00:04:12.953Z" }, + { url = "https://files.pythonhosted.org/packages/9f/6f/bafd6490fe7b355627fabe95f4417e3d02da93102c8552550eb9a0774e1e/ast_serialize-0.13.1-cp39-abi3-win_amd64.whl", hash = "sha256:ddf7cbc9c8abf70102c4a088efe154bb86bfb825b0a10e556d2b15972cf99bd7", size = 1171153, upload-time = "2026-10-17T00:04:14.362Z" }, + { url = "https://files.pythonhosted.org/packages/11/25/4235f4b375a67a37ac595bf478a5407a744eaf7b64113c1c3a5432b8d954/ast_serialize-0.13.1-cp39-abi3-win_arm64.whl", hash = "sha256:48f11d96c9ec71119612f8e79a8c55c12f2b136716d68347433c561d27a25f43", size = 1139035, upload-time = "2026-10-17T00:04:15.767Z" }, +] + +[[package]] +name = "certifi" +version = "2026.10.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/eb/a2aa1ac3ea30dd3f30ca57f40232138b6b0040719316ecb85daf61167771/certifi-2026.10.18.tar.gz", hash = "sha256:b9995d97b64d0431a5af837d8ca8cd6238104d1f319f04bd93ea43124d6c861e", size = 135227, upload-time = "2026-10-18T16:21:07.655Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/ba/95dd616042484f7c83a06974388b37b837732740ee70bc5dc715b5fcd55a/certifi-2026.10.18-py3-none-any.whl", hash = "sha256:73191c47aaa79da394e8fee2dbc2ec827432cab56311122c9e47abf79e390f62", size = 134093, upload-time = "2026-10-18T16:21:06.276Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "fastapi" +version = "0.149.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/37/6e7ec4dbe12d9721902066ddb83dd9c6e7adb1cee81e094502c43024e4d1/fastapi-0.149.0.tar.gz", hash = "sha256:aba5779358d0c5233759ddcddd84fe7269d71f775fd754b653b608f770558a41", size = 483369, upload-time = "2026-10-19T17:22:29.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/6c/78c324bd6725f69ebbac46e0d6a9a59744e108cececac4c1c8500c567512/fastapi-0.149.0-py3-none-any.whl", hash = "sha256:fc4a5cf7e14f607a48eb1ab98147e9140943e88ccd0c8791d72c0a89b1159a35", size = 145182, upload-time = "2026-10-19T17:22:27.753Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/6e/0091f175ccd02b02bc8811bbcbcc6ac2e980be116e3b2f7a736ca322bf84/greenlet-3.5.6.tar.gz", hash = "sha256:8e67c43bdfc88d5fee6db0d3e40175b362fc95fb85f0412d233b9b203c53a575", size = 207653, upload-time = "2026-09-14T15:42:51.806Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/c0/d254544ae2b8bdd311aef000fafc02828c2771b17d994b3075620ea7cc6e/greenlet-3.5.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8cddea1b8339451c2fb3388e138347b6126744f33b611bdb55b7357361cfef46", size = 295221, upload-time = "2026-09-14T14:25:11.583Z" }, + { url = "https://files.pythonhosted.org/packages/18/18/eb54be16b9cc3971e09ca5b73334e1b8c804a4630d9addaaf218a4fe300f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c59acfa8eb73a1e0d484392dc002bdf001fd4ce73394e0132df3d1ab6093d7cb", size = 660992, upload-time = "2026-09-14T15:12:04.876Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b4/e193efe65671dcf294bc51fcc59efb52d154adf8612c4ea016da0d2c486c/greenlet-3.5.6-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3b4a01c6da07ef9f80d4fe8933b994bc99747bcea3eab0330a9c34d3c12655b", size = 673428, upload-time = "2026-09-14T15:20:45.756Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/631bb45fafde1dca782152377c0676d182ec924820064047f533a3627b28/greenlet-3.5.6-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dd0b83bed3405b586a3133629f1d1a5bc7bfd64822a3b7ab342bdc68e6dbc61b", size = 677688, upload-time = "2026-09-14T15:25:09.279Z" }, + { url = "https://files.pythonhosted.org/packages/45/ac/28fa7a9e50f2859466214c4ac584d776db52c1604ad4dd158960a5af2a1f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a09d59bef1db94f384b5bcc2d523694d338f3df6b757aeeaf7baca5d0c0be88", size = 670773, upload-time = "2026-09-14T14:36:02.577Z" }, + { url = "https://files.pythonhosted.org/packages/40/30/2b0a73e68e1e18e30b601d0d183cfdfc2beca4de5a6843c630f0fc9fb90c/greenlet-3.5.6-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:fdacf26402389bdd89857ad3c045a26fe8f3314f9a8b28226f82f88463a65b77", size = 480475, upload-time = "2026-09-14T15:28:40.741Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cd/fb7d6cdd86ff3427c1494854f0e35437eba05142be91f530f6da75e09e19/greenlet-3.5.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8b7c73d1cef3d9ae963e9ff03f6222df43efbb9054ffd2f1969c935b7fc84c02", size = 1631900, upload-time = "2026-09-14T15:10:09.745Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/143bdbb20a516628cb15074ae52ed17d850b450292609c7a6fccac6dbece/greenlet-3.5.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8b27df301f56e3b3d2298095c8f7d6b68f2521f6b1693e901fa039bdbae34424", size = 1693740, upload-time = "2026-09-14T14:35:52.959Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9e/019642432e6ae283301df1361227d47610709d2dc69a38f95edef266d713/greenlet-3.5.6-cp314-cp314-win_amd64.whl", hash = "sha256:f8f0bd690e1a41294ac87905e8121c81a3761ec2583c768f13467428606c8c7a", size = 327473, upload-time = "2026-09-14T14:28:12.948Z" }, + { url = "https://files.pythonhosted.org/packages/e9/7f/8aafc7bf70c948786dba7221d0dc0838e5329bebc6d434ef2208b4f0e760/greenlet-3.5.6-cp314-cp314-win_arm64.whl", hash = "sha256:8cda13494d86a4f12429641117cb6ac4bbbc9c30a33f711f7d3a2e5fbe4b0b7e", size = 311095, upload-time = "2026-09-14T14:28:00.7Z" }, + { url = "https://files.pythonhosted.org/packages/14/7e/7a205688a5b3074933b18a906608d46d106e9a79d776bdab5a4abf4b4feb/greenlet-3.5.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:97c5a53e8c1754df58e73f047a99e287d4da1bdfe64b0072fb25c87000897951", size = 305352, upload-time = "2026-09-14T14:21:31.962Z" }, + { url = "https://files.pythonhosted.org/packages/78/cb/9c4a57a9d9dd0256e20b8f7f4f06554c2c92badebf0ab73ce344321b78b9/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fea4427d1ffdb3b523d7daa6712038428a4c16c450b9777bdd1221cfee0eab49", size = 672671, upload-time = "2026-09-14T15:12:06.347Z" }, + { url = "https://files.pythonhosted.org/packages/97/52/c6729681ebbd298f4decd28746815acc8a0b0a0fde21d2df33776fd4d042/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73a29b5ba642e35433166a03a3e02935e7238c4b3467fbd77523b99edea23e5b", size = 679489, upload-time = "2026-09-14T15:20:47.291Z" }, + { url = "https://files.pythonhosted.org/packages/71/76/3c11c21e0716b1f1dc7c1a4b3d690abb1d3b448c69a9d32049fecb64010a/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:61a61b4a95a4f97922c3a6f5606d3e360851584bd47e500a5161373c53810e3d", size = 681303, upload-time = "2026-09-14T15:25:11.088Z" }, + { url = "https://files.pythonhosted.org/packages/58/c5/2b6c721ba8b8963da42d5a0f57f25b8aaeb1fe9bdd156875e57f3be648a2/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:460e70b033aba8ed47e2ac9b5d0d2157b05a34fbfa30a241400aef4118902cdc", size = 676608, upload-time = "2026-09-14T14:36:03.959Z" }, + { url = "https://files.pythonhosted.org/packages/3f/26/3ae402202452cd5941bbbd483e5a74297e2397e7aa3182c2a5e3ab7d5666/greenlet-3.5.6-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:fe3170a69fe039b18ad18171e66faa9a75f6fe9d78f968fd9b54e09fbd714d81", size = 510112, upload-time = "2026-09-14T15:28:42.112Z" }, + { url = "https://files.pythonhosted.org/packages/b2/04/0d018e0d05bcdde19a0fcb907834155f1fc853a9bedd3f3f5e6acadcae19/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca80a49b53ed1d22f7282da7255f7bb2fd1935fd0f623d8613fda38745f18961", size = 1641479, upload-time = "2026-09-14T15:10:11.216Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/f02ef9073919158f6403fe3701d4ed4403d646720e7201dfc6e9d264bac3/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:916f92f2a8db10508f739d0b5e00b83defe5d1115a997c54532a6d7cf8c95404", size = 1698758, upload-time = "2026-09-14T14:35:54.336Z" }, + { url = "https://files.pythonhosted.org/packages/08/a5/1f48fe647473a2dcccfd1839b2ff2c78eb57009be776b4da071e901c9bff/greenlet-3.5.6-cp314-cp314t-win_amd64.whl", hash = "sha256:886bcf1870af74c32bc310fd00a6b803445e17e51b7d5a107c7b35c0f362cc16", size = 331574, upload-time = "2026-09-14T14:27:18.451Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "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.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "librt" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/f5/9dc696772d241814bacac7880bac32f2930b5a6ebc1f85317b83161a011c/librt-0.16.0.tar.gz", hash = "sha256:ac38d6d8d66bf3d744148dbbc0b8e193e195a51e364ed55e224631f5721891fc", size = 219839, upload-time = "2026-09-29T00:55:32.891Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/93/9e0cf7da129a93c3dc7f45bc3cd4a660f2aaa995aa8a6f95c2583ef41239/librt-0.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:931a0bb0fcac88f263e269e46eb30ba8e21402cd3c62ca40cb97034c0693fab1", size = 149844, upload-time = "2026-09-29T00:53:31.391Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/8a90d2a8f2b2e471bb486b7aec117b8ae622715ed6c39853aec48ea20073/librt-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1bc17e54e5305f8d40b7ca203671ff5a9e59c1d0f8ea0f625dcca53a3984de11", size = 154111, upload-time = "2026-09-29T00:53:32.718Z" }, + { url = "https://files.pythonhosted.org/packages/35/ce/67abb46258da4d3e42ff5b141db6f38c59357bef84c7979f183b22f924f1/librt-0.16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:877698bf6bca5721d8be345f2fe09778e40ecadea8b58c73075f2b1a53666bf2", size = 494234, upload-time = "2026-09-29T00:53:34.466Z" }, + { url = "https://files.pythonhosted.org/packages/12/f9/ea7162414a16f8f1bbd3b493ad6d22b926c5471916e078350bdbca8c4e5f/librt-0.16.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5981c011b306781ce561e18e14230a14524a3d8109b97553666c942c18f31a96", size = 491173, upload-time = "2026-09-29T00:53:36.124Z" }, + { url = "https://files.pythonhosted.org/packages/b1/09/9b3e869060dd33f9989b80ba4fea306f6db8cecefcdfe6d7346ef0603f65/librt-0.16.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:afced3dfc17cd805ecf7a3d77996a71cf5f2c75aa66eb0c21a9930f4fc992f86", size = 505547, upload-time = "2026-09-29T00:53:37.686Z" }, + { url = "https://files.pythonhosted.org/packages/50/07/79007d2165f649ea93e08c0962d1d62c70af9cde77965255095bf9d96f9a/librt-0.16.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca8052401c55d7511dda6760719fda7618067e83535d7d0010096d216c34b667", size = 523057, upload-time = "2026-09-29T00:53:39.301Z" }, + { url = "https://files.pythonhosted.org/packages/61/0c/8fbaff66d0ba376d8864653f5acce1569bae27e89648671f26f7eca67ab8/librt-0.16.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1e511762a074005bb0aa569166779834e75e438370226930d0ce1866d4b6a33b", size = 515158, upload-time = "2026-09-29T00:53:41.012Z" }, + { url = "https://files.pythonhosted.org/packages/df/2e/23ff0dece76f07a4124413a57682efa0bbeb5765ac0122bc0955513f82ca/librt-0.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1e8591bd8a5a628cd7f07954c6a1592359a878bf032957a8e9057a41d644311", size = 534183, upload-time = "2026-09-29T00:53:42.451Z" }, + { url = "https://files.pythonhosted.org/packages/26/c4/e11dea21d9a29486eba78887380374189d472734fa32cc50cb37ca44d3d0/librt-0.16.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4aaefb4ba6c07e1aeebb2795c8958148f1d6f9af3b555b53d23d766edb6d67a", size = 540663, upload-time = "2026-09-29T00:53:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/75/e873ae158a8b7f5359be33e7fd6c1fbe02d9a78a3e89a77de6b0e837f476/librt-0.16.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d92db7a0f6aee44f1baee94750457e8d2d1c6ccea41842de6268d34e8dc7eddd", size = 545908, upload-time = "2026-09-29T00:53:45.812Z" }, + { url = "https://files.pythonhosted.org/packages/ba/36/8939d3f6a93e11bd9592e6fe28d2b44f1c2dc4bed6e22e72359a91e18ffb/librt-0.16.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:378dfaffb38e59c24a87cde5713cd865d51ff7383fa12947f3907f306ea1ca55", size = 523302, upload-time = "2026-09-29T00:53:47.596Z" }, + { url = "https://files.pythonhosted.org/packages/71/14/35309f44a077f0f42ade0e2e7cd88c0cea760c661af205c01ea90d3c0e1f/librt-0.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3e0c39bdc85370422e8b637be76eb1fd07d30967551b03e62267dd156f553152", size = 565911, upload-time = "2026-09-29T00:53:49.278Z" }, + { url = "https://files.pythonhosted.org/packages/78/0c/df6255b94967f3159ebc46f08d8e783c12da6ee269ddb74b2efed63c640c/librt-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:1b384b90ab79a7bc30b566895809a636e0666f21f3cf12b54823d025b7e83839", size = 79119, upload-time = "2026-09-29T00:53:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/6b/44/d30d5a5461378c9d33f36736c6791c3b4c4ba4b1ffe0da7350aedcb2c9a0/librt-0.16.0-cp314-cp314-win32.whl", hash = "sha256:52327da75a94012e7f932f913d20d3876bed3c102be00e6c3e8600ff7bdd58a7", size = 100140, upload-time = "2026-09-29T00:53:52.205Z" }, + { url = "https://files.pythonhosted.org/packages/c2/98/769712f356a1e897df3581bb0c3100375d054a000de26099360ea65b5111/librt-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:3f0b8114c44b2ac06ff5dacd08e07e8e807ff4f46083f2a1602685122559be41", size = 120464, upload-time = "2026-09-29T00:53:53.495Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d3/ae2abccc8bdc8b063c1613a77686e17b74e9d3d60cfe6f12c63fa2821b9f/librt-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:8caf96a4ef8fb27d0ac0d1ad8337d26a240acd4a02fe4345d0a8f264753e8f99", size = 110889, upload-time = "2026-09-29T00:53:54.817Z" }, + { url = "https://files.pythonhosted.org/packages/e4/26/0737d4be058dd6376eade7dd8b380d4b869b6394cee929393a5a431c45bb/librt-0.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:953107e2f68d0f3512c48f898b0dbf0ce5cc52bba0f318d847c985dc555ee4cc", size = 159838, upload-time = "2026-09-29T00:53:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/01/96/9bc96531d7c620e9949af904470f02e3fa8f35129ab8e8f281c51eaa3788/librt-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad37d5b9abd49c9a655dcda7ea52a8a752884062ef1ee71ae17c2f2a0f81fe6a", size = 162018, upload-time = "2026-09-29T00:53:57.532Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fa/b0289dcb186eb3f97221ba00da5f4bd3ba7fa5e99752d48aa8d336615334/librt-0.16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c4aa329c17bd1aaea4f6e89335d8ccd494b3a5830b6654462273e50e11023f0", size = 703983, upload-time = "2026-09-29T00:53:59.024Z" }, + { url = "https://files.pythonhosted.org/packages/d9/ab/05ebbbde7530fc5eeb58fbd1581522a9f64f132fa703c4c595eed6e14760/librt-0.16.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0ead24d2562a49473dddd9efef8581f020007eb0054389c3ee3ffad38b1ca4c9", size = 683847, upload-time = "2026-09-29T00:54:00.671Z" }, + { url = "https://files.pythonhosted.org/packages/a3/75/f52aeecd4dbadbddf80725ba7de126d8bd5d0eb66247eae17a81ed90dd4d/librt-0.16.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02118f56a9c36ddd07dfd9b919d9ecc117ba20a90987d56aa4c429fa34509188", size = 698045, upload-time = "2026-09-29T00:54:02.247Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/fa277a835cd6eb42380591ceb85f48c5b4d2b2d7e2cb9869e1e17d24d237/librt-0.16.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e29522c62e28595ff7e324c6834ade51127707f0e255b18d1c1cf03d39c1048", size = 724640, upload-time = "2026-09-29T00:54:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/25/e4/2cf64354f3fde8ebd591b3b48f96510bee24ac5f7d1e567adbbe210abdbd/librt-0.16.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ff4b2367926b69c6215635902cccb04048e73094e9862900d27cb2c6bbff143", size = 731748, upload-time = "2026-09-29T00:54:05.741Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c9/c180af3e94e01aa529fa93d7733fec2abc47f222345400cf21d5481d5f8a/librt-0.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c6f1b27bf1632a7e016af9f145f82be95e1edd7721a646505c21059257cb5a04", size = 754960, upload-time = "2026-09-29T00:54:07.38Z" }, + { url = "https://files.pythonhosted.org/packages/95/d6/01073aa78c58f356b10d9c57b3fe9abb143df338142bcd316df417b89db0/librt-0.16.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5696d7f52e7b37217cb3a8f92c744fe835942602fdd4c1a8bc4741d3bfdce15e", size = 747000, upload-time = "2026-09-29T00:54:09.06Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d8/1de3783908658d697a8cfc00582f61299ffba7796d7260c112e4700b1109/librt-0.16.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:6072e92dd876ff6ceeb6cf371e35e51f479349837391341f479b08df4564242b", size = 749569, upload-time = "2026-09-29T00:54:10.702Z" }, + { url = "https://files.pythonhosted.org/packages/b7/32/e817f66c96d6caa8bb8435ff93c4c220624d59efc506be59ab98fcd01d0c/librt-0.16.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:39ca4f2f2fe05de8e63493da592d84311adabe5bef52b193851981da9816b302", size = 729483, upload-time = "2026-09-29T00:54:12.25Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c9/23992ccd2b9d22798fdd0f61353183a47414e651da782ad83eb680f50833/librt-0.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9807485a908f00355820f18e91e045ffdcdc5adb68aaec40a1e2b88c5f7bba1", size = 776584, upload-time = "2026-09-29T00:54:13.837Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/e8af957f08e6e2e8e566b099e43d2748418aa565df6ee1d9d3331209fdfe/librt-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:94be5cb7bca4df6201f4183e9e4fa2086c655283d20b38cd84500a69057575a7", size = 104402, upload-time = "2026-09-29T00:54:15.568Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/946e6443d7cd32347a086043395e421e52fd603c9163d2ec970ceab8eed6/librt-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d46ca272b251d033dd4527b0dec5f261a28a52bd5fa0f99c117b0a1f8588cc2d", size = 126227, upload-time = "2026-09-29T00:54:17.165Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a3/bc4f9959d3c62bcbf9e5fd3470a8bbd8bf33224ed4c25d7fba173201b8ac/librt-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b9d6d4b14e92d876f8026b54c20c445f36425214c1081dc76f74e40db386b82b", size = 116493, upload-time = "2026-09-29T00:54:18.567Z" }, +] + +[[package]] +name = "mako" +version = "1.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/09/e07c4b5579a79f4b16f8d4f29f6c54514ac787c4ad506b8c4f28a0e6b0bf/mako-1.4.3.tar.gz", hash = "sha256:cd6537fe88d5fec315c55c2f8529bc4ce7a9a352ad7db3eeaa6a66e2dd4ec37a", size = 412799, upload-time = "2026-09-22T20:54:31.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/a0/053d6af3e8f871e0073b4a36732d9e65be77a72e5434c31b94f6af78a6bb/mako-1.4.3-py3-none-any.whl", hash = "sha256:723296007c870bfd6b3f0c3230dba7198096e5269297ebf5e4eff9e7ffa39d4f", size = 80164, upload-time = "2026-09-22T20:54:33.128Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz", hash = "sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6", size = 153777, upload-time = "2026-10-02T23:07:22.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/c3/a944f3b0df22bd129e96915b9f4e98d2eeca6516687d7618304a966c3c74/markupsafe-3.0.4-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:4ed644d75aa94a2baf7ec3a96eaa160ea58c742eb9d27c6506053c5c40fc84ed", size = 12787, upload-time = "2026-10-02T23:05:30.971Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d6/a44863f69d88b6c7e27889108f70d47aed259edf89d5df3c5fca1eac87d6/markupsafe-3.0.4-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6d2a9efe686f9de00d0d1ea32a4a5a86d558a2277501bd78d964214eab625e59", size = 12525, upload-time = "2026-10-02T23:05:32.263Z" }, + { url = "https://files.pythonhosted.org/packages/17/8f/168ba80e532dd6a93f96f8f706f1ad41d7990b6e1aeedc1cc0d211a33497/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8781a792a070cf2bd1b86d3aa943894115faaba6e88122a7bf32d62072742453", size = 11589, upload-time = "2026-10-02T23:05:33.251Z" }, + { url = "https://files.pythonhosted.org/packages/32/b3/aa2c95a574d3af39403a469b295886eb9b6d448da568cbebb5a2cbfdc2e5/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:971a3bbb75d97ae4e2e8f7d4834236f86f85f0c85e04ab2e191db1123b04f80b", size = 11801, upload-time = "2026-10-02T23:05:34.315Z" }, + { url = "https://files.pythonhosted.org/packages/60/d0/34b810107d83840e768bf485de795893ebbae35b26ab061b487adfa0a692/markupsafe-3.0.4-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:8909c2f1c6dd65e054ac4b573a91c8384d1492281e55d82d159d653f7a13adf6", size = 11447, upload-time = "2026-10-02T23:05:35.302Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ab/2f8488f0f817a39fca068d2b17daf446bf5cdb3eae28c3720af534d873b4/markupsafe-3.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cf3468d5ec187ffffcaca8e61929a37448f215dafc1386a12c750a72fe53634", size = 11722, upload-time = "2026-10-02T23:05:36.363Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/e2d117b048d47282ade906fbfd92814cbee5647afc13fda88a3406039372/markupsafe-3.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:52704c5d36eb6dda8866493decd61111fff86244c9b1ad225ca01b9e91e5970f", size = 12061, upload-time = "2026-10-02T23:05:37.397Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a8/73a81135e85ba66217f5af7facb03bbb386807e1a729ab64532e4c802652/markupsafe-3.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1caa2fa5a6184fb233153b35f654e6687bd555476f6170f29d8ee9be1a8b0af9", size = 24434, upload-time = "2026-10-02T23:05:38.407Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ca/fa9216dd01efee2dfdacafe7df32b4d0170fbac694b0c258a193d6e53999/markupsafe-3.0.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:387d8cd30e69b3f0a72877b9ae717033396404e19095b17fe89753a981fda44f", size = 27183, upload-time = "2026-10-02T23:05:39.581Z" }, + { url = "https://files.pythonhosted.org/packages/fa/4e/a469509e538d37af51103b17b073126973f2b1cbf197ff32c7ddf025cfe5/markupsafe-3.0.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:051417f74bcaaefa316276e0ff723f541616ca51043d070da00249d9bddd3e3c", size = 24813, upload-time = "2026-10-02T23:05:40.671Z" }, + { url = "https://files.pythonhosted.org/packages/8f/db/d7282caf7ab03af44d5d6fdbaa019b35c7d7f1c90588b839c07cba640d6a/markupsafe-3.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8e9f292fcda89b324f2f5c91d13f1424a153e40fc2756f38ee23b15835ff300", size = 23049, upload-time = "2026-10-02T23:05:41.864Z" }, + { url = "https://files.pythonhosted.org/packages/30/f3/b6a425206e6964efda6acee544d0eb01d1501784d0b8e2dcc74986f33b17/markupsafe-3.0.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:df1ae86ff54725a01fa1a0510b914ca53a161b7050be74f6204e24aded5971d0", size = 22088, upload-time = "2026-10-02T23:05:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/ea/8a/84d3582fc1f0d5bd466cdf2eebf175e172158a6e70701aacec1de1b35430/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8965520ac587c94a4ac48b729be3d8b8de00af39699b17585dfb599babe77977", size = 23846, upload-time = "2026-10-02T23:05:44.098Z" }, + { url = "https://files.pythonhosted.org/packages/1c/65/db101cce51b7ba4864ac491a9859d297dd1adf0e55b103fee9db9c47c527/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:340cbb1957ba99929cbf19a75626d36ba1ae21d1730b287d1cf7f824a20c4fc7", size = 22674, upload-time = "2026-10-02T23:05:45.23Z" }, + { url = "https://files.pythonhosted.org/packages/e0/49/ddee9813d71db0c7a5c9d97c832125e6758a0c844777f1cf076569bb0e22/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3a93d9616ddecfb393727a0041a562cf0b15a244e20f2bd25efc7949be4c4f17", size = 24104, upload-time = "2026-10-02T23:05:46.398Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0e/7d8518d726726870a2399d69fd30d0fa36c5e57a2132c336b58d7c491073/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d2e56fd3b00222722abfb3f5f0759ddbae4b90811b5ad4343c64030ad1bde70c", size = 21637, upload-time = "2026-10-02T23:05:47.48Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b0/b505e8a361ba557dbf3b3aa7331ea39b00d2022a26e925ff8463b9714bb3/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0d9c47709875fdb321452056622e930c52afbc07a7d780762fbb8b4d91ce6fa4", size = 23081, upload-time = "2026-10-02T23:05:48.611Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/9cc3cea873f980c75cbdb6f4277ce30ee955de38be0b3d02f14c108e0698/markupsafe-3.0.4-cp314-cp314-win32.whl", hash = "sha256:38fc55594dab834470b6733dead2ee9e3f657fb0608c769dcafa0ba5ab52f45c", size = 14272, upload-time = "2026-10-02T23:05:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/80/f0/5792ff768a410f93ee3f84fc19345295ffc352d2c936b424cb37e514714c/markupsafe-3.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:c1bc67752d5f21013cfe430df4062441714eab79f65a6a05e01505957e9c35fe", size = 14501, upload-time = "2026-10-02T23:05:50.788Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cf/3d074a8edffcc6899355232ff2543ae8d929733239596423b7db79698bc9/markupsafe-3.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:7e1636da3d8dfc220b6dd10264db5f2b165e4888c4518594898fbe381049af8a", size = 14364, upload-time = "2026-10-02T23:05:51.857Z" }, + { url = "https://files.pythonhosted.org/packages/d9/31/87ce42159aae2163cf3bbbd0c44bc87780510eecab1ea3859099aed95dcb/markupsafe-3.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:805c8b84534fa10891890f0e4be39f3a99e94615d93e8836bf9fa1fdca2feeb2", size = 11759, upload-time = "2026-10-02T23:05:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/5f/53/b047207eeb7752e960aca3eb1df5fb7eefa7dd4c62ac49bb156456c8a702/markupsafe-3.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fa95848c929b6a75f6848d3c9793e59db365ee436776e57db835cdbfa79ba977", size = 12096, upload-time = "2026-10-02T23:05:54.066Z" }, + { url = "https://files.pythonhosted.org/packages/ee/51/4326c88a13c7b755657d44b4bb986f8c3d9843ecba7e22d98661d87f9a57/markupsafe-3.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e916035e3e9930cbdfdd10abf48861340221857f45509565898e012263f7b289", size = 25699, upload-time = "2026-10-02T23:05:55.15Z" }, + { url = "https://files.pythonhosted.org/packages/f2/bb/990581b7474bfcf2cf34bed6ba5ea23bd87adb9d671213d68e88620e7a6b/markupsafe-3.0.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4d12837e0203bbace818ff4a7461afdcd78bcd782351cea148139180d7bcffe", size = 27804, upload-time = "2026-10-02T23:05:56.29Z" }, + { url = "https://files.pythonhosted.org/packages/6b/89/89491878c28e8291f5aa2fffe2c2d57230d10ae366d55dd810b840513d78/markupsafe-3.0.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5086f9975abb1ab531ee6afca1761e4b59a19b446f3f6522ed776963228cfe5a", size = 25912, upload-time = "2026-10-02T23:05:57.416Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/680998b54efdea06fc114565cd739b6d059f826a0279219b218dfa750d29/markupsafe-3.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4a635a0487774f841cb1fb62e907e7195cc95bc761e053184b8acc3ceb20733", size = 23623, upload-time = "2026-10-02T23:05:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/ae/75/2709f5ac5de9467b40b10e2bb8f89cc63dfb74582e09aa734b1124a217de/markupsafe-3.0.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb96e6e088d6cf71c1ea977510948320234824cf226e32f6f6e044f7a9c82b34", size = 23301, upload-time = "2026-10-02T23:05:59.94Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c8/39eadc6c5b14c9c7679bfb98f4d4c6a97863b5beb91839aca4d2d6e16e55/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8b5d563170ff8ba3181caa967c99a3c804d1dedb702c7cb93a6a7c32247da978", size = 24950, upload-time = "2026-10-02T23:06:01.289Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5e/01037f8a43e8ccb0bffb4fbdc5212db05bf080fdd7286cd392332d58128a/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:396ec4e65cc889f69786b3b89478b471cee5a3bcf468b9d9bb03e1a30fb291fc", size = 23330, upload-time = "2026-10-02T23:06:02.441Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f4/23e83ce0596bb0cbe670502d31df8f757bbd01a392aa486fa3b40d1ed399/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:15ba9e28640feef770374b116a6f019c21f52404aeabe516aa7f800587b98cfc", size = 25239, upload-time = "2026-10-02T23:06:03.579Z" }, + { url = "https://files.pythonhosted.org/packages/88/5b/3708897368073cc683d524750474f41a77d2986152c380dcc55b20fdf340/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d920abdfa61279ba1a2ef9484aab07bf03331f8c08a10120fa332353d06e6932", size = 22767, upload-time = "2026-10-02T23:06:04.699Z" }, + { url = "https://files.pythonhosted.org/packages/c6/61/ebda1307864b409e6b3115757a3d4a09cca46cfb6cc65191b5de226b424b/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a9f54054101545a9a9cccefddf54316aa6e4491611fcbef9e91b3b6bebec04f6", size = 23668, upload-time = "2026-10-02T23:06:05.9Z" }, + { url = "https://files.pythonhosted.org/packages/09/15/98075cceac3b5ba0dbb8e4762a847be967d2befc349a2cf2d0ac77f62c9d/markupsafe-3.0.4-cp314-cp314t-win32.whl", hash = "sha256:12a606a492de952afcb43b59a14aaaaad120e708d3663dd0fdf2d738d427a691", size = 14301, upload-time = "2026-10-02T23:06:07.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a3/768b560fcc4156685cb563d922b217810cfa7bc135773367f62f1f9d2078/markupsafe-3.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a18f38cafc329bac5e3c2b96c765b4c96d3d103421ed22ab7988c1e3fce27464", size = 14526, upload-time = "2026-10-02T23:06:08.276Z" }, + { url = "https://files.pythonhosted.org/packages/93/63/da554b4c97a6b0ea3229ca7fe8cbfb620be81613d517f482e85958550537/markupsafe-3.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:eba154571c16e032112afac0dc2dfe9e63c2ceb7aedd07bb7eecf2ce26d4dd4c", size = 14421, upload-time = "2026-10-02T23:06:09.402Z" }, +] + +[[package]] +name = "mypy" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/4e/64300736cf0a0373a27b94a91b664ee7382e36f77b0621bae6381da3e180/mypy-2.4.0.tar.gz", hash = "sha256:77bdaebd452f43fcfc4cc3ba94352a3ea537cd01e3f2d0879f48673d2ec00d6e", size = 4064425, upload-time = "2026-10-01T20:40:39.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/66/924be0b653372ed31ad5c48e26044cc00840e591943a56e61621cf05b60e/mypy-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:058165f564ccf559c68c70fec2091fca5891110480210c22594635e3f6683437", size = 14234230, upload-time = "2026-10-01T20:39:44.535Z" }, + { url = "https://files.pythonhosted.org/packages/56/39/c4f176880a4177123576de6cec6309feea8f42fca2bf2f6584e88054f656/mypy-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fa247e02b505a45a2775f69df38d360d197e3790bc60f717595db9eda358b6e", size = 14392944, upload-time = "2026-10-01T20:40:05.78Z" }, + { url = "https://files.pythonhosted.org/packages/bc/1c/26e16977e25ef2494a74f8ffc872a76ec2c3aa43c3156057cbdf88f352c5/mypy-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20e9a5cd875837520c43db98dea0b6d0c2197833d95c30127d8f570fb9b1f00b", size = 15441463, upload-time = "2026-10-01T20:40:32.412Z" }, + { url = "https://files.pythonhosted.org/packages/31/9c/9e4b049f0ecefbfd6817ca2a16eea55dd74a07950268edc6cffd29ccfe98/mypy-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9f03a7828cca2b0adcd6662aee8f2711ff8830e1027641fdea3ab0b787483566", size = 15705406, upload-time = "2026-10-01T20:40:34.844Z" }, + { url = "https://files.pythonhosted.org/packages/4a/5e/e861b5f6c5ef9ee6cd24683aed1edecf82a26dbd536049f9b7850b586267/mypy-2.4.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9279488933040b638c0ab739084c0ca100efeea6db581bf5d7628d8e89de53fe", size = 7897523, upload-time = "2026-10-01T20:39:39.861Z" }, + { url = "https://files.pythonhosted.org/packages/3a/87/61ffee58b25a956532a6006fae84918a2de849ed25f397492b2e815fe7b3/mypy-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:2106b55105ba5ea9be4f53a24517fc5fa927ff1585edc9bc1a975abb72caef89", size = 11576345, upload-time = "2026-10-01T20:39:41.553Z" }, + { url = "https://files.pythonhosted.org/packages/a1/88/a331c20698971c2ce8d1c30f317fd61b5be13a85b1f053e12dfa22ac6568/mypy-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:528c8744b8b5e3ecb8774f86af38d2376216816e9908317ad055f3c9c2d74799", size = 11172062, upload-time = "2026-10-01T20:38:59.534Z" }, + { url = "https://files.pythonhosted.org/packages/47/c0/4f7daa73270dced8e86c6f4a911c68082d03a84e6c1ec9bd916028d66131/mypy-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0bb95cf34899e4619c61ab0a8667804e139e580b30d5df12af2102dfe44d0c97", size = 15711208, upload-time = "2026-10-01T20:39:30.597Z" }, + { url = "https://files.pythonhosted.org/packages/2f/05/f1afa303c678be24cf7a266d38fb24b3de4599a024c2f9ba0d5905a3efa3/mypy-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86d616fe84c6eab8026f8c50ab5bcb90db780d2ccd233d971e34e92bede9b359", size = 15935442, upload-time = "2026-10-01T20:39:22.781Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d6/6a1a45459b63716e0d035f4892a926d3054f0a8cbfa02551d228a9946083/mypy-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3f86fd1313dd69d013e265f1fdcd12ea7a9d606f9875b2a3db946cd334555f3", size = 17240044, upload-time = "2026-10-01T20:39:32.95Z" }, + { url = "https://files.pythonhosted.org/packages/87/85/ae33bee66c13f98d421964d87bf0888be941063bc75c1204a4cf142cf1cc/mypy-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:720434d48542ecfe84d32d287b727569d3fc8f5769acd39051130e490a5c295c", size = 17545856, upload-time = "2026-10-01T20:39:08.827Z" }, + { url = "https://files.pythonhosted.org/packages/df/de/eeff209b65c3e267818d79333bb9f058da6e4e73761461bd94748a2eb628/mypy-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a6e851b82c0661f69f1630fc16172c68787a6a9cf0991e7c6437d60976cdcd76", size = 12369342, upload-time = "2026-10-01T20:40:10.516Z" }, + { url = "https://files.pythonhosted.org/packages/2b/43/e62d8d5c1dd737aa248968302ff7d6eabf997c3301773ed8bcb64932ca77/mypy-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:3bd0e340f0ebe65c548210f53be3fd8192e83964760caf0c28bef368e68b0d37", size = 11849099, upload-time = "2026-10-01T20:39:51.834Z" }, + { url = "https://files.pythonhosted.org/packages/81/12/46ae8670c98a3cd0286ca5645c2f918f8f6be65edfed81b916010619f668/mypy-2.4.0-py3-none-any.whl", hash = "sha256:d01c5d26a352acc6d5cf3128225477e1e8465e8d3029d4c345807fbf7f3cf093", size = 2800734, upload-time = "2026-10-01T20:39:26.837Z" }, +] + +[[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 = "opentelemetry-api" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "psycopg" +version = "3.3.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/26/3ea4ca5eaea1c0debcdf7ee7c1613fbe721dc27a03c461c0817ffd8a0601/psycopg-3.3.6.tar.gz", hash = "sha256:c081f2250df751a943036e42db6df4571c66cd0aabe8291a7a506512b12007d2", size = 168171, upload-time = "2026-09-18T13:22:55.152Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/de/748bd7609c71cae5d737f0ba9192f19329f70180ecda8fff3cac02c5abe3/psycopg-3.3.6-py3-none-any.whl", hash = "sha256:a1db9f7148b06a28606767efaca51fa6f9398c5c0a3810519be69d7000bdb631", size = 215490, upload-time = "2026-09-18T13:15:29.374Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.6" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/b9/60711317c284a442511644ea7185b56ebe627606d6741e732cd16108c47b/psycopg_binary-3.3.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b3f75dee0f9afafabe4edc52c4842f1e1878ed2069bd05b22d6fe961e97e4dba", size = 4720512, upload-time = "2026-09-18T13:20:29.278Z" }, + { url = "https://files.pythonhosted.org/packages/63/da/28befc84454cbc6374550de7746f591f8fe1b6165c1fce249652cc8291c4/psycopg_binary-3.3.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5927b7ba63153cd8e9862987290a2b783a5c590daf2a4ef981700cc3569166d4", size = 4782318, upload-time = "2026-09-18T13:20:35.401Z" }, + { url = "https://files.pythonhosted.org/packages/a4/8a/0d21c2c833cdc0d4244c77e858e0ed37fa2abec2623be4fd686f617109ce/psycopg_binary-3.3.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0bf08b749cc144f33b44a91b78e3f71c60eb07963746a0df5a100b36ce3d7475", size = 5567460, upload-time = "2026-09-18T13:20:41.902Z" }, + { url = "https://files.pythonhosted.org/packages/49/6d/7692d0d4e656b6cc9868d8acc2e3b42f17a0db4a625400a6d093cb0533a1/psycopg_binary-3.3.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:31cd942c23f613276b81a6e6598cefa12960058b0f46e1e874b540c793f6aca5", size = 5246902, upload-time = "2026-09-18T13:20:47.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/c1/b8a1f18fb1b7558a17f57f7cb3fc8bc93189feea2958925950b3acb15743/psycopg_binary-3.3.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4690cf67738f0e0e49a32aeec99bf0e4595cc2b4f1af984a4345394b1dcff91a", size = 6847192, upload-time = "2026-09-18T13:20:56.874Z" }, + { url = "https://files.pythonhosted.org/packages/a5/76/404f33519167c65cca88ec4998776f1dbebccc301ee977f0e62c47fb0826/psycopg_binary-3.3.6-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ad1c785e784cfd87e8436c6b7702f2d321fc39601bbaf29bc63a41a867091638", size = 5079573, upload-time = "2026-09-18T13:21:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d9/79e8fbc8f37262a415f3550f0bcc5f98037442bf3d12ef6cbae2056655ae/psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:79a2a1c3449f6c3409427078ed1cec10de79f3023cb5f2504f0597d350ad46c7", size = 4613633, upload-time = "2026-09-18T13:21:10.664Z" }, + { url = "https://files.pythonhosted.org/packages/d4/47/96225db74be7d2ce04b3a58678b53cda610225055edf5faa775c9f501d8b/psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:86147cb5d140341c3363fb5bacce31f8d5543902a46699d3c536b101bbceaf9e", size = 4293375, upload-time = "2026-09-18T13:21:16.027Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d2/18e9c779a5efd565250329adaf529ecc2b8b2ed5be5cb0f6ccee208cbfd9/psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7308c93cf0b19bbaf8e6ff0a6ad50d3c442385739245fe15a8d593bf841734a6", size = 4019883, upload-time = "2026-09-18T13:21:21.587Z" }, + { url = "https://files.pythonhosted.org/packages/ef/28/0cc654afc6c2cda982767f5679d3646b30b1ec86545bdaa9402202d6776c/psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:05a83ac9fd52b9bca7cb5ab04b3691163170bd16f53defa27216ea3aa07ee781", size = 4332607, upload-time = "2026-09-18T13:21:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3e/0a753a74fbd7aef120f286c016e09d3cc3f1daf7688f4a145d27281260b2/psycopg_binary-3.3.6-cp314-cp314-win_amd64.whl", hash = "sha256:1fbd30e537dab22cafdf080608f10148fe2a5f3a61294ddb5113caac8a623840", size = 3755671, upload-time = "2026-09-18T13:21:33.855Z" }, +] + +[[package]] +name = "pydantic" +version = "2.14.1" +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/7c/0b/8e10b2e693af8ec54346a14caf36221334775975a747c9ceaa3f8371d96d/pydantic-2.14.1.tar.gz", hash = "sha256:94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26", size = 900362, upload-time = "2026-10-11T18:37:55.396Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl", hash = "sha256:9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454", size = 488106, upload-time = "2026-10-11T18:37:53.437Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.50.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/24/af4ec4be49fbc810f35b0bdcacc3d433b56bbfa469fd3bfd4ae116cd9bf1/pydantic_core-2.50.1.tar.gz", hash = "sha256:e50d7b94baac6c7d09927fa5ca5800a0c7ee5015c7fcff65beb3a1931b5a6e09", size = 513515, upload-time = "2026-10-11T18:35:44.82Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/33/28b96e81677153715e3eafb9f26663a80841e859fde282a380359b0d3fa1/pydantic_core-2.50.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1cf41f1ae3fa155cf167a72689ad044bcc1e3c97e064123677149bdfb5dafc4a", size = 2224428, upload-time = "2026-10-11T18:33:07.153Z" }, + { url = "https://files.pythonhosted.org/packages/94/40/15c06410c9b7b8da5805d27b64e09bd3f900e986728106db2689dbc51513/pydantic_core-2.50.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4df197990c15b5a37c5a277d131d9f2c67de6133f2e5dafd80d9bba4b99f46f9", size = 2023740, upload-time = "2026-10-11T18:33:08.763Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4e/5eb629f6efc2a27e421d789dd4bfacbb5f6d09c80c28b13d1e87d73163d5/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0036473f5583e6a60e50b8b21651511564277a3f05cc5dab8cf579f552cd5f6c", size = 2049435, upload-time = "2026-10-11T18:33:10.366Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8e/f195aebec49ad12318876ac2368c197a7939f5d52f8e156d2238ef8a4588/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:992c3514ec891fa7858099183e4d64e6bd5a5d4ff452fae29df22faa77a006bb", size = 2172975, upload-time = "2026-10-11T18:33:12.368Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f5/7ad9fb83010cd5ea0948409db105676ed779c4e709e2d3d89b9fe2558794/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:739dc730e6be3bd5ec2f4ab5cfc7eb047cc45fc1497b3bafec74ff2ed07df597", size = 2364197, upload-time = "2026-10-11T18:33:14.244Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fb/bf0aab3e78301d202b82a0322ec968cd703b11fc0623fde9a28708936f62/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32fad3a91e51b6d2039c572db04a5a873260b399f6bd62c3552671fa7a4a2899", size = 2444259, upload-time = "2026-10-11T18:33:15.79Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/38f6d6564fae57d9b12e5676dfa947e0e7d5c46dbeaa7eb3352261d6d299/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42b54c2c90ad348b5e3a85e03e715d572c1fde357ef104cdfe3b03b697a404ea", size = 2159857, upload-time = "2026-10-11T18:33:17.51Z" }, + { url = "https://files.pythonhosted.org/packages/65/a0/fb0a3ca10f139dcf765b2d312d10cf0f65c57c59993229d00ad12b14ceff/pydantic_core-2.50.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:2df1ff41884de2bc4b307bafd7c40a691094fad2ff8e767e5b45a319257bcf4e", size = 2233954, upload-time = "2026-10-11T18:33:19.591Z" }, + { url = "https://files.pythonhosted.org/packages/8c/6a/e63842252702aa4ec6e6b7178ca85e541a77459076592c23ebe2d9840b33/pydantic_core-2.50.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe90228920fd8ff2be62622b6bb8a2b11acd65046d50c6b130614b5879605a20", size = 2243876, upload-time = "2026-10-11T18:33:21.393Z" }, + { url = "https://files.pythonhosted.org/packages/39/25/5991cf8318b37e0dfab47b87541619a1df8501a793cba0d978846cba37a7/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:844b869f118e22a41a091bdcedda8a71bc1b0f62c38d1a0c3211cece47e1d8fc", size = 2277908, upload-time = "2026-10-11T18:33:23.324Z" }, + { url = "https://files.pythonhosted.org/packages/a7/3e/3ee8baaa6cc25a6961c69168cf9ff0f002d56f4e0d541ad6724c18fb61f3/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:2eb75304506894a281d346220a4f7481a1b8729577c5ed2a05395991966a8396", size = 2449285, upload-time = "2026-10-11T18:33:24.942Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/acbec6deac13fe697a80c275a9b6661db62c4d323bac8a47347b1f39c7cd/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6b20a4bffabdad0db2927ac034ae3b8a681b1f7a0182f3e60b479ad2fde21ebb", size = 2479013, upload-time = "2026-10-11T18:33:26.925Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/21a3f237b264389f6219d053ee78fc1b5c2fd402c1b1cb2b6cb8b85f9834/pydantic_core-2.50.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:99ba9bc2b8062ea0c326a990f7f00e6530c23579de66dd246e72c4cafef950a5", size = 1484400, upload-time = "2026-10-11T18:33:28.693Z" }, + { url = "https://files.pythonhosted.org/packages/09/ce/077a6d262d12ef09108377ac0717f029f420d773ace63cafd6143af75568/pydantic_core-2.50.1-cp314-cp314-win32.whl", hash = "sha256:cf356f70551d40374eaffb1aa63f1eb6d2006681cbd7a9faea173ce0f4dd7cd2", size = 2103222, upload-time = "2026-10-11T18:33:30.326Z" }, + { url = "https://files.pythonhosted.org/packages/14/4c/350a2415209c43d670eb71d3c040f332c04a30583d39e311b05c7ac15762/pydantic_core-2.50.1-cp314-cp314-win_amd64.whl", hash = "sha256:d32f3acc081cc3923386d88f422cde8892335e95f034e0104bb4cf9310d9915f", size = 2171586, upload-time = "2026-10-11T18:33:32.139Z" }, + { url = "https://files.pythonhosted.org/packages/bf/92/9bea6ca96580a0902fed366f064f0829e404f41889b34543279a1162b888/pydantic_core-2.50.1-cp314-cp314-win_arm64.whl", hash = "sha256:bed5163e03b98bc1fa2eb05d74c63d9c5c95d8ed6254985481640fbf5e237dea", size = 2155773, upload-time = "2026-10-11T18:33:33.896Z" }, + { url = "https://files.pythonhosted.org/packages/86/8c/f121f073cf32bdd5cba7e6230b1ce0ac845b0cf43e44dd597d95af272db2/pydantic_core-2.50.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:9572c1369e9c9da2d64a7b7992c786d90ff295abc93964cfe3125e4290768070", size = 2234017, upload-time = "2026-10-11T18:33:35.588Z" }, + { url = "https://files.pythonhosted.org/packages/a4/69/2bb2bcd6146cffe98d760a46c42ae71efd5151d9b2f9c9bf6619a3b32083/pydantic_core-2.50.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2005207aafe1231315718bf6ed5d064a7300fb4772754af35ee72fc68159492e", size = 2001016, upload-time = "2026-10-11T18:33:37.57Z" }, + { url = "https://files.pythonhosted.org/packages/20/b3/fbf854c7d07ec114260c26e9e2071a4381740f9ae09641dbfcbdf2a18c45/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64f6047f62a6c5ae08d0a6afb035667aa2d97c3d20d69762e034c5ea144d92a5", size = 2023015, upload-time = "2026-10-11T18:33:39.433Z" }, + { url = "https://files.pythonhosted.org/packages/65/20/6de55b2f92cdb614b745c6e9ced639fc4fd7e1e77604825a88bdece6fcbd/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ef800dd7d85bcdadf4c3076e4c94e43939493558a3b69a1ea830c706d4617bb", size = 2192543, upload-time = "2026-10-11T18:33:41.381Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d9/19e91c94bd5c405945ce2f15526808aea37e162c253160de8ed7bf70b406/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0135bcdcaa0f23573f286e4cb5e0fd2962700964ed13df085b85f2b97aeab9e", size = 2388704, upload-time = "2026-10-11T18:33:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/b6/bc/2e24c8415eae1a25ee5a5484946a917123bad2e9d01689a759236928175a/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b3a6f334c6a2345ca15318ff894502a90012536404b37c844a976c76c846e0b", size = 2464118, upload-time = "2026-10-11T18:33:44.856Z" }, + { url = "https://files.pythonhosted.org/packages/bd/1f/945b8053cb061c64e102bcaf7bfb9ed740c0bd4349f9bb978a7d4ddff4ab/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06e01fbbfdb9be777b316a71b6c49efaf4a08b615d0a98d678cda3023f79d019", size = 2150142, upload-time = "2026-10-11T18:33:46.661Z" }, + { url = "https://files.pythonhosted.org/packages/2b/78/96a3e50bf0d64aaae781107eb9335b7529d05a85793ed6e7241c4cd1d931/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a29a061fec0b4e2d714f277e70a3a18125ecff803f2fea6eade2f2e53711d112", size = 2245577, upload-time = "2026-10-11T18:33:48.574Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5d/a6038a0322232758a6ebfa709f4ca14a60bf5b93940cd0b345056a557da4/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f5187624823423e1d1b82b1072ac41dc837389e18d3d0572cc19bbee46cd550a", size = 2263728, upload-time = "2026-10-11T18:33:50.527Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4b/76ded3333a457a9344c4f2d63f2d82d0101654b05178fa4ddfe7d3627674/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3e46a9eb0a0901dd6275e6b06ac3a464885ef350ec4121fe486869de8053e4bb", size = 2293630, upload-time = "2026-10-11T18:33:52.362Z" }, + { url = "https://files.pythonhosted.org/packages/a9/00/9eca378335c9c1b72bc779bf6e4c2a82ce784f14ec48a0e87102c9870e05/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:756d669f04e62ec4148ecfe22be6a4484d9b1181a6ef32e205ebfd200540858b", size = 2468048, upload-time = "2026-10-11T18:33:54.118Z" }, + { url = "https://files.pythonhosted.org/packages/35/ca/e3832e9cf93651251de43c8c5c029ed680a3c1a0a1b17ee26c81bed08cbd/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:c516cc5367ca3448995d42cb994bf3f4c9002d2a7c22eac9622551269ad1b807", size = 2512159, upload-time = "2026-10-11T18:33:55.99Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f2/773469b5a10a39116a2cb17edaf6d722f017dd8da07161b371465311c542/pydantic_core-2.50.1-cp314-cp314t-win32.whl", hash = "sha256:9d1bed94af6a63835461f3cf7502058eb166c58c4778e11d0f433cfb1bd69e19", size = 2098406, upload-time = "2026-10-11T18:33:58.043Z" }, + { url = "https://files.pythonhosted.org/packages/ee/42/0bb74f8f25204b259b11ab7c12dc7f180893b6bd706118b385147fb6efd5/pydantic_core-2.50.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c8dce1f1e0e5358b682a6ad3fa5e31b31d4560997b8e61417e9217c8d60f8a0c", size = 2148481, upload-time = "2026-10-11T18:33:59.913Z" }, + { url = "https://files.pythonhosted.org/packages/47/0d/d801646c9679a4e630e15cf521d4108b93854b42a6e6e02391bd4c6b1095/pydantic_core-2.50.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ceff0acc940be2715bd6ad17b24c0e5304abf44f6efd0f81ee8499e640f9dc86", size = 2174972, upload-time = "2026-10-11T18:34:01.875Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/3b/a5d2294799b53b448319978cfb5bd139d5a9d45e862af91661614f14c922/pydantic_settings-2.16.0.tar.gz", hash = "sha256:5b6c578049ede4db0e2ef3b4eaa4ad4069cfa9211f83fb38df899dfade50a614", size = 301031, upload-time = "2026-10-14T12:44:09.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/f4/b987bf8c51e5b19a95fa66d1ee596074141e085d9c2ddf97920803c7029b/pydantic_settings-2.16.0-py3-none-any.whl", hash = "sha256:7e73acf7f61936a15e5a3b6eedaea29f133357faf7272f2607ba479b049dd7f2", size = 80364, upload-time = "2026-10-14T12:44:08.233Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/26/2fbeedb218a787a5eea551c7532cac4e009f83d689dd2faa0d0353473f86/python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0", size = 60824, upload-time = "2026-10-01T05:36:10Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/bb/28e3106ee586e5b2f5ecf211eec1348055f0f926314b13fe23b9cffa9039/sqlalchemy-2.1.5.tar.gz", hash = "sha256:0df76cf1ca680ad745bd378a6299a058611a8a784eeaeb4ed78ad4a0b7ce2003", size = 10569593, upload-time = "2026-10-16T20:52:20.57Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/b0/59b26375cd618e861a9295d855b866ca85be654d20ed47e494e33c3bf26e/sqlalchemy-2.1.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5541a110ac4bb6377c785aea1b8f0b4d6658ed0ca5061ed1115d0725062b026b", size = 2468925, upload-time = "2026-10-16T22:42:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/9e/a1/01931ba38770f169ce70e4034b0dcf2c1a776fe7d8dde93a34b6355510b1/sqlalchemy-2.1.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f029b2f7114623c03f762596f313515a2358004c44eb18924a15940f2e27d30f", size = 4593220, upload-time = "2026-10-16T22:55:29.253Z" }, + { url = "https://files.pythonhosted.org/packages/42/9e/fa478482503a69dfb3b0cb82f3f80fe3816026530dd34b5359e2acd9f30d/sqlalchemy-2.1.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc44dcc80e89a006af415edffc7409b1f2976a4597065449c166987f95801ab4", size = 4622806, upload-time = "2026-10-16T22:59:27.153Z" }, + { url = "https://files.pythonhosted.org/packages/fc/01/357ff85423013de8316316752c36a6bd1b7f3830c30dc63754afe06ba589/sqlalchemy-2.1.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:72ddd7a8ec187d02de0ef316a15aaf381ddcc594958247b02a3dcdb4c42f0b02", size = 4316247, upload-time = "2026-10-16T23:15:14.27Z" }, + { url = "https://files.pythonhosted.org/packages/43/53/88c5af325c81c929d0ed5b7ec15b087173bd3587d93366c5e336b9647267/sqlalchemy-2.1.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f5173a6e60a15090f9e2348a8ca94cc13de72796c16733f3b62fbb22acba07b8", size = 4522522, upload-time = "2026-10-16T22:55:30.835Z" }, + { url = "https://files.pythonhosted.org/packages/87/29/f6f01f121d5e590c568f405f78b8eb3edace446c114126fc428536446dd6/sqlalchemy-2.1.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:09b10bd68223d57cce39c08947efae3f91ac40b552e07c7c869c3350a0fc4c23", size = 4317593, upload-time = "2026-10-16T23:15:16.397Z" }, + { url = "https://files.pythonhosted.org/packages/0c/6f/14863fc9aea433645851330242823b1879cdaac71c118f7c9366e7caf4da/sqlalchemy-2.1.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ea4cb5022462d9f15e7b72a52c267385aa97ac224c8503ac63ba90c0067ad0d9", size = 4583207, upload-time = "2026-10-16T22:59:29.322Z" }, + { url = "https://files.pythonhosted.org/packages/a2/e9/6907fc009d9d06e8d03d6bf286df46e878ea2a85c839dcb65ef11ceb3a39/sqlalchemy-2.1.5-cp314-cp314-win32.whl", hash = "sha256:c3ffbe6c680c626403b7e9697d92465501078731b9e8863b1410826e66b950e1", size = 2386018, upload-time = "2026-10-16T22:48:40.692Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d6/dea47b4b361c427d390edc10d73050e6d11d905c56ef26861ff5f94aa8fa/sqlalchemy-2.1.5-cp314-cp314-win_amd64.whl", hash = "sha256:061d089901319463a1160e4596191e355d2a9b4cce4852dac5adc301b085ac2f", size = 2436983, upload-time = "2026-10-16T22:48:42.2Z" }, + { url = "https://files.pythonhosted.org/packages/be/28/305a91768a1ca0980c6ead455dcda5a2c1e616022901898e966ccf913ba9/sqlalchemy-2.1.5-cp314-cp314-win_arm64.whl", hash = "sha256:f5bc7fb93f1c17efefeb062bc4d2742ad1b71d56c36e6e2898a371ac6a56839a", size = 2401394, upload-time = "2026-10-16T22:31:45.275Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6d/d4bab269d94ed1017950873643b5bd3eb271a5b8d5b0451250f0173f90fc/sqlalchemy-2.1.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9bb993b1d0890a2da90589684c3d09de75df82c06aa8f7eda246c7cd0ea3e820", size = 2508315, upload-time = "2026-10-16T22:55:01.45Z" }, + { url = "https://files.pythonhosted.org/packages/d9/80/aeab72b51fceb71b1e1d62cabc6f51c9d8f20588165aa54e0d0d6cc77c05/sqlalchemy-2.1.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df7e71ed051e4003e73484ef6547aacfb3a2322aac0c230d530c97e48e77b4d7", size = 4924848, upload-time = "2026-10-16T22:41:10.671Z" }, + { url = "https://files.pythonhosted.org/packages/05/8b/7318ca0ae828fa1605dd4481f8eeecb5325a7ead6289c1206fb1ff1e2e1d/sqlalchemy-2.1.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c76d4cb9c76a6906563133449a76d9eae6c8529ca09484b8615ca604442b442e", size = 4835557, upload-time = "2026-10-16T23:06:18.482Z" }, + { url = "https://files.pythonhosted.org/packages/35/17/7a5a196b0af72d65cbcd3a6fb7c4128693d039faf58a682ffe2d19329420/sqlalchemy-2.1.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:59a56be2e4e972061f9d5c1d5b01763b8b2207b48b956fbb6879c7443f5817f7", size = 4513707, upload-time = "2026-10-16T23:28:18.969Z" }, + { url = "https://files.pythonhosted.org/packages/91/50/ca323ed15fc06c73a7fd85a3a339749994c560c12312ee8a53abf0f9a7ec/sqlalchemy-2.1.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc1a61d7f42e71746b5a2e10e90a6b174b4add441b7b6cf14278f1e029b2b800", size = 4781127, upload-time = "2026-10-16T22:41:12.784Z" }, + { url = "https://files.pythonhosted.org/packages/26/3d/c56a75d0a2f6af08f6bb28d0b69e129911a2fc52b24dbafd7a7e5ac5c1aa/sqlalchemy-2.1.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:740e96dfb99a615c8056070d3747efe8bce4c145e5953ccc9e4b004c139de0d2", size = 4513816, upload-time = "2026-10-16T23:28:21.378Z" }, + { url = "https://files.pythonhosted.org/packages/7d/dd/84e0cd627bbd3e35b5f033191a4f597e611ff96e424db36a444a8d6755cc/sqlalchemy-2.1.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:92a0bb0595331c8e40bc615a67cc7bca9b761487409d1bf46c598b6ba5c0754d", size = 4772462, upload-time = "2026-10-16T23:06:20.876Z" }, + { url = "https://files.pythonhosted.org/packages/b7/47/cf0a920fc40344f8a1806d661e7c702341aebd871e31e1d1d6e0fbc9d1e0/sqlalchemy-2.1.5-cp314-cp314t-win32.whl", hash = "sha256:11185c72b5372369166cb721537943f8e1e6e42a399e9bac19e02f3ee20592a4", size = 2444861, upload-time = "2026-10-16T22:51:51.169Z" }, + { url = "https://files.pythonhosted.org/packages/bc/1d/4c5dc7e30fe740a01df4ba5ab800be2d7674b87f042e4c21c7ca89b36f9d/sqlalchemy-2.1.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5173207b8fe1ddfc1d451c420a2a9cbbd0734b4a19896b8080e3744497e1c20c", size = 2512497, upload-time = "2026-10-16T22:51:53.014Z" }, + { url = "https://files.pythonhosted.org/packages/5f/2f/d3e9bfb1faabea302b98ba8c9dd6252bc362c6afeabe28ffcc1eea8a6bd2/sqlalchemy-2.1.5-cp314-cp314t-win_arm64.whl", hash = "sha256:def2f117a6ec0a25202ee36a148bbcfad3d43e2f2bb2e698e6d4448c6db5cb19", size = 2430598, upload-time = "2026-10-16T22:32:54.554Z" }, + { url = "https://files.pythonhosted.org/packages/de/e9/61176e4753f74ab5f6e72a56314ee654d25c31e6fc98c99060442998cccb/sqlalchemy-2.1.5-py3-none-any.whl", hash = "sha256:2f77b77b716f005b2dc3cc059d56df6a919bfb5b439e4149890a9df6c36be9e3", size = 2060214, upload-time = "2026-10-16T22:43:30.801Z" }, +] + +[package.optional-dependencies] +asyncio = [ + { name = "greenlet" }, +] + +[[package]] +name = "starlette" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/eb/4e197bf26b023abafc5e9043ec8710d7de0fcb032d0969045236d72aa0b1/starlette-1.9.0.tar.gz", hash = "sha256:3d931de246ed870e35ce605974440816ef895e592f255d1396cfdf191d4c4318", size = 2732466, upload-time = "2026-10-18T15:50:33.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/93/181df62699d9b5aca1d87e5cfda131dac87e3cdaebf668662f7206ca3470/starlette-1.9.0-py3-none-any.whl", hash = "sha256:fd3f159936e882d06c1108f84688f7cda2cf85fc6a592a50edbd175e882c2ddc", size = 80335, upload-time = "2026-10-18T15:50:31.526Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/68/f1b440335057bfce71b6e50a9d09445aa2ecbd08359a337976627b8409e7/tzdata-2026.5.tar.gz", hash = "sha256:8cc73c0a0bfca7dbfa59235d60b2eff82231dee33f53d206db1acd9173cfc0a7", size = 200404, upload-time = "2026-10-03T09:23:14.143Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/21/1e5995a1c920cce14e4bffae20c665ec10e7ed03ab25e006cd741092b718/tzdata-2026.5-py2.py3-none-any.whl", hash = "sha256:b683bd1b6659ddcd810ff02ad09ba821d4bf1065072805063eb35c49617905ac", size = 347996, upload-time = "2026-10-03T09:23:12.535Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.54.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" }, +] diff --git a/compose.override.yaml b/compose.override.yaml new file mode 100644 index 0000000..33b2efd --- /dev/null +++ b/compose.override.yaml @@ -0,0 +1,50 @@ +# Local settings for your laptop, merged automatically on top of compose.yaml +# whenever you run `docker compose ...` (or `mise run dev`) in this folder. +# +# Differences from the deployed setup: +# - images are built from the `local` stage of each Dockerfile +# - source folders are bind-mounted, so edits on your machine hot-reload +# - ports are published to localhost +# - a .env file is required (cp .env.example .env) + +services: + postgres: + environment: + # ":?" stops every `docker compose` command with this message until .env exists. + POSTGRES_USER: "${SERVICE_USER_POSTGRES:?not set. Create your .env file: cp .env.example .env}" + POSTGRES_PASSWORD: "${SERVICE_PASSWORD_POSTGRES:?not set. Create your .env file: cp .env.example .env}" + POSTGRES_DB: "${POSTGRES_DB:?not set. Create your .env file: cp .env.example .env}" + ports: + # For mise db:* tasks and database tools on your machine (psql, TablePlus, DBeaver). + # Set POSTGRES_PORT in .env if something else already uses 5432. + - "127.0.0.1:${POSTGRES_PORT:-5432}:5432" + + backend: + build: + target: local + environment: + LOG_FORMAT: text + volumes: + - ./backend:/app + ports: + # The API docs live at http://localhost:8000/api/v1/docs. The frontend reaches + # the API through Vite's proxy at http://localhost:5173/api/v1. + - "127.0.0.1:8000:8000" + healthcheck: + # First start installs packages, which can take a minute. + start_period: 120s + + web: + build: + target: local + volumes: + - ./frontend:/app + # Keeps the container's Linux node_modules separate from the one on your machine. + - /app/node_modules + ports: + - "127.0.0.1:5173:5173" + # In dev the frontend does not need to wait for the backend to be healthy. + depends_on: !reset {} + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:5173/"] + start_period: 120s diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..73cce31 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,69 @@ +# Deployed stack: what runs in every deployed environment (e.g. on Coolify). +# +# Locally, `docker compose up` also merges compose.override.yaml on top of this +# file automatically. A deploy platform that reads only this file never sees it. +# +# Variables like ${SERVICE_PASSWORD_POSTGRES:-app}: +# - on Coolify: SERVICE_* are "magic" variables; Coolify generates a random +# value once per environment and keeps it across deploys. +# - locally: values come from your .env file, which compose.override.yaml +# requires. The value after ":-" is only used by `mise run deploy:check`. + +name: python-react-starter + +services: + postgres: + image: postgres:18.6-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${SERVICE_USER_POSTGRES:-app} + POSTGRES_PASSWORD: ${SERVICE_PASSWORD_POSTGRES:-app} + POSTGRES_DB: ${POSTGRES_DB:-app} + volumes: + # Postgres 18+ images store data under /var/lib/postgresql/<major>/docker + - postgres-data:/var/lib/postgresql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 10s + + backend: + build: + context: ./backend + target: deploy + restart: unless-stopped + environment: + DATABASE_URL: postgresql://${SERVICE_USER_POSTGRES:-app}:${SERVICE_PASSWORD_POSTGRES:-app}@postgres:5432/${POSTGRES_DB:-app} + LOG_LEVEL: ${LOG_LEVEL:-INFO} + # One JSON object per line, so a log service can parse them. + LOG_FORMAT: json + depends_on: + postgres: + condition: service_healthy + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/v1/health', timeout=4)"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + + web: + build: + context: ./frontend + target: deploy + restart: unless-stopped + depends_on: + backend: + condition: service_healthy + healthcheck: + # 127.0.0.1, not localhost: in Alpine, localhost resolves to IPv6 first, and nginx listens on IPv4. + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:8080/"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + +volumes: + postgres-data: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..f06235c --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/frontend/.oxfmtrc.json b/frontend/.oxfmtrc.json new file mode 100644 index 0000000..633cf15 --- /dev/null +++ b/frontend/.oxfmtrc.json @@ -0,0 +1,4 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "ignorePatterns": ["dist/**", "src/client/**"] +} diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 0000000..53606ae --- /dev/null +++ b/frontend/.oxlintrc.json @@ -0,0 +1,17 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript", "unicorn", "oxc", "react", "import"], + "categories": { + "correctness": "error", + "suspicious": "warn" + }, + "rules": { + "react/rules-of-hooks": "error", + "react/exhaustive-deps": "warn", + "react/react-in-jsx-scope": "off" + }, + "env": { + "browser": true + }, + "ignorePatterns": ["dist/**", "src/client/**"] +} diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..f6c190d --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,35 @@ +# syntax=docker/dockerfile:1 + +# Node and pnpm versions are pinned here and in mise.toml. Keep them in sync. +FROM node:24.22.0-alpine AS base +RUN npm install --global pnpm@12.14.0 +WORKDIR /app + +# --------------------------------------------------------------------------- +# local: your laptop only, selected by compose.override.yaml. Source code comes +# from the bind mount; node_modules is a separate volume so it never clashes +# with the one on your machine. Installs packages, then runs the Vite dev +# server (hot reload). +# --------------------------------------------------------------------------- +FROM base AS local +EXPOSE 5173 +CMD ["sh", "-c", "pnpm install --frozen-lockfile && exec pnpm exec vite"] + +# --------------------------------------------------------------------------- +# build: type-check and bundle the app into static files in dist/ +# --------------------------------------------------------------------------- +FROM base AS build +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile +COPY . . +RUN pnpm run build + +# --------------------------------------------------------------------------- +# deploy: every deployed environment, selected by compose.yaml. nginx serves +# the static files and forwards /api requests to the backend container. Runs +# as a non-root user, so it listens on 8080 instead of 80. +# --------------------------------------------------------------------------- +FROM nginxinc/nginx-unprivileged:1.30.6-alpine AS deploy +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 8080 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..7b5110a --- /dev/null +++ b/frontend/index.html @@ -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>Python React Starter + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..63d7d82 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,50 @@ +# Web server for the deployed frontend container. Locally, the Vite dev server +# does the same /api forwarding (see vite.config.ts), so nginx only runs here. + +# Access log: one JSON object per request on stdout, matching the backend's JSON +# logs so a log service can parse both. Requests from 127.0.0.1 (the container's +# own health check) are not logged. +log_format json escape=json + '{"time":"$time_iso8601","method":"$request_method","path":"$request_uri",' + '"status":$status,"bytes":$body_bytes_sent,"duration_s":$request_time,' + '"client":"$http_x_forwarded_for","user_agent":"$http_user_agent"}'; +map $remote_addr $loggable { + 127.0.0.1 0; + default 1; +} + +server { + listen 8080; + server_name _; + root /usr/share/nginx/html; + index index.html; + access_log /var/log/nginx/access.log json if=$loggable; + + # Docker's internal DNS. Looking up "backend" per request (via a variable) + # means nginx keeps working when the backend container is recreated. + resolver 127.0.0.11 valid=10s; + set $backend http://backend:8000; + + # Forward API calls to the backend container. Same origin as the app, so no CORS. + location /api/ { + proxy_pass $backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; + } + + # Vite puts hashed, never-changing files in /assets: cache them for a long time. + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + # Everything else is the single-page app. Unknown paths fall back to + # index.html so client-side routes (e.g. /settings) work on page refresh. + location / { + add_header Cache-Control "no-cache"; + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/openapi-ts.config.ts b/frontend/openapi-ts.config.ts new file mode 100644 index 0000000..becaa32 --- /dev/null +++ b/frontend/openapi-ts.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "@hey-api/openapi-ts"; + +// Generates a typed client from the backend's contract. Run `mise run client:generate`. +export default defineConfig({ + input: "../backend/openapi.json", + output: "src/client", + plugins: ["@hey-api/client-fetch"], +}); diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..db25e0a --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,32 @@ +{ + "name": "frontend", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "generate:client": "openapi-ts", + "lint": "oxlint --type-aware --deny-warnings", + "lint:fix": "oxlint --type-aware --fix", + "format": "oxfmt --check", + "format:fix": "oxfmt", + "typecheck": "tsc -b" + }, + "dependencies": { + "react": "19.3.1", + "react-dom": "19.3.1" + }, + "devDependencies": { + "@hey-api/openapi-ts": "0.99.0", + "@types/node": "24.19.2", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "@vitejs/plugin-react": "6.1.2", + "oxfmt": "0.73.0", + "oxlint": "1.88.0", + "oxlint-tsgolint": "7.0.2004", + "typescript": "6.0.3", + "vite": "8.3.4" + }, + "packageManager": "pnpm@12.14.0" +} diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 0000000..a782979 --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,1607 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.14.0 + version: 12.14.0 + +packages: + + '@pnpm/exe.android-arm64@12.14.0': + resolution: {integrity: sha512-+iSh7x21C3A2hqc+Z0ZmbKTcn6YNlzrXM142PsnQH/dLj0DYxFzoBvc6XAnL/yfQ5nnH2ebBiRj/zVxr61fZbQ==} + cpu: [arm64] + os: [android] + + '@pnpm/exe.android-x64@12.14.0': + resolution: {integrity: sha512-qT+0LqEr2wBVaxcG2T8cX/R8SSRz2Rvpp8nRR4fh8tnGUZRch87gFmsHUAoSQh7w6j1h0qTjrA384j4xi8frvw==} + cpu: [x64] + os: [android] + + '@pnpm/exe.darwin-arm64@12.14.0': + resolution: {integrity: sha512-tnrdUB8ecJoehuKKqi1YHCBVl61xtJ9XVGcx5EviC6fWO4DeGI8GtXrjvJxDBM3nuVjJeWistgsUp/N142x4sw==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.14.0': + resolution: {integrity: sha512-vlBfGkruqQRsEHTW43cPwpYd7QlbIJ//cvpAdb3bVXkI8SgTPoXhCnc05m+ZNiPrrEkBsX/t7KHw9aXpkzPm3Q==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.freebsd-x64@12.14.0': + resolution: {integrity: sha512-Qg5rxBrGYl6Tfso+EyygPf84fY1Fzy55E/0or+iuYKtnf0vc0MlHJfVIlT7pBukMRntQHRik7hUi0Z4s7AlJsw==} + cpu: [x64] + os: [freebsd] + + '@pnpm/exe.linux-arm64-musl@12.14.0': + resolution: {integrity: sha512-DRIe4yQkXBGA/PhVJdzlbnllxBdAtnMMvTVLLcGpsK5FvJz9kxD2Hyxtn68ovmNkVX06f6p45cQwfm3wx2jn1A==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.14.0': + resolution: {integrity: sha512-7joGShZ44Nk3WpNU+60JoOPjCXlgxhJAq11S2UiVtwCUDgTer1yTPPfxGW8pxaD6046actYLIYDbZNlubHWs0A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-ppc64@12.14.0': + resolution: {integrity: sha512-gSoryn7ySdXd//gjgf3MvvA8ZerJayaSbuMZMtAWq4vb2/wlVEgpl0Kvqt54y58TRj533Qjq2txKgnxd4JyoAQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-riscv64@12.14.0': + resolution: {integrity: sha512-EMjew1H9WqQ7pL+tYqCPwiuB+0PeCiJRV7fyWeuFw0wwYUajFBjcaPO3IGAVu9sdmJDU+e+HYv/Zey6aKae0Vw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-s390x@12.14.0': + resolution: {integrity: sha512-shYOj/SFoXD35fHe0pWr2JofwSXulC0BnEIXLj2QokDi0EF//AfKsui35yKIkTEl+s2VJybc3PO4iJNhKIj5Qw==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.14.0': + resolution: {integrity: sha512-btZiZ2Ot0llKK1ndSfA5i3IRX7/S0VrmzseNH/BJ4c+Cady+S81H6vvU1zByggHMyqQhC8OgxO5PiIY7D9bH0Q==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.14.0': + resolution: {integrity: sha512-F6K8e/jWxiuEYJaQtRRUG9ynBSTEHa+t7Lf86MKuOO5UA7WVVMbi+oB8nY6iKW++ncWS2yG6T9faXZGxqNsfoQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.14.0': + resolution: {integrity: sha512-DOjlkVB6TtTKeAAe0hNZXn5S7YqESmo1Z6SGbR4BZNQ4isRg31XVHjX2RFTbY8gnZq68IwzyLmPqlEjMmYxofg==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.14.0': + resolution: {integrity: sha512-/LSX6mew/sLf08XqcjemuklNKf1K/Ftpgj4//hK/HKzI2dKbmxDB27a/2OaQ5a0O3/3OFzlssXDziZsi+yVBwQ==} + cpu: [x64] + os: [win32] + + pnpm@12.14.0: + resolution: {integrity: sha512-wYum2HmT09Fky16JSPMrLAbuhUSH4GzY2GeBNC+z5btGNDZwNHEehoArqpw3vqkGzhkprQjXCITJfYAd8TOJpg==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.android-arm64@12.14.0': + optional: true + + '@pnpm/exe.android-x64@12.14.0': + optional: true + + '@pnpm/exe.darwin-arm64@12.14.0': + optional: true + + '@pnpm/exe.darwin-x64@12.14.0': + optional: true + + '@pnpm/exe.freebsd-x64@12.14.0': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.14.0': + optional: true + + '@pnpm/exe.linux-arm64@12.14.0': + optional: true + + '@pnpm/exe.linux-ppc64@12.14.0': + optional: true + + '@pnpm/exe.linux-riscv64@12.14.0': + optional: true + + '@pnpm/exe.linux-s390x@12.14.0': + optional: true + + '@pnpm/exe.linux-x64-musl@12.14.0': + optional: true + + '@pnpm/exe.linux-x64@12.14.0': + optional: true + + '@pnpm/exe.win32-arm64@12.14.0': + optional: true + + '@pnpm/exe.win32-x64@12.14.0': + optional: true + + pnpm@12.14.0: + optionalDependencies: + '@pnpm/exe.android-arm64': 12.14.0 + '@pnpm/exe.android-x64': 12.14.0 + '@pnpm/exe.darwin-arm64': 12.14.0 + '@pnpm/exe.darwin-x64': 12.14.0 + '@pnpm/exe.freebsd-x64': 12.14.0 + '@pnpm/exe.linux-arm64': 12.14.0 + '@pnpm/exe.linux-arm64-musl': 12.14.0 + '@pnpm/exe.linux-ppc64': 12.14.0 + '@pnpm/exe.linux-riscv64': 12.14.0 + '@pnpm/exe.linux-s390x': 12.14.0 + '@pnpm/exe.linux-x64': 12.14.0 + '@pnpm/exe.linux-x64-musl': 12.14.0 + '@pnpm/exe.win32-arm64': 12.14.0 + '@pnpm/exe.win32-x64': 12.14.0 + +--- +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + react: + specifier: 19.3.1 + version: 19.3.1 + react-dom: + specifier: 19.3.1 + version: 19.3.1(react@19.3.1) + devDependencies: + '@hey-api/openapi-ts': + specifier: 0.99.0 + version: 0.99.0(typescript@6.0.3) + '@types/node': + specifier: 24.19.2 + version: 24.19.2 + '@types/react': + specifier: 19.3.0 + version: 19.3.0 + '@types/react-dom': + specifier: 19.3.0 + version: 19.3.0(@types/react@19.3.0) + '@vitejs/plugin-react': + specifier: 6.1.2 + version: 6.1.2(vite@8.3.4(@types/node@24.19.2)(jiti@2.7.0)) + oxfmt: + specifier: 0.73.0 + version: 0.73.0 + oxlint: + specifier: 1.88.0 + version: 1.88.0(oxlint-tsgolint@7.0.2004) + oxlint-tsgolint: + specifier: 7.0.2004 + version: 7.0.2004 + typescript: + specifier: 6.0.3 + version: 6.0.3 + vite: + specifier: 8.3.4 + version: 8.3.4(@types/node@24.19.2)(jiti@2.7.0) + +packages: + + '@hey-api/codegen-core@0.9.1': + resolution: {integrity: sha512-s97jL1dgTMuiMHv2BZ1X4Tgd99Mf9GOvGdNqNcGwIMmnR+PgYNoraj4Zvp134MKsNCap/m7k0r0vKKnl56pj4w==} + engines: {node: '>=22.18.0'} + + '@hey-api/json-schema-ref-parser@1.4.4': + resolution: {integrity: sha512-otmd+zCxbYVBIp/mlMTnGkvlNYLkVKgs3VOIq0kSnenhB1+fRwLPQIeSwyWM6E51oXhUedkYjVsVpkVexeuJOA==} + engines: {node: '>=22.18.0'} + + '@hey-api/openapi-ts@0.99.0': + resolution: {integrity: sha512-SePU/5oEWWkvUBYmvzdYRctseoLuskyhs4ET0RvLIcmzc8yLQoA2R+KtBIQ8bPsoSUB0m4E5SmBnl6aGSA0szQ==} + engines: {node: '>=22.18.0'} + hasBin: true + peerDependencies: + typescript: '>=5.5.3 || >=6.0.0 || 6.0.1-rc' + + '@hey-api/shared@0.5.0': + resolution: {integrity: sha512-JN/j4Ebh4cJGYIQ5cwWuqe7GeSUyQoz7oC51WqyhKOcrejK6DKZMDkshc5d1eKTRuRL+rjozuRcoUaZZn2DGPw==} + engines: {node: '>=22.18.0'} + + '@hey-api/spec-types@0.2.0': + resolution: {integrity: sha512-ibQ8Is7evMavzr8GNyJCcTg975d8DpaMUyLmOrQ85UBdy1l6t1KuRAwgChAbesJsIlNV6gjmlXruWyegDX18Fg==} + + '@hey-api/types@0.1.4': + resolution: {integrity: sha512-thWfawrDIP7wSI9ioT13I5soaaqB5vAPIiZmgD8PbeEVKNrkonc0N/Sjj97ezl7oQgusZmaNphGdMKipPO6IBg==} + + '@jsdevtools/ono@7.1.3': + resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} + + '@lukeed/ms@2.0.2': + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + engines: {node: '>=8'} + + '@oxc-project/types@0.154.0': + resolution: {integrity: sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==} + + '@oxfmt/binding-android-arm-eabi@0.73.0': + resolution: {integrity: sha512-dhunAdMbi/4o0Y66/+Wl6rpsNqpvE4i26Qw6bB77A97L2lJoktWg6m+X9QOon8iOEEqzD9rEZqFi5uZ4oIzhCA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxfmt/binding-android-arm64@0.73.0': + resolution: {integrity: sha512-dBoypjC2/xeyKyIhN2waX0/XeoccH9k0lVVe+sGRc4X8HSJVRtqyZDoXdr7bGY9A75c51dX0z0dLDpGQKwCdCg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxfmt/binding-darwin-arm64@0.73.0': + resolution: {integrity: sha512-Tn7Y+qWIpCQK+qdYwBcn6YvqeBuPiuG3tcNlYg16a5QL2o3rSmrgBB1nCyYqo0uvc1LNrfQW9/CK2tzFuID/Fg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxfmt/binding-darwin-x64@0.73.0': + resolution: {integrity: sha512-yVb0E0tg/+9Xc9YJCBq66qF1DC/aN5mhb5nnHz0k4ikwkMANtSul2VPDSwjYdXR6/DFimc+pPUgLBqkIa5Ctuw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxfmt/binding-freebsd-x64@0.73.0': + resolution: {integrity: sha512-IKR4lFnBkYNL9Uqdxa5GMTQLvnLj+3sDwTvh2atHCzah0xeo+1xR5bigRU8uyXlOsA4VpXUn0UAimq4E4RBGmQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxfmt/binding-linux-arm-gnueabihf@0.73.0': + resolution: {integrity: sha512-7qnZ4g0ZCO3aZevOOQpZIABYwwXJW1lLDo2elviGdPQ0i11255xKEMFsi97fC1/nauQFseCJfELUy/k2JoOP+g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm-musleabihf@0.73.0': + resolution: {integrity: sha512-36eBico2j/WPB84gb4FYd/jjqGCKOwU4M+TWLBUIqlnzTozQueLy0joA5BwsSp2OZFVkOlpCwvA3ndPamdiyLw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm64-gnu@0.73.0': + resolution: {integrity: sha512-VZ3cSgCAT18knKo52DcACmAuIoqG6qVEyeZRFbJd0xmYQ8GyYjuBc6IAh6MZHijT6q7p4/HPI+6ZWsCuhdV6iw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-arm64-musl@0.73.0': + resolution: {integrity: sha512-8YVzH59XSHcqwXNgc5qK851zCqwpmBgOifARXEPPe5/xTsIbHceYoPZAyqfpYV0CfKmzSaEDBgYHFza4rhx+Zw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-ppc64-gnu@0.73.0': + resolution: {integrity: sha512-MqRJnmCys0rwOgpWyEidSA7v/bvOqfNDJhPkQoR9mPVYrvSmMaW2Xhdpl28cbTA+brOM1is62GjIvJplVlUVpw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-gnu@0.73.0': + resolution: {integrity: sha512-4t2F3SWJSK/O/kRRFnL0elJzn75fJGGu71gs1HtiWcS80SmJOpkHiwwLnTp9suGdns8VHEPxYS0GTcyVwHKWBA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-musl@0.73.0': + resolution: {integrity: sha512-D8b4I8I1kLdJQFwdFXNV+cwh3xWSiNKofjdGUk0nDpc0AHOds8T+poGbP9RWQMvgsvMb9OMgp/aV7ahBR37TbQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-s390x-gnu@0.73.0': + resolution: {integrity: sha512-QrC1S5VL1+N5rvorB15jH7CPvjVHA7GyUI5oVEd8Bd9m5605RDBQYTxDQ920DuylRoZOh2V7/xXpfCtFUrojUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-gnu@0.73.0': + resolution: {integrity: sha512-dFcUKQPltCEPa+YlkF3zqufn6nJPZdG73LpnjBvZFFsU3laCMssDXSYB99mVaj43FqdEgjpz5TO6gbYXcczd9g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-musl@0.73.0': + resolution: {integrity: sha512-LmQe+nV8e+eUwfPNCxDF+owbsg88toBiR/8rZRgVOeKQP3MaGefXZRvBoc1UG37iWEQ/9JlCXG9Mc4nf4N3agw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-openharmony-arm64@0.73.0': + resolution: {integrity: sha512-8weOr1OKknE0G2DbR6ejJUT9AOsoBU6cXlsetmbihHv1K+bK7qP61T6lJxY0k/aAO7GyOJiRGNlL+04r4eH8Xg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxfmt/binding-win32-arm64-msvc@0.73.0': + resolution: {integrity: sha512-mZV+QxXYgb8E18gzAFItb937VIsE4dqWdStrD3tRRC52UubOYEuU+e7NaIqUsGdB5YsJqNeuck3Ue/af2r9wyA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxfmt/binding-win32-ia32-msvc@0.73.0': + resolution: {integrity: sha512-QJPUsbgUb97ZPPMkFUq6VQq0fLhC4Np19KL9aB/qKVwXnpKE6MIiILXqzhKnQ/UbnEDjKJ7Xv1d6zH4bYPCUdQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxfmt/binding-win32-x64-msvc@0.73.0': + resolution: {integrity: sha512-cGcze2fu+ykIaFtkEvZlkZ3HvnCLk+WISthtq99zWTWn0x13t5L9TxvGdLZMUMvtfYR+ICkiCwKnguVubdcw0w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxlint-tsgolint/darwin-arm64@7.0.2004': + resolution: {integrity: sha512-uJk3ttju7Lz+Nv8WG4CsLyewoL1qf/Ny8S8OksJ7Bm/F14Tc9gMfW6arM1PCc57IzH/X/I79YsCWTgxX1FKibQ==} + cpu: [arm64] + os: [darwin] + + '@oxlint-tsgolint/darwin-x64@7.0.2004': + resolution: {integrity: sha512-0BFfHTzDxNDFPcu0UZAXT6CyCJHAXM0dZveyDb4KEHa2qF/DSnOKOrmvK/wFuFol9kvijbGM9JfuOsor+zWXRw==} + cpu: [x64] + os: [darwin] + + '@oxlint-tsgolint/linux-arm64@7.0.2004': + resolution: {integrity: sha512-TAehQc+qdRXyDN3nl/r/3/oPrBVrbtrDeGMkFyG6mf+XphpoJ2lAMVFLclyvrVZCw02RbKROjyw8h72XwQQHcw==} + cpu: [arm64] + os: [linux] + + '@oxlint-tsgolint/linux-x64@7.0.2004': + resolution: {integrity: sha512-LPunZvm6ZbWPfHMAyfZf9ci3HHvNEzLejO8zdqO3GoVGoSEHqqufHUD7+OgeatVSaJP7uXDJ2HaNOHIoH7go4Q==} + cpu: [x64] + os: [linux] + + '@oxlint-tsgolint/win32-arm64@7.0.2004': + resolution: {integrity: sha512-FVWplzDImWhiuawnIZVETaQecv4GWom6Jv4Fx2xj7hhXXDZ5okEgiw1gTCImHTIQsi2ycC5oSxVwrn69bMh7iw==} + cpu: [arm64] + os: [win32] + + '@oxlint-tsgolint/win32-x64@7.0.2004': + resolution: {integrity: sha512-7PsxuUx11Ry9DYTWcljTzRbmZY9B6O9fFFfZV+aodQEMy6pfRT3fhoQRv9PtGN/89z1pPz3nFKrsFm6WqNtX7w==} + cpu: [x64] + os: [win32] + + '@oxlint/binding-android-arm-eabi@1.88.0': + resolution: {integrity: sha512-k6Fgkf86ubfeH4GvqYsRolfuVAp0pfYVZvifSlXNHPdMaWUOfxUrmLEeDD7hF6U2oJuhsVxhSWq86/xCJ+9MmA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.88.0': + resolution: {integrity: sha512-AsIyhl9o1ejwtac7NMbhKpMdSml6NWR0ljKjZERxC8OHYDAW8o/Om9n1QHVLi4VCEUo42Zte2/Op9kyoGpJZMg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.88.0': + resolution: {integrity: sha512-qgBjB7eEKcCB4RqjXGuoSZq74g0wD09fWFJ+HWcCJcKaga8TeUHtb+BY0SsxdleSLBgx4RZ7qmgo0EzqEq7JoQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.88.0': + resolution: {integrity: sha512-sCz3X5Mw/xdzEzn4fsCt1Iq8NKJnGNfhR+b9B5k+4DysYAHsmIhYoocnBPiQ9kvY3+pUun2A0Z8Vtun/Nv6YoQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.88.0': + resolution: {integrity: sha512-xTOdYOIqCufgIj0knvxu86FBfovP6QBiD7ngNva01gb8xPQCD5gXkE5APGczH35zStim1O3zqvFbLQmY85xXRg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.88.0': + resolution: {integrity: sha512-BLadtw88NdYJBrAtC1dYzrPfSP2d5zgx6iuZycdD0YmZBJ2Nqbit9Z4PSaIb6gxK25dweArGMPbOmq/0ugwUPg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.88.0': + resolution: {integrity: sha512-wILSczl/DaZsCcf7j7tvNbssp8hxEHIJxrv4j7+JjI3TAL1U/JTWbeUtoJJC/vo8+2hkTmHLYoKJjmiNbijdXw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.88.0': + resolution: {integrity: sha512-jutT2Hy8JCuZuMwIFHo2HtyxfOfBJHyQxlBHBK1SL9W2f8n/WhjAP0FnDMz9d/mjhv9+QA0mx5AdQ9fDjsrFFg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-arm64-musl@1.88.0': + resolution: {integrity: sha512-8N8iGV9hh4Lkeuq7QsEkjoXNRRflqR1G2qyoGYXcA0k5qllrdqmfylKEXyHIVERxqNDyMO4StqDQ0uLxXcBQAg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-ppc64-gnu@1.88.0': + resolution: {integrity: sha512-5ydcQt3znNQ/rGEWYRvpS2cK9+T18P2VYgnyJ+eloEAwi9YUmsXy5VGrRqNWIK6qzWwHGnEE3ojVqqQNo+f0hA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-gnu@1.88.0': + resolution: {integrity: sha512-iGx0uvFT54b/5DPJdEBCxYIrVB3v0uTy+JhRh3CyEp8/22BXDkG8rffkFwREdLsdj9mOrPbJYVOZw8ipcBuzjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-musl@1.88.0': + resolution: {integrity: sha512-Wt47mKMWelVjRjuR7BCFxXkB0m79K1sTEbqMHRWciKUgo5iv7sWsF4RXSOyFYHD9zerNrkRZblnN273rhwOA1Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-s390x-gnu@1.88.0': + resolution: {integrity: sha512-g3L12KCS4+1gtUt3RbJ4F+Yxx4LNmmZLEPfhd8rAHEJjJk2lAgNfnjGoP/phJyhG5WS6VbNYbJtadJelNfvTMA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-gnu@1.88.0': + resolution: {integrity: sha512-og/XHVDOD9ZJgRiZkT9DgzQd4sr+JZW7e4l17Q/jVMZiIbeEd4uIu9iAoUbTF1gIvVZoRc4QDOxyVUvQGp4WHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-musl@1.88.0': + resolution: {integrity: sha512-EmhBsp+xuKb6YbZNEMLBo80Xw71AZOIm18do8ni/GLX/Lw1XzNhVYpbxO1KasYtoAvrGtCOdCPULRlIE0bPbTQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxlint/binding-openharmony-arm64@1.88.0': + resolution: {integrity: sha512-DBUcx/KwYdg1DAz+G01P/GFv512J04EsZadbHptx2FTpWT/MWRg21ZToNHt9pnsfQRBQ3et/g+vqzbvrdAVbuA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.88.0': + resolution: {integrity: sha512-xeVd0n4XG5HvynxfYvXi6uB+JvZbVZataowPS8cYpMbYg9mwE4sSyElFQd4LVJFifq8bRLDSR18XgEildmcXAg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.88.0': + resolution: {integrity: sha512-MA6iOw//amleaz2mVMsZrA7NHlEexjTrhIxSMFCXQi6IUuQO+3Od00GZhr2iCYw6RFzMW36jkKK/WHBXMsoQAw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.88.0': + resolution: {integrity: sha512-3Sz6rVWe8Dy+ufsXurFN1GX2dX43F/lQMSLelK9gFjiba7nHvLlqlhR5cHlWVnCpSqUY1hN9pyCX9XToe6ve6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/binding-android-arm-eabi@1.2.14': + resolution: {integrity: sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.14': + resolution: {integrity: sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.14': + resolution: {integrity: sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.14': + resolution: {integrity: sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.14': + resolution: {integrity: sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.14': + resolution: {integrity: sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.14': + resolution: {integrity: sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.14': + resolution: {integrity: sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.14': + resolution: {integrity: sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.14': + resolution: {integrity: sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.14': + resolution: {integrity: sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.14': + resolution: {integrity: sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.14': + resolution: {integrity: sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.14': + resolution: {integrity: sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.14': + resolution: {integrity: sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@24.19.2': + resolution: {integrity: sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==} + + '@types/react-dom@19.3.0': + resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} + peerDependencies: + '@types/react': ^19.3.0 + + '@types/react@19.3.0': + resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} + + '@vitejs/plugin-react@6.1.2': + resolution: {integrity: sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + oxc-transform-react: ^0.152.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + oxc-transform-react: + optional: true + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + bundle-name@4.1.1: + resolution: {integrity: sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA==} + engines: {node: '>=18'} + + c12@3.3.4: + resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==} + peerDependencies: + magicast: '*' + peerDependenciesMeta: + magicast: + optional: true + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + color-support@1.1.3: + resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} + hasBin: true + + commander@15.0.0: + resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} + engines: {node: '>=22.12.0'} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + confbox@0.3.1: + resolution: {integrity: sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.1: + resolution: {integrity: sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==} + engines: {node: '>=18'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + + exsolve@1.1.3: + resolution: {integrity: sha512-kuzL9zherLQoqDeQaS55AiiIg1kb4KYXaSusTtgZTPlvKP2gGCI82LNmBNh5RjDVUXIcmgx+/Mx7vPwRzBbhJA==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + + giget@3.3.1: + resolution: {integrity: sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==} + hasBin: true + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-yaml@4.2.0: + resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + hasBin: true + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + ohash@2.0.12: + resolution: {integrity: sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==} + + open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + engines: {node: '>=20'} + + oxfmt@0.73.0: + resolution: {integrity: sha512-Ke4xbpuzqIBP4bkd48q815RhqWNJqOBzxVA9199M4qcF0y0LtpUv7UTg1dJ5ea6jw0RBUDZvX3qOUMNmmxfxbw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@astrojs/compiler-rs': ^0.4.0 || ^0.5.0 + svelte: ^5.0.0 + vite-plus: '*' + peerDependenciesMeta: + '@astrojs/compiler-rs': + optional: true + svelte: + optional: true + vite-plus: + optional: true + + oxlint-tsgolint@7.0.2004: + resolution: {integrity: sha512-vQ9WDF3fiZJvRdSDosNOflerOCOpwol6vWzVDxW76RcU19cjTh7engvkHLvmv+tzedR8fAiJUVUGr6IVZdkzXQ==} + hasBin: true + + oxlint@1.88.0: + resolution: {integrity: sha512-uIcFgw8BmqvFRGzH5+r7d2zJMEexwNSrEmM6WQNnGh3VkO0XYTEFoyxyNE6tjKMUib9Cue4XJA2DoWruO5D6aw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=7.0.2003' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + pkg-types@2.3.3: + resolution: {integrity: sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==} + + postcss@8.5.31: + resolution: {integrity: sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==} + engines: {node: ^10 || ^12 || >=14} + + powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + + rc9@3.1.0: + resolution: {integrity: sha512-ufjkNVzbRHKcCOmTahZkmVsyc3W+MSk3jY03m+a7tGHkIsdVMG9l10/3HvFbWkkKzY5VFp3pkRsIo/UYgmFL7Q==} + + react-dom@19.3.1: + resolution: {integrity: sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==} + peerDependencies: + react: ^19.3.1 + + react@19.3.1: + resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==} + engines: {node: '>=0.10.0'} + + readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} + engines: {node: '>= 20.19.0'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + rolldown@1.2.14: + resolution: {integrity: sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + + scheduler@0.28.0: + resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} + + semver@7.8.4: + resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@2.2.0: + resolution: {integrity: sha512-jBrmx4lYmaC9k/mgPbylxs7kBUxHtD8256up+HjLaDFfXScKJQyil+SWXSvhAtT7XHo+yTVlpB81PGmHP8oLSQ==} + engines: {node: ^20.0.0 || >=22.0.0} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + vite@8.3.4: + resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + engines: {node: '>=20'} + +snapshots: + + '@hey-api/codegen-core@0.9.1': + dependencies: + '@hey-api/types': 0.1.4 + ansi-colors: 4.1.3 + c12: 3.3.4 + color-support: 1.1.3 + transitivePeerDependencies: + - magicast + + '@hey-api/json-schema-ref-parser@1.4.4': + dependencies: + '@jsdevtools/ono': 7.1.3 + '@types/json-schema': 7.0.15 + js-yaml: 4.2.0 + + '@hey-api/openapi-ts@0.99.0(typescript@6.0.3)': + dependencies: + '@hey-api/codegen-core': 0.9.1 + '@hey-api/json-schema-ref-parser': 1.4.4 + '@hey-api/shared': 0.5.0 + '@hey-api/spec-types': 0.2.0 + '@hey-api/types': 0.1.4 + '@lukeed/ms': 2.0.2 + ansi-colors: 4.1.3 + color-support: 1.1.3 + commander: 15.0.0 + get-tsconfig: 4.14.0 + typescript: 6.0.3 + transitivePeerDependencies: + - magicast + + '@hey-api/shared@0.5.0': + dependencies: + '@hey-api/codegen-core': 0.9.1 + '@hey-api/json-schema-ref-parser': 1.4.4 + '@hey-api/spec-types': 0.2.0 + '@hey-api/types': 0.1.4 + ansi-colors: 4.1.3 + cross-spawn: 7.0.6 + open: 11.0.0 + semver: 7.8.4 + transitivePeerDependencies: + - magicast + + '@hey-api/spec-types@0.2.0': + dependencies: + '@hey-api/types': 0.1.4 + + '@hey-api/types@0.1.4': {} + + '@jsdevtools/ono@7.1.3': {} + + '@lukeed/ms@2.0.2': {} + + '@oxc-project/types@0.154.0': {} + + '@oxfmt/binding-android-arm-eabi@0.73.0': + optional: true + + '@oxfmt/binding-android-arm64@0.73.0': + optional: true + + '@oxfmt/binding-darwin-arm64@0.73.0': + optional: true + + '@oxfmt/binding-darwin-x64@0.73.0': + optional: true + + '@oxfmt/binding-freebsd-x64@0.73.0': + optional: true + + '@oxfmt/binding-linux-arm-gnueabihf@0.73.0': + optional: true + + '@oxfmt/binding-linux-arm-musleabihf@0.73.0': + optional: true + + '@oxfmt/binding-linux-arm64-gnu@0.73.0': + optional: true + + '@oxfmt/binding-linux-arm64-musl@0.73.0': + optional: true + + '@oxfmt/binding-linux-ppc64-gnu@0.73.0': + optional: true + + '@oxfmt/binding-linux-riscv64-gnu@0.73.0': + optional: true + + '@oxfmt/binding-linux-riscv64-musl@0.73.0': + optional: true + + '@oxfmt/binding-linux-s390x-gnu@0.73.0': + optional: true + + '@oxfmt/binding-linux-x64-gnu@0.73.0': + optional: true + + '@oxfmt/binding-linux-x64-musl@0.73.0': + optional: true + + '@oxfmt/binding-openharmony-arm64@0.73.0': + optional: true + + '@oxfmt/binding-win32-arm64-msvc@0.73.0': + optional: true + + '@oxfmt/binding-win32-ia32-msvc@0.73.0': + optional: true + + '@oxfmt/binding-win32-x64-msvc@0.73.0': + optional: true + + '@oxlint-tsgolint/darwin-arm64@7.0.2004': + optional: true + + '@oxlint-tsgolint/darwin-x64@7.0.2004': + optional: true + + '@oxlint-tsgolint/linux-arm64@7.0.2004': + optional: true + + '@oxlint-tsgolint/linux-x64@7.0.2004': + optional: true + + '@oxlint-tsgolint/win32-arm64@7.0.2004': + optional: true + + '@oxlint-tsgolint/win32-x64@7.0.2004': + optional: true + + '@oxlint/binding-android-arm-eabi@1.88.0': + optional: true + + '@oxlint/binding-android-arm64@1.88.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.88.0': + optional: true + + '@oxlint/binding-darwin-x64@1.88.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.88.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.88.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.88.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.88.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.88.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.88.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.88.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.88.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.88.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.88.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.88.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.88.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.88.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.88.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.88.0': + optional: true + + '@rolldown/binding-android-arm-eabi@1.2.14': + optional: true + + '@rolldown/binding-android-arm64@1.2.14': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.14': + optional: true + + '@rolldown/binding-darwin-x64@1.2.14': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.14': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.14': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.14': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.14': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.14': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.14': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.14': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.14': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.14': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.14': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.14': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@24.19.2': + dependencies: + undici-types: 7.24.6 + + '@types/react-dom@19.3.0(@types/react@19.3.0)': + dependencies: + '@types/react': 19.3.0 + + '@types/react@19.3.0': + dependencies: + csstype: 3.2.3 + + '@vitejs/plugin-react@6.1.2(vite@8.3.4(@types/node@24.19.2)(jiti@2.7.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.3.4(@types/node@24.19.2)(jiti@2.7.0) + + ansi-colors@4.1.3: {} + + argparse@2.0.1: {} + + bundle-name@4.1.1: + dependencies: + run-applescript: 7.1.0 + + c12@3.3.4: + dependencies: + chokidar: 5.0.0 + confbox: 0.2.4 + defu: 6.1.7 + dotenv: 17.4.2 + exsolve: 1.1.3 + giget: 3.3.1 + jiti: 2.7.0 + ohash: 2.0.12 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.3 + rc9: 3.1.0 + + chokidar@5.0.0: + dependencies: + readdirp: 5.1.1 + + color-support@1.1.3: {} + + commander@15.0.0: {} + + confbox@0.2.4: {} + + confbox@0.3.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + default-browser-id@5.0.1: {} + + default-browser@5.5.1: + dependencies: + bundle-name: 4.1.1 + default-browser-id: 5.0.1 + + define-lazy-prop@3.0.0: {} + + defu@6.1.7: {} + + destr@2.0.5: {} + + detect-libc@2.1.2: {} + + dotenv@17.4.2: {} + + exsolve@1.1.3: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fsevents@2.3.3: + optional: true + + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + giget@3.3.1: {} + + is-docker@3.0.0: {} + + is-in-ssh@1.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + isexe@2.0.0: {} + + jiti@2.7.0: {} + + js-yaml@4.2.0: + dependencies: + argparse: 2.0.1 + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + nanoid@3.3.20: {} + + ohash@2.0.12: {} + + open@11.0.0: + dependencies: + default-browser: 5.5.1 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 + + oxfmt@0.73.0: + dependencies: + tinypool: 2.2.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.73.0 + '@oxfmt/binding-android-arm64': 0.73.0 + '@oxfmt/binding-darwin-arm64': 0.73.0 + '@oxfmt/binding-darwin-x64': 0.73.0 + '@oxfmt/binding-freebsd-x64': 0.73.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.73.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.73.0 + '@oxfmt/binding-linux-arm64-gnu': 0.73.0 + '@oxfmt/binding-linux-arm64-musl': 0.73.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.73.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.73.0 + '@oxfmt/binding-linux-riscv64-musl': 0.73.0 + '@oxfmt/binding-linux-s390x-gnu': 0.73.0 + '@oxfmt/binding-linux-x64-gnu': 0.73.0 + '@oxfmt/binding-linux-x64-musl': 0.73.0 + '@oxfmt/binding-openharmony-arm64': 0.73.0 + '@oxfmt/binding-win32-arm64-msvc': 0.73.0 + '@oxfmt/binding-win32-ia32-msvc': 0.73.0 + '@oxfmt/binding-win32-x64-msvc': 0.73.0 + + oxlint-tsgolint@7.0.2004: + optionalDependencies: + '@oxlint-tsgolint/darwin-arm64': 7.0.2004 + '@oxlint-tsgolint/darwin-x64': 7.0.2004 + '@oxlint-tsgolint/linux-arm64': 7.0.2004 + '@oxlint-tsgolint/linux-x64': 7.0.2004 + '@oxlint-tsgolint/win32-arm64': 7.0.2004 + '@oxlint-tsgolint/win32-x64': 7.0.2004 + + oxlint@1.88.0(oxlint-tsgolint@7.0.2004): + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.88.0 + '@oxlint/binding-android-arm64': 1.88.0 + '@oxlint/binding-darwin-arm64': 1.88.0 + '@oxlint/binding-darwin-x64': 1.88.0 + '@oxlint/binding-freebsd-x64': 1.88.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.88.0 + '@oxlint/binding-linux-arm-musleabihf': 1.88.0 + '@oxlint/binding-linux-arm64-gnu': 1.88.0 + '@oxlint/binding-linux-arm64-musl': 1.88.0 + '@oxlint/binding-linux-ppc64-gnu': 1.88.0 + '@oxlint/binding-linux-riscv64-gnu': 1.88.0 + '@oxlint/binding-linux-riscv64-musl': 1.88.0 + '@oxlint/binding-linux-s390x-gnu': 1.88.0 + '@oxlint/binding-linux-x64-gnu': 1.88.0 + '@oxlint/binding-linux-x64-musl': 1.88.0 + '@oxlint/binding-openharmony-arm64': 1.88.0 + '@oxlint/binding-win32-arm64-msvc': 1.88.0 + '@oxlint/binding-win32-ia32-msvc': 1.88.0 + '@oxlint/binding-win32-x64-msvc': 1.88.0 + oxlint-tsgolint: 7.0.2004 + + path-key@3.1.1: {} + + pathe@2.0.3: {} + + perfect-debounce@2.1.0: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + pkg-types@2.3.3: + dependencies: + confbox: 0.3.1 + exsolve: 1.1.3 + pathe: 2.0.3 + + postcss@8.5.31: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + powershell-utils@0.1.0: {} + + rc9@3.1.0: + dependencies: + defu: 6.1.7 + destr: 2.0.5 + + react-dom@19.3.1(react@19.3.1): + dependencies: + react: 19.3.1 + scheduler: 0.28.0 + + react@19.3.1: {} + + readdirp@5.1.1: {} + + resolve-pkg-maps@1.0.0: {} + + rolldown@1.2.14: + dependencies: + '@oxc-project/types': 0.154.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.14 + '@rolldown/binding-android-arm64': 1.2.14 + '@rolldown/binding-darwin-arm64': 1.2.14 + '@rolldown/binding-darwin-x64': 1.2.14 + '@rolldown/binding-freebsd-x64': 1.2.14 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.14 + '@rolldown/binding-linux-arm64-gnu': 1.2.14 + '@rolldown/binding-linux-arm64-musl': 1.2.14 + '@rolldown/binding-linux-ppc64-gnu': 1.2.14 + '@rolldown/binding-linux-s390x-gnu': 1.2.14 + '@rolldown/binding-linux-x64-gnu': 1.2.14 + '@rolldown/binding-linux-x64-musl': 1.2.14 + '@rolldown/binding-openharmony-arm64': 1.2.14 + '@rolldown/binding-win32-arm64-msvc': 1.2.14 + '@rolldown/binding-win32-x64-msvc': 1.2.14 + + run-applescript@7.1.0: {} + + scheduler@0.28.0: {} + + semver@7.8.4: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + source-map-js@1.2.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinypool@2.2.0: {} + + typescript@6.0.3: {} + + undici-types@7.24.6: {} + + vite@8.3.4(@types/node@24.19.2)(jiti@2.7.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.31 + rolldown: 1.2.14 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.19.2 + fsevents: 2.3.3 + jiti: 2.7.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wsl-utils@0.3.1: + dependencies: + is-wsl: 3.1.1 + powershell-utils: 0.1.0 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..1902154 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,10 @@ +import { NoteList } from "@/components/NoteList"; + +export function App() { + return ( +
+

Python React Starter

+ +
+ ); +} diff --git a/frontend/src/client/client.gen.ts b/frontend/src/client/client.gen.ts new file mode 100644 index 0000000..47828b7 --- /dev/null +++ b/frontend/src/client/client.gen.ts @@ -0,0 +1,16 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { type Client, type ClientOptions, type Config, createClient, createConfig } from './client'; +import type { ClientOptions as ClientOptions2 } from './types.gen'; + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig = (override?: Config) => Config & T>; + +export const client: Client = createClient(createConfig()); diff --git a/frontend/src/client/client/client.gen.ts b/frontend/src/client/client/client.gen.ts new file mode 100644 index 0000000..fc3f037 --- /dev/null +++ b/frontend/src/client/client/client.gen.ts @@ -0,0 +1,277 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { createSseClient } from '../core/serverSentEvents.gen'; +import type { HttpMethod } from '../core/types.gen'; +import { getValidRequestBody } from '../core/utils.gen'; +import type { Client, Config, RequestOptions, ResolvedRequestOptions } from './types.gen'; +import { + buildUrl, + createConfig, + createInterceptors, + getParseAs, + mergeConfigs, + mergeHeaders, + setAuthParams, +} from './utils.gen'; + +type ReqInit = Omit & { + body?: any; + headers: ReturnType; +}; + +export const createClient = (config: Config = {}): Client => { + let _config = mergeConfigs(createConfig(), config); + + const getConfig = (): Config => ({ ..._config }); + + const setConfig = (config: Config): Config => { + _config = mergeConfigs(_config, config); + return getConfig(); + }; + + const interceptors = createInterceptors(); + + const beforeRequest = async < + TData = unknown, + TResponseStyle extends 'data' | 'fields' = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string, + >( + options: RequestOptions, + ) => { + const opts = { + ..._config, + ...options, + fetch: options.fetch ?? _config.fetch ?? globalThis.fetch, + headers: mergeHeaders(_config.headers, options.headers), + serializedBody: undefined as string | undefined, + }; + + if (opts.security) { + await setAuthParams(opts); + } + + if (opts.requestValidator) { + await opts.requestValidator(opts); + } + + if (opts.body !== undefined && opts.bodySerializer) { + opts.serializedBody = opts.bodySerializer(opts.body) as string | undefined; + } + + // remove Content-Type header if body is empty to avoid sending invalid requests + if (opts.body === undefined || opts.serializedBody === '') { + opts.headers.delete('Content-Type'); + } + + const resolvedOpts = opts as typeof opts & + ResolvedRequestOptions; + const url = buildUrl(resolvedOpts); + + return { opts: resolvedOpts, url }; + }; + + const request: Client['request'] = async (options) => { + const throwOnError = options.throwOnError ?? _config.throwOnError; + const responseStyle = options.responseStyle ?? _config.responseStyle; + + let request: Request | undefined; + let response: Response | undefined; + + try { + const { opts, url } = await beforeRequest(options); + const requestInit: ReqInit = { + redirect: 'follow', + ...opts, + body: getValidRequestBody(opts), + }; + + request = new Request(url, requestInit); + + for (const fn of interceptors.request.fns) { + if (fn) { + request = await fn(request, opts); + } + } + + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = opts.fetch!; + + response = await _fetch(request); + + for (const fn of interceptors.response.fns) { + if (fn) { + response = await fn(response, request, opts); + } + } + + const result = { + request, + response, + }; + + if (response.ok) { + const parseAs = + (opts.parseAs === 'auto' + ? getParseAs(response.headers.get('Content-Type')) + : opts.parseAs) ?? 'json'; + + if (response.status === 204 || response.headers.get('Content-Length') === '0') { + let emptyData: any; + switch (parseAs) { + case 'arrayBuffer': + case 'blob': + case 'text': + emptyData = await response[parseAs](); + break; + case 'formData': + emptyData = new FormData(); + break; + case 'stream': + emptyData = response.body; + break; + case 'json': + default: + emptyData = {}; + break; + } + return opts.responseStyle === 'data' + ? emptyData + : { + data: emptyData, + ...result, + }; + } + + let data: any; + switch (parseAs) { + case 'arrayBuffer': + case 'blob': + case 'formData': + case 'text': + data = await response[parseAs](); + break; + case 'json': { + // Some servers return 200 with no Content-Length and empty body. + // response.json() would throw; read as text and parse if non-empty. + const text = await response.text(); + data = text ? JSON.parse(text) : {}; + break; + } + case 'stream': + return opts.responseStyle === 'data' + ? response.body + : { + data: response.body, + ...result, + }; + } + + if (parseAs === 'json') { + if (opts.responseValidator) { + await opts.responseValidator(data); + } + + if (opts.responseTransformer) { + data = await opts.responseTransformer(data); + } + } + + return opts.responseStyle === 'data' + ? data + : { + data, + ...result, + }; + } + + const textError = await response.text(); + let jsonError: unknown; + + try { + jsonError = JSON.parse(textError); + } catch { + // noop + } + + throw jsonError ?? textError; + } catch (error) { + let finalError = error; + + for (const fn of interceptors.error.fns) { + if (fn) { + finalError = await fn(finalError, response, request, options as ResolvedRequestOptions); + } + } + + finalError = finalError || {}; + + if (throwOnError) { + throw finalError; + } + + // TODO: we probably want to return error and improve types + return responseStyle === 'data' + ? undefined + : { + error: finalError, + request, + response, + }; + } + }; + + const makeMethodFn = (method: Uppercase) => (options: RequestOptions) => + request({ ...options, method }); + + const makeSseFn = (method: Uppercase) => async (options: RequestOptions) => { + const { opts, url } = await beforeRequest(options); + return createSseClient({ + ...opts, + body: opts.body as BodyInit | null | undefined, + method, + onRequest: async (url, init) => { + let request = new Request(url, init); + for (const fn of interceptors.request.fns) { + if (fn) { + request = await fn(request, opts); + } + } + return request; + }, + serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined, + url, + }); + }; + + const _buildUrl: Client['buildUrl'] = (options) => buildUrl({ ..._config, ...options }); + + return { + buildUrl: _buildUrl, + connect: makeMethodFn('CONNECT'), + delete: makeMethodFn('DELETE'), + get: makeMethodFn('GET'), + getConfig, + head: makeMethodFn('HEAD'), + interceptors, + options: makeMethodFn('OPTIONS'), + patch: makeMethodFn('PATCH'), + post: makeMethodFn('POST'), + put: makeMethodFn('PUT'), + request, + setConfig, + sse: { + connect: makeSseFn('CONNECT'), + delete: makeSseFn('DELETE'), + get: makeSseFn('GET'), + head: makeSseFn('HEAD'), + options: makeSseFn('OPTIONS'), + patch: makeSseFn('PATCH'), + post: makeSseFn('POST'), + put: makeSseFn('PUT'), + trace: makeSseFn('TRACE'), + }, + trace: makeMethodFn('TRACE'), + } as Client; +}; diff --git a/frontend/src/client/client/index.ts b/frontend/src/client/client/index.ts new file mode 100644 index 0000000..8c69331 --- /dev/null +++ b/frontend/src/client/client/index.ts @@ -0,0 +1,27 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type { Auth } from '../core/auth.gen'; +export type { QuerySerializerOptions } from '../core/bodySerializer.gen'; +export { + formDataBodySerializer, + jsonBodySerializer, + urlSearchParamsBodySerializer, +} from '../core/bodySerializer.gen'; +export { buildClientParams } from '../core/params.gen'; +export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen'; +export type { ServerSentEventsResult } from '../core/serverSentEvents.gen'; +export type { ClientMeta } from '../core/types.gen'; +export { createClient } from './client.gen'; +export type { + Client, + ClientOptions, + Config, + CreateClientConfig, + Options, + RequestOptions, + RequestResult, + ResolvedRequestOptions, + ResponseStyle, + TDataShape, +} from './types.gen'; +export { createConfig, mergeHeaders } from './utils.gen'; diff --git a/frontend/src/client/client/types.gen.ts b/frontend/src/client/client/types.gen.ts new file mode 100644 index 0000000..193646c --- /dev/null +++ b/frontend/src/client/client/types.gen.ts @@ -0,0 +1,218 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Auth } from '../core/auth.gen'; +import type { + ServerSentEventsOptions, + ServerSentEventsResult, +} from '../core/serverSentEvents.gen'; +import type { Client as CoreClient, Config as CoreConfig } from '../core/types.gen'; +import type { Middleware } from './utils.gen'; + +export type ResponseStyle = 'data' | 'fields'; + +export interface Config + extends Omit, CoreConfig { + /** + * Base URL for all requests made by this client. + */ + baseUrl?: T['baseUrl']; + /** + * Fetch API implementation. You can use this option to provide a custom + * fetch instance. + * + * @default globalThis.fetch + */ + fetch?: typeof fetch; + /** + * Please don't use the Fetch client for Next.js applications. The `next` + * options won't have any effect. + * + * Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead. + */ + next?: never; + /** + * Return the response data parsed in a specified format. By default, `auto` + * will infer the appropriate method from the `Content-Type` response header. + * You can override this behavior with any of the {@link Body} methods. + * Select `stream` if you don't want to parse response data at all. + * + * @default 'auto' + */ + parseAs?: 'arrayBuffer' | 'auto' | 'blob' | 'formData' | 'json' | 'stream' | 'text'; + /** + * Should we return only data or multiple fields (data, error, response, etc.)? + * + * @default 'fields' + */ + responseStyle?: ResponseStyle; + /** + * Throw an error instead of returning it in the response? + * + * @default false + */ + throwOnError?: T['throwOnError']; +} + +export interface RequestOptions< + TData = unknown, + TResponseStyle extends ResponseStyle = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string, +> + extends + Config<{ + responseStyle: TResponseStyle; + throwOnError: ThrowOnError; + }>, + Pick< + ServerSentEventsOptions, + | 'onRequest' + | 'onSseError' + | 'onSseEvent' + | 'sseDefaultRetryDelay' + | 'sseMaxRetryAttempts' + | 'sseMaxRetryDelay' + > { + /** + * Any body that you want to add to your request. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#body} + */ + body?: unknown; + path?: Record; + query?: Record; + /** + * Security mechanism(s) to use for the request. + */ + security?: ReadonlyArray; + url: Url; +} + +export interface ResolvedRequestOptions< + TResponseStyle extends ResponseStyle = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string, +> extends RequestOptions { + headers: Headers; + serializedBody?: string; +} + +export type RequestResult< + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = boolean, + TResponseStyle extends ResponseStyle = 'fields', +> = ThrowOnError extends true + ? Promise< + TResponseStyle extends 'data' + ? TData extends Record + ? TData[keyof TData] + : TData + : { + data: TData extends Record ? TData[keyof TData] : TData; + request: Request; + response: Response; + } + > + : Promise< + TResponseStyle extends 'data' + ? (TData extends Record ? TData[keyof TData] : TData) | undefined + : ( + | { + data: TData extends Record ? TData[keyof TData] : TData; + error: undefined; + } + | { + data: undefined; + error: TError extends Record ? TError[keyof TError] : TError; + } + ) & { + /** request may be undefined, because error may be from building the request object itself */ + request?: Request; + /** response may be undefined, because error may be from building the request object itself or from a network error */ + response?: Response; + } + >; + +export interface ClientOptions { + baseUrl?: string; + responseStyle?: ResponseStyle; + throwOnError?: boolean; +} + +type MethodFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit, 'method'>, +) => RequestResult; + +type SseFn = < + TData = unknown, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit, 'method'>, +) => Promise>; + +type RequestFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit, 'method'> & + Pick>, 'method'>, +) => RequestResult; + +type BuildUrlFn = < + TData extends { + body?: unknown; + path?: Record; + query?: Record; + url: string; + }, +>( + options: TData & Options, +) => string; + +export type Client = CoreClient & { + interceptors: Middleware; +}; + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig = ( + override?: Config, +) => Config & T>; + +export interface TDataShape { + body?: unknown; + headers?: unknown; + path?: unknown; + query?: unknown; + url: string; +} + +type OmitKeys = Pick>; + +export type Options< + TData extends TDataShape = TDataShape, + ThrowOnError extends boolean = boolean, + TResponse = unknown, + TResponseStyle extends ResponseStyle = 'fields', +> = OmitKeys< + RequestOptions, + 'body' | 'path' | 'query' | 'url' +> & + ([TData] extends [never] ? unknown : Omit); diff --git a/frontend/src/client/client/utils.gen.ts b/frontend/src/client/client/utils.gen.ts new file mode 100644 index 0000000..d4a7284 --- /dev/null +++ b/frontend/src/client/client/utils.gen.ts @@ -0,0 +1,316 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { getAuthToken } from '../core/auth.gen'; +import type { QuerySerializerOptions } from '../core/bodySerializer.gen'; +import { jsonBodySerializer } from '../core/bodySerializer.gen'; +import { + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from '../core/pathSerializer.gen'; +import { getUrl } from '../core/utils.gen'; +import type { Client, ClientOptions, Config, RequestOptions } from './types.gen'; + +export const createQuerySerializer = ({ + parameters = {}, + ...args +}: QuerySerializerOptions = {}): ((queryParams: T) => string) => { + const querySerializer = (queryParams: T): string => { + const search: string[] = []; + if (queryParams && typeof queryParams === 'object') { + for (const name in queryParams) { + const value = queryParams[name]; + + if (value === undefined || value === null) { + continue; + } + + const options = parameters[name] || args; + + if (Array.isArray(value)) { + const serializedArray = serializeArrayParam({ + allowReserved: options.allowReserved, + explode: true, + name, + style: 'form', + value, + ...options.array, + }); + if (serializedArray) search.push(serializedArray); + } else if (typeof value === 'object') { + const serializedObject = serializeObjectParam({ + allowReserved: options.allowReserved, + explode: true, + name, + style: 'deepObject', + value: value as Record, + ...options.object, + }); + if (serializedObject) search.push(serializedObject); + } else { + const serializedPrimitive = serializePrimitiveParam({ + allowReserved: options.allowReserved, + name, + value: value as string, + }); + if (serializedPrimitive) search.push(serializedPrimitive); + } + } + } + return search.join('&'); + }; + return querySerializer; +}; + +/** + * Infers parseAs value from provided Content-Type header. + */ +export const getParseAs = (contentType: string | null): Exclude => { + if (!contentType) { + // If no Content-Type header is provided, the best we can do is return the raw response body, + // which is effectively the same as the 'stream' option. + return 'stream'; + } + + const cleanContent = contentType.split(';')[0]?.trim(); + + if (!cleanContent) { + return; + } + + if (cleanContent.startsWith('application/json') || cleanContent.endsWith('+json')) { + return 'json'; + } + + if (cleanContent === 'multipart/form-data') { + return 'formData'; + } + + if ( + ['application/', 'audio/', 'image/', 'video/'].some((type) => cleanContent.startsWith(type)) + ) { + return 'blob'; + } + + if (cleanContent.startsWith('text/')) { + return 'text'; + } + + return; +}; + +const checkForExistence = ( + options: Pick & { + headers: Headers; + }, + name?: string, +): boolean => { + if (!name) { + return false; + } + if ( + options.headers.has(name) || + options.query?.[name] || + options.headers.get('Cookie')?.includes(`${name}=`) + ) { + return true; + } + return false; +}; + +export async function setAuthParams( + options: Pick & { + headers: Headers; + }, +): Promise { + for (const auth of options.security ?? []) { + if (checkForExistence(options, auth.name)) { + continue; + } + + const token = await getAuthToken(auth, options.auth); + + if (!token) { + continue; + } + + const name = auth.name ?? 'Authorization'; + + switch (auth.in) { + case 'query': + if (!options.query) { + options.query = {}; + } + options.query[name] = token; + break; + case 'cookie': + options.headers.append('Cookie', `${name}=${token}`); + break; + case 'header': + default: + options.headers.set(name, token); + break; + } + } +} + +export const buildUrl: Client['buildUrl'] = (options) => + getUrl({ + baseUrl: options.baseUrl as string, + path: options.path, + query: options.query, + querySerializer: + typeof options.querySerializer === 'function' + ? options.querySerializer + : createQuerySerializer(options.querySerializer), + url: options.url, + }); + +export const mergeConfigs = (a: Config, b: Config): Config => { + const config = { ...a, ...b }; + if (config.baseUrl?.endsWith('/')) { + config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1); + } + config.headers = mergeHeaders(a.headers, b.headers); + return config; +}; + +const headersEntries = (headers: Headers): Array<[string, string]> => { + const entries: Array<[string, string]> = []; + headers.forEach((value, key) => { + entries.push([key, value]); + }); + return entries; +}; + +export const mergeHeaders = ( + ...headers: Array['headers'] | undefined> +): Headers => { + const mergedHeaders = new Headers(); + for (const header of headers) { + if (!header) { + continue; + } + + const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header); + + for (const [key, value] of iterator) { + if (value === null) { + mergedHeaders.delete(key); + } else if (Array.isArray(value)) { + for (const v of value) { + mergedHeaders.append(key, v as string); + } + } else if (value !== undefined) { + // assume object headers are meant to be JSON stringified, i.e., their + // content value in OpenAPI specification is 'application/json' + mergedHeaders.set( + key, + typeof value === 'object' ? JSON.stringify(value) : (value as string), + ); + } + } + } + return mergedHeaders; +}; + +type ErrInterceptor = ( + error: Err, + /** response may be undefined due to a network error where no response object is produced */ + response: Res | undefined, + /** request may be undefined, because error may be from building the request object itself */ + request: Req | undefined, + options: Options, +) => Err | Promise; + +type ReqInterceptor = (request: Req, options: Options) => Req | Promise; + +type ResInterceptor = ( + response: Res, + request: Req, + options: Options, +) => Res | Promise; + +class Interceptors { + fns: Array = []; + + clear(): void { + this.fns = []; + } + + eject(id: number | Interceptor): void { + const index = this.getInterceptorIndex(id); + if (this.fns[index]) { + this.fns[index] = null; + } + } + + exists(id: number | Interceptor): boolean { + const index = this.getInterceptorIndex(id); + return Boolean(this.fns[index]); + } + + getInterceptorIndex(id: number | Interceptor): number { + if (typeof id === 'number') { + return this.fns[id] ? id : -1; + } + return this.fns.indexOf(id); + } + + update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false { + const index = this.getInterceptorIndex(id); + if (this.fns[index]) { + this.fns[index] = fn; + return id; + } + return false; + } + + use(fn: Interceptor): number { + this.fns.push(fn); + return this.fns.length - 1; + } +} + +export interface Middleware { + error: Interceptors>; + request: Interceptors>; + response: Interceptors>; +} + +export const createInterceptors = (): Middleware< + Req, + Res, + Err, + Options +> => ({ + error: new Interceptors>(), + request: new Interceptors>(), + response: new Interceptors>(), +}); + +const defaultQuerySerializer = createQuerySerializer({ + allowReserved: false, + array: { + explode: true, + style: 'form', + }, + object: { + explode: true, + style: 'deepObject', + }, +}); + +const defaultHeaders = { + 'Content-Type': 'application/json', +}; + +export const createConfig = ( + override: Config & T> = {}, +): Config & T> => ({ + ...jsonBodySerializer, + headers: defaultHeaders, + parseAs: 'auto', + querySerializer: defaultQuerySerializer, + ...override, +}); diff --git a/frontend/src/client/core/auth.gen.ts b/frontend/src/client/core/auth.gen.ts new file mode 100644 index 0000000..c663664 --- /dev/null +++ b/frontend/src/client/core/auth.gen.ts @@ -0,0 +1,48 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type AuthToken = string | undefined; + +export interface Auth { + /** + * Which part of the request do we use to send the auth? + * + * @default 'header' + */ + in?: 'header' | 'query' | 'cookie'; + /** + * A unique identifier for the security scheme. + * + * Defined only when there are multiple security schemes whose `Auth` + * shape would otherwise be identical. + */ + key?: string; + /** + * Header or query parameter name. + * + * @default 'Authorization' + */ + name?: string; + scheme?: 'basic' | 'bearer'; + type: 'apiKey' | 'http'; +} + +export const getAuthToken = async ( + auth: Auth, + callback: ((auth: Auth) => Promise | AuthToken) | AuthToken, +): Promise => { + const token = typeof callback === 'function' ? await callback(auth) : callback; + + if (!token) { + return; + } + + if (auth.scheme === 'bearer') { + return `Bearer ${token}`; + } + + if (auth.scheme === 'basic') { + return `Basic ${btoa(token)}`; + } + + return token; +}; diff --git a/frontend/src/client/core/bodySerializer.gen.ts b/frontend/src/client/core/bodySerializer.gen.ts new file mode 100644 index 0000000..67daca6 --- /dev/null +++ b/frontend/src/client/core/bodySerializer.gen.ts @@ -0,0 +1,82 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { ArrayStyle, ObjectStyle, SerializerOptions } from './pathSerializer.gen'; + +export type QuerySerializer = (query: Record) => string; + +export type BodySerializer = (body: unknown) => unknown; + +type QuerySerializerOptionsObject = { + allowReserved?: boolean; + array?: Partial>; + object?: Partial>; +}; + +export type QuerySerializerOptions = QuerySerializerOptionsObject & { + /** + * Per-parameter serialization overrides. When provided, these settings + * override the global array/object settings for specific parameter names. + */ + parameters?: Record; +}; + +const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => { + if (typeof value === 'string' || value instanceof Blob) { + data.append(key, value); + } else if (value instanceof Date) { + data.append(key, value.toISOString()); + } else { + data.append(key, JSON.stringify(value)); + } +}; + +const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => { + if (typeof value === 'string') { + data.append(key, value); + } else { + data.append(key, JSON.stringify(value)); + } +}; + +export const formDataBodySerializer = { + bodySerializer: (body: unknown): FormData => { + const data = new FormData(); + + Object.entries(body as Record).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + value.forEach((v) => serializeFormDataPair(data, key, v)); + } else { + serializeFormDataPair(data, key, value); + } + }); + + return data; + }, +}; + +export const jsonBodySerializer = { + bodySerializer: (body: unknown): string => + JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)), +}; + +export const urlSearchParamsBodySerializer = { + bodySerializer: (body: unknown): string => { + const data = new URLSearchParams(); + + Object.entries(body as Record).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + value.forEach((v) => serializeUrlSearchParamsPair(data, key, v)); + } else { + serializeUrlSearchParamsPair(data, key, value); + } + }); + + return data.toString(); + }, +}; diff --git a/frontend/src/client/core/params.gen.ts b/frontend/src/client/core/params.gen.ts new file mode 100644 index 0000000..5e8908f --- /dev/null +++ b/frontend/src/client/core/params.gen.ts @@ -0,0 +1,178 @@ +// This file is auto-generated by @hey-api/openapi-ts + +type Slot = 'body' | 'headers' | 'path' | 'query'; + +export type Field = + | { + in: Exclude; + /** + * Field name. This is the name we want the user to see and use. + */ + key: string; + /** + * Field mapped name. This is the name we want to use in the request. + * If omitted, we use the same value as `key`. + */ + map?: string; + } + | { + in: Extract; + /** + * Key isn't required for bodies. + */ + key?: string; + map?: string; + } + | { + /** + * Field name. This is the name we want the user to see and use. + */ + key: string; + /** + * Field mapped name. This is the name we want to use in the request. + * If `in` is omitted, `map` aliases `key` to the transport layer. + */ + map: Slot; + }; + +export interface Fields { + allowExtra?: Partial>; + args?: ReadonlyArray; +} + +export type FieldsConfig = ReadonlyArray; + +const extraPrefixesMap: Record = { + $body_: 'body', + $headers_: 'headers', + $path_: 'path', + $query_: 'query', +}; +const extraPrefixes = Object.entries(extraPrefixesMap); + +type KeyMap = Map< + string, + | { + in: Slot; + map?: string; + } + | { + in?: never; + map: Slot; + } +>; + +function buildKeyMap(fields: FieldsConfig, map?: KeyMap): KeyMap { + if (!map) { + map = new Map(); + } + + for (const config of fields) { + if ('in' in config) { + if (config.key) { + map.set(config.key, { + in: config.in, + map: config.map, + }); + } + } else if ('key' in config) { + map.set(config.key, { + map: config.map, + }); + } else if (config.args) { + buildKeyMap(config.args, map); + } + } + + return map; +} + +interface Params { + body?: unknown; + headers: Record; + path: Record; + query: Record; +} + +function stripEmptySlots(params: Params): void { + for (const [slot, value] of Object.entries(params)) { + if (slot === 'body') continue; + if (value && typeof value === 'object' && !Array.isArray(value) && !Object.keys(value).length) { + delete params[slot as Slot]; + } + } +} + +export function buildClientParams(args: ReadonlyArray, fields: FieldsConfig): Params { + const params: Params = { + headers: Object.create(null), + path: Object.create(null), + query: Object.create(null), + }; + + const map = buildKeyMap(fields); + + function writeSlot(slot: Slot, key: string, value: unknown): void { + let record = params[slot] as Record | undefined; + if (record === undefined) { + record = Object.create(null) as Record; + params[slot] = record; + } + record[key] = value; + } + + let config: FieldsConfig[number] | undefined; + + for (const [index, arg] of args.entries()) { + if (fields[index]) { + config = fields[index]; + } + + if (!config) { + continue; + } + + if ('in' in config) { + if (config.key) { + const field = map.get(config.key)!; + const name = field.map || config.key; + if (field.in) { + writeSlot(field.in, name, arg); + } + } else { + params.body = arg; + } + } else { + for (const [key, value] of Object.entries(arg ?? {})) { + const field = map.get(key); + + if (field) { + if (field.in) { + const name = field.map || key; + writeSlot(field.in, name, value); + } else { + params[field.map] = value; + } + } else { + const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix)); + + if (extra) { + const [prefix, slot] = extra; + writeSlot(slot, key.slice(prefix.length), value); + } else if ('allowExtra' in config && config.allowExtra) { + for (const [slot, allowed] of Object.entries(config.allowExtra)) { + if (allowed) { + writeSlot(slot as Slot, key, value); + break; + } + } + } + } + } + } + } + + stripEmptySlots(params); + + return params; +} diff --git a/frontend/src/client/core/pathSerializer.gen.ts b/frontend/src/client/core/pathSerializer.gen.ts new file mode 100644 index 0000000..fab1ed4 --- /dev/null +++ b/frontend/src/client/core/pathSerializer.gen.ts @@ -0,0 +1,171 @@ +// This file is auto-generated by @hey-api/openapi-ts + +interface SerializeOptions extends SerializePrimitiveOptions, SerializerOptions {} + +interface SerializePrimitiveOptions { + allowReserved?: boolean; + name: string; +} + +export interface SerializerOptions { + /** + * @default true + */ + explode: boolean; + style: T; +} + +export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited'; +export type ArraySeparatorStyle = ArrayStyle | MatrixStyle; +type MatrixStyle = 'label' | 'matrix' | 'simple'; +export type ObjectStyle = 'form' | 'deepObject'; +type ObjectSeparatorStyle = ObjectStyle | MatrixStyle; + +interface SerializePrimitiveParam extends SerializePrimitiveOptions { + value: string; +} + +export const separatorArrayExplode = (style: ArraySeparatorStyle): '.' | ';' | ',' | '&' => { + switch (style) { + case 'label': + return '.'; + case 'matrix': + return ';'; + case 'simple': + return ','; + default: + return '&'; + } +}; + +export const separatorArrayNoExplode = (style: ArraySeparatorStyle): ',' | '|' | '%20' => { + switch (style) { + case 'form': + return ','; + case 'pipeDelimited': + return '|'; + case 'spaceDelimited': + return '%20'; + default: + return ','; + } +}; + +export const separatorObjectExplode = (style: ObjectSeparatorStyle): '.' | ';' | ',' | '&' => { + switch (style) { + case 'label': + return '.'; + case 'matrix': + return ';'; + case 'simple': + return ','; + default: + return '&'; + } +}; + +export const serializeArrayParam = ({ + allowReserved, + explode, + name, + style, + value, +}: SerializeOptions & { + value: unknown[]; +}): string => { + if (!explode) { + const joinedValues = ( + allowReserved ? value : value.map((v) => encodeURIComponent(v as string)) + ).join(separatorArrayNoExplode(style)); + switch (style) { + case 'label': + return `.${joinedValues}`; + case 'matrix': + return `;${name}=${joinedValues}`; + case 'simple': + return joinedValues; + default: + return `${name}=${joinedValues}`; + } + } + + const separator = separatorArrayExplode(style); + const joinedValues = value + .map((v) => { + if (style === 'label' || style === 'simple') { + return allowReserved ? v : encodeURIComponent(v as string); + } + + return serializePrimitiveParam({ + allowReserved, + name, + value: v as string, + }); + }) + .join(separator); + return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues; +}; + +export const serializePrimitiveParam = ({ + allowReserved, + name, + value, +}: SerializePrimitiveParam): string => { + if (value === undefined || value === null) { + return ''; + } + + if (typeof value === 'object') { + throw new Error( + 'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.', + ); + } + + return `${name}=${allowReserved ? value : encodeURIComponent(value)}`; +}; + +export const serializeObjectParam = ({ + allowReserved, + explode, + name, + style, + value, + valueOnly, +}: SerializeOptions & { + value: Record | Date; + valueOnly?: boolean; +}): string => { + if (value instanceof Date) { + return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`; + } + + if (style !== 'deepObject' && !explode) { + let values: string[] = []; + Object.entries(value).forEach(([key, v]) => { + values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)]; + }); + const joinedValues = values.join(','); + switch (style) { + case 'form': + return `${name}=${joinedValues}`; + case 'label': + return `.${joinedValues}`; + case 'matrix': + return `;${name}=${joinedValues}`; + default: + return joinedValues; + } + } + + const separator = separatorObjectExplode(style); + const joinedValues = Object.entries(value) + .map(([key, v]) => + serializePrimitiveParam({ + allowReserved, + name: style === 'deepObject' ? `${name}[${key}]` : key, + value: v as string, + }), + ) + .join(separator); + return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues; +}; diff --git a/frontend/src/client/core/queryKeySerializer.gen.ts b/frontend/src/client/core/queryKeySerializer.gen.ts new file mode 100644 index 0000000..773b065 --- /dev/null +++ b/frontend/src/client/core/queryKeySerializer.gen.ts @@ -0,0 +1,117 @@ +// This file is auto-generated by @hey-api/openapi-ts + +/** + * JSON-friendly union that mirrors what Pinia Colada can hash. + */ +export type JsonValue = + | null + | string + | number + | boolean + | JsonValue[] + | { [key: string]: JsonValue }; + +/** + * Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes. + */ +export const queryKeyJsonReplacer = (_key: string, value: unknown): unknown | undefined => { + if (value === undefined || typeof value === 'function' || typeof value === 'symbol') { + return undefined; + } + if (typeof value === 'bigint') { + return value.toString(); + } + if (value instanceof Date) { + return value.toISOString(); + } + return value; +}; + +/** + * Safely stringifies a value and parses it back into a JsonValue. + */ +export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => { + try { + const json = JSON.stringify(input, queryKeyJsonReplacer); + if (json === undefined) { + return undefined; + } + return JSON.parse(json) as JsonValue; + } catch { + return undefined; + } +}; + +/** + * Detects plain objects (including objects with a null prototype). + */ +const isPlainObject = (value: unknown): value is Record => { + if (value === null || typeof value !== 'object') { + return false; + } + const prototype = Object.getPrototypeOf(value as object); + return prototype === Object.prototype || prototype === null; +}; + +/** + * Turns URLSearchParams into a sorted JSON object for deterministic keys. + */ +const serializeSearchParams = (params: URLSearchParams): JsonValue => { + const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b)); + const result: Record = {}; + + for (const [key, value] of entries) { + const existing = result[key]; + if (existing === undefined) { + result[key] = value; + continue; + } + + if (Array.isArray(existing)) { + (existing as string[]).push(value); + } else { + result[key] = [existing, value]; + } + } + + return result; +}; + +/** + * Normalizes any accepted value into a JSON-friendly shape for query keys. + */ +export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined => { + if (value === null) { + return null; + } + + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return value; + } + + if (value === undefined || typeof value === 'function' || typeof value === 'symbol') { + return undefined; + } + + if (typeof value === 'bigint') { + return value.toString(); + } + + if (value instanceof Date) { + return value.toISOString(); + } + + if (Array.isArray(value)) { + return stringifyToJsonValue(value); + } + + if (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams) { + return serializeSearchParams(value); + } + + if (isPlainObject(value)) { + return stringifyToJsonValue(value); + } + + return undefined; +}; diff --git a/frontend/src/client/core/serverSentEvents.gen.ts b/frontend/src/client/core/serverSentEvents.gen.ts new file mode 100644 index 0000000..ddf3c4d --- /dev/null +++ b/frontend/src/client/core/serverSentEvents.gen.ts @@ -0,0 +1,242 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Config } from './types.gen'; + +export type ServerSentEventsOptions = Omit & + Pick & { + /** + * Fetch API implementation. You can use this option to provide a custom + * fetch instance. + * + * @default globalThis.fetch + */ + fetch?: typeof fetch; + /** + * Implementing clients can call request interceptors inside this hook. + */ + onRequest?: (url: string, init: RequestInit) => Promise; + /** + * Callback invoked when a network or parsing error occurs during streaming. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param error The error that occurred. + */ + onSseError?: (error: unknown) => void; + /** + * Callback invoked when an event is streamed from the server. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param event Event streamed from the server. + * @returns Nothing (void). + */ + onSseEvent?: (event: StreamEvent) => void; + serializedBody?: RequestInit['body']; + /** + * Default retry delay in milliseconds. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 3000 + */ + sseDefaultRetryDelay?: number; + /** + * Maximum number of retry attempts before giving up. + */ + sseMaxRetryAttempts?: number; + /** + * Maximum retry delay in milliseconds. + * + * Applies only when exponential backoff is used. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 30000 + */ + sseMaxRetryDelay?: number; + /** + * Optional sleep function for retry backoff. + * + * Defaults to using `setTimeout`. + */ + sseSleepFn?: (ms: number) => Promise; + url: string; + }; + +export interface StreamEvent { + data: TData; + event?: string; + id?: string; + retry?: number; +} + +export type ServerSentEventsResult = { + stream: AsyncGenerator< + TData extends Record ? TData[keyof TData] : TData, + TReturn, + TNext + >; +}; + +export function createSseClient({ + onRequest, + onSseError, + onSseEvent, + responseTransformer, + responseValidator, + sseDefaultRetryDelay, + sseMaxRetryAttempts, + sseMaxRetryDelay, + sseSleepFn, + url, + ...options +}: ServerSentEventsOptions): ServerSentEventsResult { + let lastEventId: string | undefined; + + const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + + const createStream = async function* () { + let retryDelay: number = sseDefaultRetryDelay ?? 3000; + let attempt = 0; + const signal = options.signal ?? new AbortController().signal; + + while (true) { + if (signal.aborted) break; + + attempt++; + + const headers = + options.headers instanceof Headers + ? options.headers + : new Headers(options.headers as Record | undefined); + + if (lastEventId !== undefined) { + headers.set('Last-Event-ID', lastEventId); + } + + try { + const requestInit: RequestInit = { + redirect: 'follow', + ...options, + body: options.serializedBody, + headers, + signal, + }; + let request = new Request(url, requestInit); + if (onRequest) { + request = await onRequest(url, requestInit); + } + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = options.fetch ?? globalThis.fetch; + const response = await _fetch(request); + + if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`); + + if (!response.body) throw new Error('No body in SSE response'); + + const reader = response.body.pipeThrough(new TextDecoderStream()).getReader(); + + let buffer = ''; + + const abortHandler = () => { + try { + reader.cancel(); + } catch { + // noop + } + }; + + signal.addEventListener('abort', abortHandler); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += value; + buffer = buffer.replace(/\r\n?/g, '\n'); // normalize line endings + + const chunks = buffer.split('\n\n'); + buffer = chunks.pop() ?? ''; + + for (const chunk of chunks) { + const lines = chunk.split('\n'); + const dataLines: Array = []; + let eventName: string | undefined; + + for (const line of lines) { + if (line.startsWith('data:')) { + dataLines.push(line.replace(/^data:\s*/, '')); + } else if (line.startsWith('event:')) { + eventName = line.replace(/^event:\s*/, ''); + } else if (line.startsWith('id:')) { + lastEventId = line.replace(/^id:\s*/, ''); + } else if (line.startsWith('retry:')) { + const parsed = Number.parseInt(line.replace(/^retry:\s*/, ''), 10); + if (!Number.isNaN(parsed)) { + retryDelay = parsed; + } + } + } + + let data: unknown; + let parsedJson = false; + + if (dataLines.length) { + const rawData = dataLines.join('\n'); + try { + data = JSON.parse(rawData); + parsedJson = true; + } catch { + data = rawData; + } + } + + if (parsedJson) { + if (responseValidator) { + await responseValidator(data); + } + + if (responseTransformer) { + data = await responseTransformer(data); + } + } + + onSseEvent?.({ + data, + event: eventName, + id: lastEventId, + retry: retryDelay, + }); + + if (dataLines.length) { + yield data as any; + } + } + } + } finally { + signal.removeEventListener('abort', abortHandler); + reader.releaseLock(); + } + + break; // exit loop on normal completion + } catch (error) { + // connection failed or aborted; retry after delay + onSseError?.(error); + + if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) { + break; // stop after firing error + } + + // exponential backoff: double retry each attempt, cap at 30s + const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000); + await sleep(backoff); + } + } + }; + + const stream = createStream(); + + return { stream }; +} diff --git a/frontend/src/client/core/types.gen.ts b/frontend/src/client/core/types.gen.ts new file mode 100644 index 0000000..c657c85 --- /dev/null +++ b/frontend/src/client/core/types.gen.ts @@ -0,0 +1,110 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Auth, AuthToken } from './auth.gen'; +import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from './bodySerializer.gen'; + +export type HttpMethod = + | 'connect' + | 'delete' + | 'get' + | 'head' + | 'options' + | 'patch' + | 'post' + | 'put' + | 'trace'; + +export type Client< + RequestFn = never, + Config = unknown, + MethodFn = never, + BuildUrlFn = never, + SseFn = never, +> = { + /** + * Returns the final request URL. + */ + buildUrl: BuildUrlFn; + getConfig: () => Config; + request: RequestFn; + setConfig: (config: Config) => Config; +} & { + [K in HttpMethod]: MethodFn; +} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } }); + +export interface Config { + /** + * Auth token or a function returning auth token. The resolved value will be + * added to the request payload as defined by its `security` array. + */ + auth?: ((auth: Auth) => Promise | AuthToken) | AuthToken; + /** + * A function for serializing request body parameter. By default, + * {@link JSON.stringify()} will be used. + */ + bodySerializer?: BodySerializer | null; + /** + * An object containing any HTTP headers that you want to pre-populate your + * `Headers` object with. + * + * {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more} + */ + headers?: + | RequestInit['headers'] + | Record< + string, + string | number | boolean | (string | number | boolean)[] | null | undefined | unknown + >; + /** + * The request method. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more} + */ + method?: Uppercase; + /** + * A function for serializing request query parameters. By default, arrays + * will be exploded in form style, objects will be exploded in deepObject + * style, and reserved characters are percent-encoded. + * + * This method will have no effect if the native `paramsSerializer()` Axios + * API function is used. + * + * {@link https://swagger.io/docs/specification/serialization/#query View examples} + */ + querySerializer?: QuerySerializer | QuerySerializerOptions; + /** + * A function validating request data. This is useful if you want to ensure + * the request conforms to the desired shape, so it can be safely sent to + * the server. + */ + requestValidator?: (data: unknown) => Promise; + /** + * A function transforming response data before it's returned. This is useful + * for post-processing data, e.g., converting ISO strings into Date objects. + */ + responseTransformer?: (data: unknown) => Promise; + /** + * A function validating response data. This is useful if you want to ensure + * the response conforms to the desired shape, so it can be safely passed to + * the transformers and returned to the user. + */ + responseValidator?: (data: unknown) => Promise; +} + +/** + * Arbitrary metadata passed through the `meta` request option. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface ClientMeta {} + +type IsExactlyNeverOrNeverUndefined = [T] extends [never] + ? true + : [T] extends [never | undefined] + ? [undefined] extends [T] + ? false + : true + : false; + +export type OmitNever> = { + [K in keyof T as IsExactlyNeverOrNeverUndefined extends true ? never : K]: T[K]; +}; diff --git a/frontend/src/client/core/utils.gen.ts b/frontend/src/client/core/utils.gen.ts new file mode 100644 index 0000000..af56e07 --- /dev/null +++ b/frontend/src/client/core/utils.gen.ts @@ -0,0 +1,140 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { BodySerializer, QuerySerializer } from './bodySerializer.gen'; +import { + type ArraySeparatorStyle, + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from './pathSerializer.gen'; + +export interface PathSerializer { + path: Record; + url: string; +} + +export const PATH_PARAM_RE: RegExp = /\{[^{}]+\}/g; + +export const defaultPathSerializer = ({ path, url: _url }: PathSerializer): string => { + let url = _url; + const matches = _url.match(PATH_PARAM_RE); + if (matches) { + for (const match of matches) { + let explode = false; + let name = match.substring(1, match.length - 1); + let style: ArraySeparatorStyle = 'simple'; + + if (name.endsWith('*')) { + explode = true; + name = name.substring(0, name.length - 1); + } + + if (name.startsWith('.')) { + name = name.substring(1); + style = 'label'; + } else if (name.startsWith(';')) { + name = name.substring(1); + style = 'matrix'; + } + + const value = path[name]; + + if (value === undefined || value === null) { + continue; + } + + if (Array.isArray(value)) { + url = url.replace(match, serializeArrayParam({ explode, name, style, value })); + continue; + } + + if (typeof value === 'object') { + url = url.replace( + match, + serializeObjectParam({ + explode, + name, + style, + value: value as Record, + valueOnly: true, + }), + ); + continue; + } + + if (style === 'matrix') { + url = url.replace( + match, + `;${serializePrimitiveParam({ + name, + value: value as string, + })}`, + ); + continue; + } + + const replaceValue = encodeURIComponent( + style === 'label' ? `.${value as string}` : (value as string), + ); + url = url.replace(match, replaceValue); + } + } + return url; +}; + +export const getUrl = ({ + baseUrl, + path, + query, + querySerializer, + url: _url, +}: { + baseUrl?: string; + path?: Record; + query?: Record; + querySerializer: QuerySerializer; + url: string; +}): string => { + const pathUrl = _url.startsWith('/') ? _url : `/${_url}`; + let url = (baseUrl ?? '') + pathUrl; + if (path) { + url = defaultPathSerializer({ path, url }); + } + let search = query ? querySerializer(query) : ''; + if (search.startsWith('?')) { + search = search.substring(1); + } + if (search) { + url += `?${search}`; + } + return url; +}; + +export function getValidRequestBody(options: { + body?: unknown; + bodySerializer?: BodySerializer | null; + serializedBody?: unknown; +}): unknown { + const hasBody = options.body !== undefined; + const isSerializedBody = hasBody && options.bodySerializer; + + if (isSerializedBody) { + if ('serializedBody' in options) { + const hasSerializedBody = + options.serializedBody !== undefined && options.serializedBody !== ''; + + return hasSerializedBody ? options.serializedBody : null; + } + + // not all clients implement a serializedBody property (i.e., client-axios) + return options.body !== '' ? options.body : null; + } + + // plain/text body + if (hasBody) { + return options.body; + } + + // no body was provided + return undefined; +} diff --git a/frontend/src/client/index.ts b/frontend/src/client/index.ts new file mode 100644 index 0000000..5d92911 --- /dev/null +++ b/frontend/src/client/index.ts @@ -0,0 +1,4 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export { getHealth, listNotes, type Options } from './sdk.gen'; +export type { ClientOptions, GetHealthData, GetHealthResponse, GetHealthResponses, HealthResponse, ListNotesData, ListNotesResponse, ListNotesResponses, NoteResponse } from './types.gen'; diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts new file mode 100644 index 0000000..668aa73 --- /dev/null +++ b/frontend/src/client/sdk.gen.ts @@ -0,0 +1,29 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Client, ClientMeta, Options as Options2, RequestResult, TDataShape } from './client'; +import { client } from './client.gen'; +import type { GetHealthData, GetHealthResponses, ListNotesData, ListNotesResponses } from './types.gen'; + +export type Options = Options2 & { + /** + * You can provide a client instance returned by `createClient()` instead of + * individual options. This might be also useful if you want to implement a + * custom client. + */ + client?: Client; + /** + * You can pass arbitrary values through the `meta` object. This can be + * used to access values that aren't defined as part of the SDK function. + */ + meta?: keyof ClientMeta extends never ? Record : ClientMeta; +}; + +/** + * Get Health + */ +export const getHealth = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/v1/health', ...options }); + +/** + * List Notes + */ +export const listNotes = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/v1/notes', ...options }); diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts new file mode 100644 index 0000000..eef6f6f --- /dev/null +++ b/frontend/src/client/types.gen.ts @@ -0,0 +1,67 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type ClientOptions = { + baseUrl: `${string}://${string}` | (string & {}); +}; + +/** + * HealthResponse + */ +export type HealthResponse = { + /** + * Status + */ + status: 'ok'; +}; + +/** + * NoteResponse + */ +export type NoteResponse = { + /** + * Id + */ + id: number; + /** + * Body + */ + body: string; + /** + * Created At + */ + created_at: string; +}; + +export type GetHealthData = { + body?: never; + path?: never; + query?: never; + url: '/api/v1/health'; +}; + +export type GetHealthResponses = { + /** + * Successful Response + */ + 200: HealthResponse; +}; + +export type GetHealthResponse = GetHealthResponses[keyof GetHealthResponses]; + +export type ListNotesData = { + body?: never; + path?: never; + query?: never; + url: '/api/v1/notes'; +}; + +export type ListNotesResponses = { + /** + * Response List Notes + * + * Successful Response + */ + 200: Array; +}; + +export type ListNotesResponse = ListNotesResponses[keyof ListNotesResponses]; diff --git a/frontend/src/components/NoteList.tsx b/frontend/src/components/NoteList.tsx new file mode 100644 index 0000000..0afda85 --- /dev/null +++ b/frontend/src/components/NoteList.tsx @@ -0,0 +1,41 @@ +import { useState } from "react"; + +import { listNotes } from "@/client"; +import type { NoteResponse } from "@/client"; + +export function NoteList() { + const [notes, setNotes] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + async function loadNotes() { + setLoading(true); + setError(null); + try { + const { data } = await listNotes({ throwOnError: true }); + setNotes(data); + } catch { + setError("Could not load notes. Is the backend running?"); + } finally { + setLoading(false); + } + } + + return ( +
+ + + {error &&

{error}

} + {notes?.length === 0 &&

No notes yet.

} + {notes && notes.length > 0 && ( +
    + {notes.map((note) => ( +
  • {note.body}
  • + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..ac1c592 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; + +import { App } from "@/App"; + +const root = document.getElementById("root"); +if (!root) throw new Error("index.html is missing
"); + +createRoot(root).render( + + + , +); diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..2d8028c --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "moduleDetection": "force", + "jsx": "react-jsx", + "noEmit": true, + "skipLibCheck": true, + + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, + + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..d32ff68 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,4 @@ +{ + "files": [], + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..2c06519 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "moduleResolution": "bundler", + "moduleDetection": "force", + "types": ["node"], + "noEmit": true, + "skipLibCheck": true, + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true + }, + "include": ["vite.config.ts", "openapi-ts.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..d268c24 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,22 @@ +import { fileURLToPath } from "node:url"; + +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) }, + }, + server: { + // Listen on all interfaces so the browser on your machine can reach the container. + host: true, + port: 5173, + strictPort: true, + // Forward /api to the backend container, mirroring what nginx does when deployed. + // Code always calls relative URLs like /api/v1/notes. + proxy: { + "/api": "http://backend:8000", + }, + }, +}); diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..ad16e59 --- /dev/null +++ b/mise.toml @@ -0,0 +1,178 @@ +[tools] +node = "24.22.0" +pnpm = "12.14.0" +python = "3.14.8" +uv = "0.13.1" +ruff = "0.17.1" + +[env] +# Loads DATABASE_URL and friends for the tasks below. Missing in CI, which is fine. +_.file = ".env" + +# ─── setup ──────────────────────────────────────────────────────────────────── + +[tasks.install] +description = "Install frontend and backend dependencies on your machine (for editors and checks)" +run = ["uv sync --directory backend", "pnpm install --dir frontend"] + +# ─── dev ────────────────────────────────────────────────────────────────────── + +[tasks.dev] +description = "Start Postgres, the backend and the frontend with hot reload (http://localhost:5173)" +run = "docker compose up --build" + +[tasks.down] +description = "Stop every container (data in Postgres is kept)" +run = "docker compose down" + +[tasks.logs] +description = "Tail logs from every container (arg: service name, e.g. backend)" +usage = 'arg "[service]" default=""' +run = 'docker compose logs -f $usage_service' + +# ─── database ───────────────────────────────────────────────────────────────── + +[tasks."db:migrate"] +description = "Apply pending migrations (the backend container also does this on start)" +dir = "backend" +run = "uv run alembic upgrade head" + +[tasks."db:rollback"] +description = "Roll back the latest migration" +dir = "backend" +run = "uv run alembic downgrade -1" + +[tasks."db:revision"] +description = "Generate a migration from changes to the models in backend/src/app/models (arg: short description)" +usage = 'arg ""' +dir = "backend" +run = 'uv run alembic revision --autogenerate -m "$usage_message"' + +[tasks."db:psql"] +description = "Open psql on the local database" +run = 'docker compose exec postgres psql -U "$SERVICE_USER_POSTGRES" -d "$POSTGRES_DB"' + +[tasks."db:reset"] +description = "Delete the local database volume and start fresh" +confirm = "Delete all local database data?" +run = "docker compose down --volumes" + +# ─── contract ───────────────────────────────────────────────────────────────── + +[tasks."contract:export"] +description = "Write the backend's OpenAPI contract to backend/openapi.json" +dir = "backend" +run = "uv run python -m app.export_openapi" + +[tasks."client:generate"] +description = "Export the contract, then regenerate the typed frontend client in frontend/src/client" +depends = ["contract:export"] +dir = "frontend" +run = "pnpm run generate:client" + +[tasks."contract:check"] +description = "Fail if backend/openapi.json doesn't match the routes" +dir = "backend" +run = "uv run python -m app.export_openapi --check" + +[tasks."client:check"] +description = "Fail if frontend/src/client doesn't match backend/openapi.json" +dir = "frontend" +run = ''' +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +pnpm exec openapi-ts --silent --output "$tmp/client" +if ! diff -r src/client "$tmp/client" > /dev/null; then + echo "frontend/src/client is out of date. Run: mise run client:generate" + exit 1 +fi +echo "frontend/src/client is up to date" +''' + +# ─── lint and format ────────────────────────────────────────────────────────── + +[tasks."lint:backend"] +description = "Lint the backend (ruff)" +dir = "backend" +run = "ruff check ." + +[tasks."lint:backend:fix"] +description = "Fix the backend's lint issues" +dir = "backend" +run = "ruff check --fix ." + +[tasks."lint:frontend"] +description = "Lint the frontend (oxlint)" +dir = "frontend" +run = "pnpm run lint" + +[tasks."lint:frontend:fix"] +description = "Fix the frontend's lint issues" +dir = "frontend" +run = "pnpm run lint:fix" + +[tasks.lint] +description = "Lint the backend and frontend" +depends = ["lint:backend", "lint:frontend"] + +[tasks."lint:fix"] +description = "Fix lint issues in the backend and frontend" +depends = ["lint:backend:fix", "lint:frontend:fix"] + +[tasks."fmt:backend"] +description = "Check the backend's formatting (ruff)" +dir = "backend" +run = "ruff format --check ." + +[tasks."fmt:backend:fix"] +description = "Format the backend" +dir = "backend" +run = "ruff format ." + +[tasks."fmt:frontend"] +description = "Check the frontend's formatting (oxfmt)" +dir = "frontend" +run = "pnpm run format" + +[tasks."fmt:frontend:fix"] +description = "Format the frontend" +dir = "frontend" +run = "pnpm run format:fix" + +[tasks.fmt] +alias = ["format", "fmt:check"] +description = "Check formatting for the backend and frontend" +depends = ["fmt:backend", "fmt:frontend"] + +[tasks."fmt:fix"] +alias = "format:fix" +description = "Format the backend and frontend" +depends = ["fmt:backend:fix", "fmt:frontend:fix"] + +# ─── typecheck, build ───────────────────────────────────────────────────────── + +[tasks."typecheck:backend"] +description = "Typecheck the backend (mypy --strict)" +dir = "backend" +run = "uv run mypy" + +[tasks."typecheck:frontend"] +description = "Typecheck the frontend (tsc)" +dir = "frontend" +run = "pnpm run typecheck" + +[tasks.typecheck] +description = "Typecheck the backend and frontend" +depends = ["typecheck:backend", "typecheck:frontend"] + +[tasks.check] +description = "Run everything CI runs" +depends = ["fmt", "lint", "typecheck", "contract:check", "client:check"] + +# A separate project name keeps this from replacing your `mise run dev` containers. +[tasks."deploy:check"] +description = "Build the deployed stack (nginx, no hot reload), wait until every container is healthy, then stop it" +run = [ + "docker compose -p python-react-starter-deploy -f compose.yaml up --build --wait", + "docker compose -p python-react-starter-deploy -f compose.yaml down --volumes", +] From f89e40da7718a3440177fc363258a4b06392c2b0 Mon Sep 17 00:00:00 2001 From: Anish Sahoo Date: Mon, 19 Oct 2026 20:05:44 -0400 Subject: [PATCH 2/6] feat: rebuild backend to match the typescript starter - messages example with the same API, operationIds and schemas - plain SQL migrations generated from SQLAlchemy models and applied by dbmate - flatter layout: config, middleware, models, routes, schemas - stdlib logging with rich, no lifespan, no served docs pages - frontend matches the typescript starter, client is gitignored - same mise tasks and CI jobs as the other starters --- .agents/rules/backend.md | 46 ++- .agents/rules/frontend.md | 23 +- .dockerignore | 7 + .env.example | 19 +- .gitattributes | 9 - .github/workflows/ci.yml | 62 ++-- .gitignore | 21 +- AGENTS.md | 82 +++-- CLAUDE.md | 1 - README.md | 273 +++++++-------- backend/Dockerfile | 32 +- backend/alembic.ini | 43 --- backend/migrations/20261019232056_init.sql | 14 + backend/migrations/env.py | 43 --- backend/migrations/script.py.mako | 24 -- .../20261019_1817_create_notes_table.py | 44 --- backend/openapi.json | 48 ++- backend/pyproject.toml | 13 +- backend/scripts/generate_migration.py | 78 +++++ backend/scripts/openapi.py | 14 + backend/src/app/{core => config}/__init__.py | 0 backend/src/app/{core => config}/db.py | 6 +- backend/src/app/config/logger.py | 25 ++ backend/src/app/{core => config}/settings.py | 6 +- backend/src/app/core/http_client.py | 16 - backend/src/app/core/logging_config.py | 77 ----- backend/src/app/core/middleware.py | 64 ---- backend/src/app/export_openapi.py | 31 -- backend/src/app/main.py | 69 ++-- backend/src/app/middleware/__init__.py | 0 backend/src/app/middleware/request_logger.py | 37 ++ backend/src/app/models/__init__.py | 6 +- .../src/app/models/{note.py => message.py} | 6 +- backend/src/app/routes/__init__.py | 4 +- backend/src/app/routes/health.py | 15 +- backend/src/app/routes/messages.py | 17 + backend/src/app/routes/notes.py | 14 - backend/src/app/schemas/__init__.py | 4 + backend/src/app/schemas/health.py | 2 +- backend/src/app/schemas/message.py | 15 + backend/src/app/schemas/note.py | 11 - backend/uv.lock | 53 ++- compose.override.yaml | 48 +-- compose.yaml | 51 ++- frontend/.dockerignore | 2 - frontend/.oxfmtrc.json | 3 +- frontend/.oxlintrc.json | 17 - frontend/Dockerfile | 42 +-- frontend/index.html | 2 +- frontend/nginx.conf | 36 +- frontend/openapi-ts.config.ts | 5 +- frontend/oxlint.config.ts | 21 ++ frontend/package.json | 11 +- frontend/pnpm-lock.yaml | 8 + frontend/src/App.tsx | 31 +- frontend/src/client/client.gen.ts | 16 - frontend/src/client/client/client.gen.ts | 277 --------------- frontend/src/client/client/index.ts | 27 -- frontend/src/client/client/types.gen.ts | 218 ------------ frontend/src/client/client/utils.gen.ts | 316 ------------------ frontend/src/client/core/auth.gen.ts | 48 --- .../src/client/core/bodySerializer.gen.ts | 82 ----- frontend/src/client/core/params.gen.ts | 178 ---------- .../src/client/core/pathSerializer.gen.ts | 171 ---------- .../src/client/core/queryKeySerializer.gen.ts | 117 ------- .../src/client/core/serverSentEvents.gen.ts | 242 -------------- frontend/src/client/core/types.gen.ts | 110 ------ frontend/src/client/core/utils.gen.ts | 140 -------- frontend/src/client/index.ts | 4 - frontend/src/client/sdk.gen.ts | 29 -- frontend/src/client/types.gen.ts | 67 ---- frontend/src/components/NoteList.tsx | 41 --- frontend/src/main.tsx | 5 +- frontend/tsconfig.app.json | 28 -- frontend/tsconfig.json | 22 +- frontend/tsconfig.node.json | 20 -- frontend/vite.config.ts | 11 +- mise.toml | 233 +++++++------ 78 files changed, 851 insertions(+), 3202 deletions(-) create mode 100644 .dockerignore delete mode 120000 CLAUDE.md delete mode 100644 backend/alembic.ini create mode 100644 backend/migrations/20261019232056_init.sql delete mode 100644 backend/migrations/env.py delete mode 100644 backend/migrations/script.py.mako delete mode 100644 backend/migrations/versions/20261019_1817_create_notes_table.py create mode 100644 backend/scripts/generate_migration.py create mode 100644 backend/scripts/openapi.py rename backend/src/app/{core => config}/__init__.py (100%) rename backend/src/app/{core => config}/db.py (73%) create mode 100644 backend/src/app/config/logger.py rename backend/src/app/{core => config}/settings.py (53%) delete mode 100644 backend/src/app/core/http_client.py delete mode 100644 backend/src/app/core/logging_config.py delete mode 100644 backend/src/app/core/middleware.py delete mode 100644 backend/src/app/export_openapi.py create mode 100644 backend/src/app/middleware/__init__.py create mode 100644 backend/src/app/middleware/request_logger.py rename backend/src/app/models/{note.py => message.py} (77%) create mode 100644 backend/src/app/routes/messages.py delete mode 100644 backend/src/app/routes/notes.py create mode 100644 backend/src/app/schemas/message.py delete mode 100644 backend/src/app/schemas/note.py delete mode 100644 frontend/.dockerignore delete mode 100644 frontend/.oxlintrc.json create mode 100644 frontend/oxlint.config.ts delete mode 100644 frontend/src/client/client.gen.ts delete mode 100644 frontend/src/client/client/client.gen.ts delete mode 100644 frontend/src/client/client/index.ts delete mode 100644 frontend/src/client/client/types.gen.ts delete mode 100644 frontend/src/client/client/utils.gen.ts delete mode 100644 frontend/src/client/core/auth.gen.ts delete mode 100644 frontend/src/client/core/bodySerializer.gen.ts delete mode 100644 frontend/src/client/core/params.gen.ts delete mode 100644 frontend/src/client/core/pathSerializer.gen.ts delete mode 100644 frontend/src/client/core/queryKeySerializer.gen.ts delete mode 100644 frontend/src/client/core/serverSentEvents.gen.ts delete mode 100644 frontend/src/client/core/types.gen.ts delete mode 100644 frontend/src/client/core/utils.gen.ts delete mode 100644 frontend/src/client/index.ts delete mode 100644 frontend/src/client/sdk.gen.ts delete mode 100644 frontend/src/client/types.gen.ts delete mode 100644 frontend/src/components/NoteList.tsx delete mode 100644 frontend/tsconfig.app.json delete mode 100644 frontend/tsconfig.node.json diff --git a/.agents/rules/backend.md b/.agents/rules/backend.md index 41eb9b7..3ce26b8 100644 --- a/.agents/rules/backend.md +++ b/.agents/rules/backend.md @@ -3,36 +3,34 @@ paths: - "backend/**" --- -# Backend rules +# Backend -FastAPI, SQLAlchemy and Alembic rules for `backend/`, on top of the root -`AGENTS.md`. Copy the shape of the notes example for a new resource: -`models/note.py`, `schemas/note.py`, `routes/notes.py`. +FastAPI, pydantic and SQLAlchemy. Copy the shape of the messages example for a new +resource: `models/message.py`, `schemas/message.py`, `routes/messages.py`. ## Where code goes -- `routes/`: HTTP handlers. One file per resource, added to `api_router` in +- `main.py`: creates the app and wires in middleware and routers. Run it with + `python -m app.main`. +- `routes/`: HTTP handlers, one file per resource, added to `api_router` in `routes/__init__.py`. Every route lives under `/api/v1`. -- `models/`: SQLAlchemy tables, one file per table. Import each one in - `models/__init__.py`, or Alembic will not see it. +- `models/`: SQLAlchemy tables, one file per table, imported in `models/__init__.py`. - `schemas/`: pydantic request and response models, one file per resource. -- `core/`: app-wide setup (settings, database, logging, middleware, HTTP - client). Feature code does not go here. +- `middleware/`: request middleware. +- `config/`: settings, the database session and logging. ## Rules -- Routes are `async def` and use `SessionDep` for the database. A blocking - call (sync DB driver, `requests`, `time.sleep`) stalls every other request, - including `/api/v1/health`. Use `HttpClientDep` to call other services. -- Every route declares a pydantic response model as its return type. Never - return a SQLAlchemy model or a bare `dict`. -- Change the schema only by editing models, then run - `mise run db:revision "what changed"`. Read the generated file before - committing it. Never edit a migration that is already on `main`. -- End every `ORDER BY` with a unique column, usually `id`, so rows come back in - the same order every time. -- Log with `logging.getLogger(__name__)`. Routine success is `debug`. Do not - log passwords, tokens or full request bodies. -- New environment variables go in `core/settings.py` and `.env.example`. -- Code must pass `mypy --strict`. Avoid `Any` and `# type: ignore`. If you need - one, add a comment saying why. +- Routes are `async def` and use `SessionDep` for the database. Call other services + with `httpx.AsyncClient`, never `requests`. +- The handler's name becomes the operationId in camelCase, so `list_messages` becomes + `listMessages()` in the frontend. Name handlers after what they do. +- Every route declares a pydantic response model as its return type. Never return a + SQLAlchemy model or a bare `dict`. Schemas use camelCase aliases in JSON. +- Query with `select()` / `insert()` / `update()` / `delete()` or `text()` only. See + "Queries" in `AGENTS.md`. +- Change tables only through `models/` and `mise run db:dev:migrate:generate `. +- End every `ORDER BY` with a unique column, usually `id`. +- Log with `logging.getLogger(__name__)`. Never `print`. +- New environment variables go in `config/settings.py` and `.env.example`. +- Code must pass `mypy --strict`. Avoid `Any` and `# type: ignore`. diff --git a/.agents/rules/frontend.md b/.agents/rules/frontend.md index e11892b..0f08802 100644 --- a/.agents/rules/frontend.md +++ b/.agents/rules/frontend.md @@ -3,19 +3,12 @@ paths: - "frontend/**" --- -# Frontend rules +# Frontend -React and TypeScript rules for `frontend/`, on top of the root `AGENTS.md`. - -- Call the backend only through the generated client in `src/client` - (`listNotes()` and friends). Do not hand-write `fetch` calls or response - types for the API. -- Never edit `src/client` by hand. Change the backend, then run - `mise run client:generate`. -- Use relative URLs (`/api/v1/...`). Vite forwards them locally and nginx does - the same when deployed, so the app never needs a backend hostname. -- Import from `src` with the `@/` alias: `import { App } from "@/App"`. -- No `any`, no `as` casts to silence the compiler, no non-null `!`. Fix the - type instead. -- One component per file, named after the component. -- Handle the loading and error states of every API call, not just success. +- Call the backend only through the generated SDK in `src/api/generated`. No raw + `fetch` to `/api`. +- If the SDK is missing a route or a field, change the backend and run + `mise run contract`. Do not hand-write types for API responses. +- Use relative `/api/...` URLs. Vite proxies them in dev and nginx proxies them when + deployed. +- Use arrow functions for components and handlers: `export const App = () => {}`. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8086bd0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +**/node_modules +**/dist +**/.env +**/.venv +**/__pycache__ +frontend/src/api/generated +.git diff --git a/.env.example b/.env.example index 30113e4..689ae64 100644 --- a/.env.example +++ b/.env.example @@ -1,20 +1,7 @@ -# Copy to .env before running anything: cp .env.example .env -# .env is gitignored. These defaults are fine for local development. - -# Postgres login and database name. Docker Compose creates the database with -# these. On Coolify, SERVICE_* variables are generated for you per environment. SERVICE_USER_POSTGRES=app SERVICE_PASSWORD_POSTGRES=app POSTGRES_DB=app - -# Port Postgres is published on at localhost. Change it if something else on -# your machine already uses 5432, and change it in DATABASE_URL below too. POSTGRES_PORT=5432 - -# Used by commands you run on your machine (mise run db:migrate, db:revision), -# which reach Postgres through the port above. The backend container builds -# its own URL from the values above. -DATABASE_URL=postgresql://app:app@localhost:5432/app - -# DEBUG, INFO, WARNING or ERROR. -LOG_LEVEL=INFO +DATABASE_URL=postgresql://app:app@localhost:5432/app?sslmode=disable +PORT=3000 +LOG_LEVEL=info diff --git a/.gitattributes b/.gitattributes index 231021d..3a861ba 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,12 +1,3 @@ # Force LF everywhere. Files are executed inside Linux containers; # CRLF line endings from Windows checkouts break shell scripts and config. * text=auto eol=lf - -*.png binary -*.jpg binary -*.ico binary -*.woff2 binary - -# Generated from the backend's contract. Collapsed in GitHub diffs. -frontend/src/client/** linguist-generated=true -backend/openapi.json linguist-generated=true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e070a23..ba23890 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,63 +1,57 @@ name: CI on: - pull_request: push: branches: [main] + pull_request: concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true -# Every step calls the same mise task you run locally, so `mise run check` +# Every step runs the same mise task you run locally, so `mise run check` # passing on your machine means these pass too. jobs: - backend_lint: - name: backend / lint + frontend_lint: + name: frontend / lint runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: jdx/mise-action@v5 - with: - install_args: python uv ruff - - run: uv sync --locked --directory backend - - run: mise run lint:backend - - run: mise run typecheck:backend - - run: mise run contract:check - env: - # Settings require a URL to import the app. Nothing connects to it. - DATABASE_URL: postgresql://ci:ci@localhost:5432/ci + - run: pnpm install --frozen-lockfile --dir frontend + - run: mise run lint:frontend - backend_format: - name: backend / format + frontend_format: + name: frontend / format runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: jdx/mise-action@v5 - with: - install_args: ruff - - run: mise run fmt:backend + - run: pnpm install --frozen-lockfile --dir frontend + - run: mise run fmt:frontend - frontend_lint: - name: frontend / lint + backend_lint: + name: backend / lint runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: jdx/mise-action@v5 - with: - install_args: node pnpm - - run: pnpm install --frozen-lockfile --dir frontend - - run: mise run lint:frontend - - run: mise run typecheck:frontend - - run: mise run client:check + - run: uv sync --locked --directory backend + - run: mise run lint:backend - frontend_format: - name: frontend / format + backend_contract: + name: backend / contract runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: jdx/mise-action@v5 - with: - install_args: node pnpm - - run: pnpm install --frozen-lockfile --dir frontend - - run: mise run fmt:frontend + - run: uv sync --locked --directory backend + - run: mise run contract:check + + backend_format: + name: backend / format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: jdx/mise-action@v5 + - run: mise run fmt:backend diff --git a/.gitignore b/.gitignore index d26d92f..13e6831 100644 --- a/.gitignore +++ b/.gitignore @@ -1,20 +1,15 @@ -**/node_modules/ -**/dist/ +node_modules/ +dist/ *.tsbuildinfo +.DS_Store -**/.venv/ -**/__pycache__/ -**/*.egg-info/ -**/.mypy_cache/ -**/.ruff_cache/ -**/.pytest_cache/ +.venv/ +__pycache__/ +.mypy_cache/ +.ruff_cache/ .env .env.* !.env.example -.DS_Store -*.log -.idea/ -.vscode/* -!.vscode/extensions.json +frontend/src/api/generated/ diff --git a/AGENTS.md b/AGENTS.md index 0eb6ef8..2c9051b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,43 +1,63 @@ -# Working with agents +# Working in this repo -Rules for anyone writing code in this repo, human or AI agent. Coding agents -(Claude Code, Codex, Cursor, Copilot) read this file automatically. -`CLAUDE.md` is a symlink to it. +Rules for anyone changing code here, human or agent. Before editing `backend/` or +`frontend/`, also read the matching file in `.agents/rules/`. -This is a starting point. Add a rule when you catch yourself correcting the -same mistake twice. +## Before you start -Before changing `backend/`, read `.agents/rules/backend.md`. Before changing -`frontend/`, read `.agents/rules/frontend.md`. +- Read `README.md` and the code you are about to change. +- Make the smallest change that does the job. No extra abstractions, dependencies or + config the task does not need. +- Follow the patterns already in the repo before inventing new ones. -## Before you change anything +## Writing code -- Read the code you are about to change, and the code that calls it. -- Make the smallest change that solves the problem. -- Follow the patterns already in the repo. If one looks wrong, ask before - replacing it. +- Clear names over comments. Only comment what a reader would otherwise get wrong. +- Guard clauses and early returns instead of nested `if`s. +- Keep functions short and focused on one thing. +- Delete dead code and commented-out code. Git remembers it. +- Validate untrusted input once, with pydantic, where it enters the system. Trust it + after that. +- In the frontend, write modern TypeScript: arrow functions, `type` instead of + `interface`, no `enum` or `namespace`. -## Adding code and dependencies +## The database -- Do not add a dependency for something the standard library or an existing - dependency already does. Say why when you add one. -- Do not add abstractions, config options or files the task does not need. -- Two similar blocks of code are fine. Extract a shared function on the third. +The SQLAlchemy models in `backend/src/app/models/` are the source of truth for every +table. Change the database only this way: -## Writing code +1. Edit a model, or add one and import it in `models/__init__.py`. +2. Run `mise run db:dev:migrate:generate `. It compares the models with the + database and writes the SQL to a new file in `backend/migrations/`. +3. Read the generated SQL, then run `mise run db:dev:migrate:up`. +4. `mise run db:dev:migrate:status` shows whether the database is missing migrations. + +Never write schema SQL by hand and never edit a migration that is already on `main`. +The only SQL you add to a migration is seed data. + +## Queries + +Every query must read as SQL. Use SQLAlchemy's `select()`, `insert()`, `update()` and +`delete()`, or `text()` with bound parameters, and run them on the session: + +```python +rows = await session.scalars( + select(models.Message).where(models.Message.id == message_id) +) +``` + +- Never use the ORM's object style: `session.query()`, `session.get()`, + `session.add()`, `session.delete()` or lazy-loaded relationships. +- Never build SQL strings with f-strings or `%`. Pass values as parameters. + +## The API contract -- Names carry the meaning. If a variable needs a comment to explain what it - holds, rename it. -- Comments explain why, not what. Delete comments that restate the code. -- Use guard clauses and early returns instead of nested `if`s. -- Validate input once, where it enters the system (request bodies, query - params). Trust it after that. -- Do not swallow errors. If you cannot handle one, let it surface. -- Delete code nothing uses, including commented-out code. +The routes and pydantic schemas are the source of truth for the API. +`backend/openapi.json` is generated from them and committed. +`frontend/src/api/generated/` is generated from that file and gitignored. Never edit +either by hand. After changing a route, run `mise run contract` and commit +`backend/openapi.json`. ## Finishing -- Run `mise run check` and fix what it reports. It runs the same checks as CI. -- If you changed a route or a response schema, run `mise run client:generate` - and commit the regenerated files. -- Say what you ran and what you did not. +- Run `mise run check` and fix everything it reports. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 120000 index 47dc3e3..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md \ No newline at end of file diff --git a/README.md b/README.md index 76cf84c..a3d296f 100644 --- a/README.md +++ b/README.md @@ -1,150 +1,151 @@ -# Python React Starter +# Python React starter -A starting point for a web app with a React frontend, a FastAPI backend and a -Postgres database. Docker Compose runs all three. [mise](https://mise.jdx.dev) -installs the tools and runs every command. +A three-container web app: a React frontend, a FastAPI backend and a Postgres +database. It ships with one example feature (a list of messages) that shows a request +going from React to FastAPI to Postgres and back. Replace it with your own. + +There are matching TypeScript (Hono) and Java (Spring Boot) starters with the same +API, the same frontend and the same `mise` tasks. + +| Part | Stack | +| -------- | ------------------------------------------------------------------------------ | +| Frontend | React, Vite, a client generated from the API contract by `@hey-api/openapi-ts` | +| Backend | FastAPI, pydantic, SQLAlchemy (async), httpx, logging with rich | +| Database | Postgres 18, migrations generated by Alembic and applied by dbmate | +| Tooling | mise, uv, ruff, mypy, oxlint, oxfmt, strict TypeScript, Docker Compose | + +## Layout ``` -browser ──> web (React + Vite, :5173) ──/api/v1──> backend (FastAPI, :8000) ──> postgres (:5432) +mise.toml Tool versions and every task you will run +compose.yaml The deployed stack: web, backend, db. Coolify reads this file. +compose.override.yaml Local additions: published ports, a required .env +.env.example Local settings. Copy it to .env. +backend/ + src/app/main.py Creates the app. `python -m app.main` runs it. + src/app/config/ Settings, database session and logging + src/app/routes/ HTTP handlers, one file per resource + src/app/schemas/ pydantic request and response models + src/app/models/ SQLAlchemy tables. The source of truth for the schema. + src/app/middleware/ Request logging + migrations/ Generated SQL migrations. Commit these. + openapi.json The API contract, generated from the routes. Commit it. + scripts/ Writes openapi.json and generates migrations +frontend/ + src/App.tsx The page + src/api/generated/ Client generated from the contract (not committed) + nginx.conf Deployed web server: serves the app and proxies /api +.agents/rules/ Extra rules for AI agents, per folder +AGENTS.md Rules for agents and humans ``` -## Quickstart +## Getting started -You need [Docker](https://docs.docker.com/get-docker/) and -[mise](https://mise.jdx.dev/getting-started.html). +You need [mise](https://mise.jdx.dev) and Docker. ```sh -cp .env.example .env # local settings; the defaults work -mise install # installs the pinned Node, pnpm, Python, uv and ruff -mise run install # installs packages on your machine, for your editor and checks -mise run dev # starts postgres, backend and web with hot reload +mise install +cp .env.example .env +mise run setup:dev +mise run dev ``` -Open http://localhost:5173 and click **Load notes**. The first migration -creates the `notes` table with a few example rows, so you'll see them right away. +Open and press the button. The API runs on +, with docs at . -The API docs are at http://localhost:8000/api/v1/docs. +`mise tasks` lists every task. The ones you will use most: -## Layout +| Task | What it does | +| ----------------------------------------- | --------------------------------------------------------------- | +| `mise run dev` | Start the db, then run the backend and frontend with hot reload | +| `mise run contract` | Regenerate `backend/openapi.json` and the frontend client | +| `mise run db:dev:migrate:generate ` | Write a SQL migration from your changes to the models | +| `mise run db:dev:migrate:up` | Apply pending migrations | +| `mise run db:dev:migrate:status` | Show whether the database is missing migrations | +| `mise run db:dev:migrate:down` | Roll back the latest migration | +| `mise run db:dev:studio` | Open Prisma Studio on the dev database (:5555) | +| `mise run db:dev:psql` | Open psql in the database | +| `mise run check` | Run what CI runs: format check, lint, typecheck, contract check | +| `mise run fmt:fix` | Format everything | +| `mise run stack:dev:up` | Build and run all three containers, as deployed, on :8080 | -``` -backend/ - src/app/ - main.py creates the FastAPI app, middleware and routers - routes/ HTTP handlers, one file per resource, all under /api/v1 - models/ SQLAlchemy tables, one file per table - schemas/ pydantic request and response models, one file per resource - core/ - settings.py environment variables - db.py async engine and the SessionDep dependency - http_client.py shared httpx client (HttpClientDep) for calling other services - logging_config.py log format (text locally, JSON when deployed) and request ids - middleware.py logs one line per request - migrations/ Alembic migrations - openapi.json the API contract, generated from the routes -frontend/ - src/App.tsx the page layout - src/components/ React components, one per file - src/client/ typed API client, generated from backend/openapi.json - nginx.conf web server for deployed environments -compose.yaml the deployed stack -compose.override.yaml local settings, merged on top of compose.yaml -mise.toml tool versions and every task -AGENTS.md rules for humans and coding agents; .agents/rules has more -``` +## The API contract + +The routes and pydantic schemas are the source of truth. `mise run contract` does two +things: + +1. `contract:generate` writes `backend/openapi.json` from the app. FastAPI builds the + spec itself; `scripts/openapi.py` only saves it. Commit this file. +2. `contract:client` generates a typed client with zod validators in + `frontend/src/api/generated/`. This folder is gitignored. + +`mise run check` fails if `backend/openapi.json` is out of date with the routes. + +To add a route, copy `backend/src/app/routes/messages.py`, add its router in +`routes/__init__.py` and run `mise run contract`. The frontend then has a new function +named after the handler, in camelCase. + +## The database + +The models in `backend/src/app/models/` describe every table. Migrations are plain SQL +files in `backend/migrations/`, with an up and a down section. + +1. Edit a model, or add one and import it in `models/__init__.py`. +2. Run `mise run db:dev:migrate:generate describe_your_change`. Alembic compares the + models with your database, and `scripts/generate_migration.py` writes the SQL for + the difference to a new file. Nothing is written if nothing changed. +3. Read the SQL, then run `mise run db:dev:migrate:up`. +4. Commit the model and the migration together. + +[dbmate](https://github.com/amacneil/dbmate) applies the files. Each one has a +`-- migrate:up` section and a `-- migrate:down` section, and +`mise run db:dev:migrate:down` runs the down section of the latest one. + +After pulling a teammate's migration, `mise run db:dev:migrate:status` tells you +whether your database is behind, and `mise run db:dev:migrate:up` catches it up. The +backend container applies pending migrations every time it starts, so deployed +environments stay in sync on their own. + +Queries are SQL too. Use `select()`, `insert()`, `update()`, `delete()` or `text()`, +as `routes/messages.py` does, not `session.query()` or `session.add()`. + +`mise run db:dev:studio` opens Prisma Studio on , already +connected to the dev database, to browse and edit rows. For raw SQL, use +`mise run db:dev:psql`. + +`mise run db:dev:reset` wipes the local database and reapplies everything. + +## Logging + +Logs use the standard `logging` module. In development (`mise run dev`) they go +through rich, with colors and readable tracebacks. When deployed they are plain lines +on stdout. Get a logger with `logging.getLogger(__name__)`. + +## Environment variables + +`.env` holds your local values and is never committed. `.env.example` lists every +variable with values that work locally. mise loads `.env` for every task, and Docker +Compose reads it too. If port 5432 is taken, change `POSTGRES_PORT` and the port in +`DATABASE_URL`. + +To add a variable, add it to `.env.example`, to `Settings` in +`backend/src/app/config/settings.py`, and to the `backend` service in `compose.yaml`. +Secrets belong in the backend only: anything the frontend uses ends up in the browser. + +## Deploying on Coolify + +1. Create a resource from this repo with the **Docker Compose** build pack, base + directory `/` and compose file `/compose.yaml`. +2. Give the `web` service a domain that includes its port, for example + `https://myapp.example.com:8080`. Do not give `backend` or `db` a domain. +3. Deploy. Coolify generates `SERVICE_USER_POSTGRES` and `SERVICE_PASSWORD_POSTGRES` + once and keeps them for that environment. + +## Removing the example -## Tasks - -Run `mise tasks` for the full list. - -| Task | What it does | -| --- | --- | -| `mise run dev` | Start everything with hot reload | -| `mise run down` | Stop everything (database data is kept) | -| `mise run logs [service]` | Tail logs | -| `mise run check` | Run every check CI runs: format, lint, typecheck, and a stale-client check | -| `mise run fmt:fix` / `lint:fix` | Fix formatting and lint issues | -| `mise run client:generate` | Export the API contract and regenerate the frontend client | -| `mise run db:revision "add users"` | Generate a migration from changes to `models/` | -| `mise run db:migrate` | Apply migrations (the backend also does this on start) | -| `mise run db:rollback` | Undo the latest migration | -| `mise run db:psql` | Open a SQL shell | -| `mise run db:reset` | Delete the local database and start fresh | -| `mise run deploy:check` | Build the deployed stack and check that every container is healthy | - -## How the frontend talks to the backend - -The backend's routes and pydantic models are the contract. When you change one: - -1. `mise run client:generate` writes `backend/openapi.json` and regenerates - `frontend/src/client`. -2. The frontend calls the new function, e.g. `listNotes()`. It is fully typed, - so `tsc` catches a field the backend renamed or removed. -3. Commit both. CI fails if either one is out of date - (`mise run contract:check` and `mise run client:check`). - -The frontend always calls relative URLs like `/api/v1/notes`. Locally, Vite -forwards `/api` to the backend container. When deployed, nginx does the same. -The browser only ever sees one origin, so there is no CORS to configure. - -## Changing the database - -The models in `backend/src/app/models/` are the source of truth for the -schema. To add a table or column, or change one: - -1. Edit a model, or add a new file like `models/user.py` and import it in - `models/__init__.py`. Alembic can't see a model that isn't imported there. -2. `mise run db:revision "add
"`. Alembic compares your models with the - database and writes a migration to `backend/migrations/versions/`. Read it - before committing. If nothing changed, it writes nothing. -3. `mise run db:migrate`, or restart the backend. - -## Make it yours - -Rename the project: - -- `name:` in `compose.yaml`, and the `-p python-react-starter-deploy` project - name in the `deploy:check` task in `mise.toml` -- `name` and `description` in `backend/pyproject.toml`, then run - `uv lock --directory backend` -- `title` in `backend/src/app/main.py` -- `name` in `frontend/package.json` -- `` in `frontend/index.html` and the heading in `frontend/src/App.tsx` -- this README - -Remove the notes example once you have your own feature: - -- delete `backend/src/app/routes/notes.py` and its lines in `routes/__init__.py` -- delete `models/note.py` (and its import in `models/__init__.py`) and - `schemas/note.py` -- run `mise run db:revision "drop notes"` to generate a migration that drops - the table -- run `mise run client:generate`, delete `frontend/src/components/NoteList.tsx` - and remove it from `App.tsx` - -## Deploying - -`compose.yaml` is the deployed stack, and it works as-is on platforms that read -a Compose file, such as Coolify. It builds the `deploy` stage of each -Dockerfile: - -- `backend` applies migrations, then runs uvicorn without reload, as a non-root - user, with JSON logs. -- `web` builds the frontend into static files and serves them with nginx - (`frontend/nginx.conf`). nginx forwards `/api/` to the backend, falls back to - `index.html` for client-side routes, caches `/assets/` for a year, and logs - each request as JSON. - -Run `mise run deploy:check` before your first deploy to build that stack -locally and check that it comes up healthy. - -## Troubleshooting - -- **Port already in use.** Another app is using 5173, 8000 or 5432. For - Postgres, set `POSTGRES_PORT` in `.env` and change the port in - `DATABASE_URL` to match. For the others, change the left side of the port in - `compose.override.yaml`. -- **`not set. Create your .env file`.** Run `cp .env.example .env`. -- **The frontend can't find a package you just added.** The container has its - own `node_modules`. Run `docker compose restart web`, which reinstalls on - start. The same goes for `backend` after adding a Python package. +1. Delete `routes/messages.py` and its line in `routes/__init__.py`, and + `schemas/message.py`. +2. Delete `models/message.py` and its import in `models/__init__.py`, run + `mise run db:dev:migrate:generate remove_messages`, then + `mise run db:dev:migrate:up`. +3. Replace `frontend/src/App.tsx`, then run `mise run contract`. diff --git a/backend/Dockerfile b/backend/Dockerfile index 0a8508d..bdb8a68 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,11 +1,7 @@ -# syntax=docker/dockerfile:1 - # Python and uv versions are pinned here and in mise.toml. Keep them in sync. FROM python:3.14.8-slim AS base COPY --from=ghcr.io/astral-sh/uv:0.13.1 /uv /usr/local/bin/uv WORKDIR /app -# The virtualenv lives outside /app so the local bind mount never hides it and -# never clashes with the .venv on your machine. ENV UV_COMPILE_BYTECODE=1 \ UV_LINK_MODE=copy \ UV_PYTHON_DOWNLOADS=never \ @@ -13,35 +9,17 @@ ENV UV_COMPILE_BYTECODE=1 \ PATH=/opt/venv/bin:$PATH \ PYTHONUNBUFFERED=1 -# --------------------------------------------------------------------------- -# local: your laptop only, selected by compose.override.yaml. Source code comes -# from the bind mount. On startup: install packages, apply migrations, then run -# the server with hot reload. -# --------------------------------------------------------------------------- -FROM base AS local -EXPOSE 8000 -CMD ["sh", "-c", "uv sync --locked && alembic upgrade head && exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload --no-access-log"] - -# --------------------------------------------------------------------------- -# build: install runtime dependencies and the app into /opt/venv -# --------------------------------------------------------------------------- FROM base AS build COPY pyproject.toml uv.lock ./ -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --locked --no-dev --no-install-project +RUN uv sync --locked --no-dev --no-install-project COPY src src -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --locked --no-dev --no-editable +RUN uv sync --locked --no-dev --no-editable -# --------------------------------------------------------------------------- -# deploy: every deployed environment, selected by compose.yaml. Applies -# pending migrations, then starts. -# --------------------------------------------------------------------------- FROM base AS deploy +COPY --from=ghcr.io/amacneil/dbmate:2.37.0 /usr/local/bin/dbmate /usr/local/bin/dbmate RUN useradd --system --uid 10001 --no-create-home --shell /usr/sbin/nologin app COPY --from=build /opt/venv /opt/venv -COPY alembic.ini ./ COPY migrations migrations USER app -EXPOSE 8000 -CMD ["sh", "-c", "alembic upgrade head && exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --no-access-log"] +EXPOSE 3000 +CMD ["sh", "-c", "dbmate --migrations-dir migrations --no-dump-schema migrate && exec python -m app.main"] diff --git a/backend/alembic.ini b/backend/alembic.ini deleted file mode 100644 index 17004f1..0000000 --- a/backend/alembic.ini +++ /dev/null @@ -1,43 +0,0 @@ -[alembic] -script_location = %(here)s/migrations -prepend_sys_path = src -file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(slug)s -# The database URL comes from app.settings (DATABASE_URL), not from this file. - -[post_write_hooks] -hooks = ruff_format -ruff_format.type = exec -ruff_format.executable = ruff -ruff_format.options = format REVISION_SCRIPT_FILENAME - -[loggers] -keys = root,sqlalchemy,alembic - -[handlers] -keys = console - -[formatters] -keys = generic - -[logger_root] -level = WARNING -handlers = console - -[logger_sqlalchemy] -level = WARNING -handlers = -qualname = sqlalchemy.engine - -[logger_alembic] -level = INFO -handlers = -qualname = alembic - -[handler_console] -class = StreamHandler -args = (sys.stderr,) -level = NOTSET -formatter = generic - -[formatter_generic] -format = %(levelname)-5.5s [%(name)s] %(message)s diff --git a/backend/migrations/20261019232056_init.sql b/backend/migrations/20261019232056_init.sql new file mode 100644 index 0000000..11aa87d --- /dev/null +++ b/backend/migrations/20261019232056_init.sql @@ -0,0 +1,14 @@ +-- migrate:up +CREATE TABLE messages ( + id SERIAL NOT NULL, + text TEXT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL, + PRIMARY KEY (id) +); + +INSERT INTO messages (text) VALUES + ('Hello from the database'), + ('These rows come from the first migration'); + +-- migrate:down +DROP TABLE messages; diff --git a/backend/migrations/env.py b/backend/migrations/env.py deleted file mode 100644 index 5a4ed9f..0000000 --- a/backend/migrations/env.py +++ /dev/null @@ -1,43 +0,0 @@ -import asyncio -from logging.config import fileConfig - -from alembic import context -from alembic.operations.ops import MigrationScript -from alembic.runtime.migration import MigrationContext -from sqlalchemy import Connection - -from app.core.db import engine -from app.models import Base - -if context.config.config_file_name is not None: - fileConfig(context.config.config_file_name) - - -def skip_empty_revision( - _context: MigrationContext, _revision: object, directives: list[MigrationScript] -) -> None: - """Migrations come from model changes. With no changes, write nothing.""" - upgrade_ops = directives[0].upgrade_ops - if upgrade_ops is not None and upgrade_ops.is_empty(): - directives.clear() - print("No model changes found, so no migration was created.") - - -def run_migrations(connection: Connection) -> None: - # Autogenerate compares the database against every model on this metadata. - context.configure( - connection=connection, - target_metadata=Base.metadata, - process_revision_directives=skip_empty_revision, - ) - with context.begin_transaction(): - context.run_migrations() - - -async def main() -> None: - async with engine.connect() as connection: - await connection.run_sync(run_migrations) - await engine.dispose() - - -asyncio.run(main()) diff --git a/backend/migrations/script.py.mako b/backend/migrations/script.py.mako deleted file mode 100644 index dc8cca5..0000000 --- a/backend/migrations/script.py.mako +++ /dev/null @@ -1,24 +0,0 @@ -"""${message} - -Revision ID: ${up_revision} -Revises: ${down_revision | comma,n} -Create Date: ${create_date} -""" - -from collections.abc import Sequence - -import sqlalchemy as sa -from alembic import op -${imports if imports else ""} -revision: str = ${repr(up_revision)} -down_revision: str | None = ${repr(down_revision)} -branch_labels: str | Sequence[str] | None = ${repr(branch_labels)} -depends_on: str | Sequence[str] | None = ${repr(depends_on)} - - -def upgrade() -> None: - ${upgrades if upgrades else "pass"} - - -def downgrade() -> None: - ${downgrades if downgrades else "pass"} diff --git a/backend/migrations/versions/20261019_1817_create_notes_table.py b/backend/migrations/versions/20261019_1817_create_notes_table.py deleted file mode 100644 index 213c849..0000000 --- a/backend/migrations/versions/20261019_1817_create_notes_table.py +++ /dev/null @@ -1,44 +0,0 @@ -"""create notes table - -Revision ID: 153e5fa1eaa3 -Revises: -Create Date: 2026-10-19 18:17:01.250453 -""" - -from collections.abc import Sequence - -import sqlalchemy as sa -from alembic import op - -revision: str = "153e5fa1eaa3" -down_revision: str | None = None -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - notes = op.create_table( - "notes", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("body", sa.Text(), nullable=False), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.PrimaryKeyConstraint("id"), - ) - # Example rows, so "Load notes" shows something on a fresh database. - op.bulk_insert( - notes, - [ - {"body": "Hello from Postgres!"}, - {"body": "This note was inserted by the first Alembic migration."}, - {"body": "Edit the models in backend/src/app/models to change the schema."}, - ], - ) - - -def downgrade() -> None: - op.drop_table("notes") diff --git a/backend/openapi.json b/backend/openapi.json index 8790be2..23ccd96 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -1,24 +1,21 @@ { "openapi": "3.1.0", "info": { - "title": "Starter API", - "version": "0.1.0" + "title": "API", + "version": "1.0.0" }, "paths": { "/api/v1/health": { "get": { - "tags": [ - "health" - ], "summary": "Get Health", - "operationId": "get_health", + "operationId": "getHealth", "responses": { "200": { - "description": "Successful Response", + "description": "The API and database are reachable", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HealthResponse" + "$ref": "#/components/schemas/Health" } } } @@ -26,24 +23,21 @@ } } }, - "/api/v1/notes": { + "/api/v1/messages": { "get": { - "tags": [ - "notes" - ], - "summary": "List Notes", - "operationId": "list_notes", + "summary": "List Messages", + "operationId": "listMessages", "responses": { "200": { - "description": "Successful Response", + "description": "The 50 most recent messages", "content": { "application/json": { "schema": { "items": { - "$ref": "#/components/schemas/NoteResponse" + "$ref": "#/components/schemas/Message" }, "type": "array", - "title": "Response List Notes" + "title": "Response Listmessages" } } } @@ -54,7 +48,7 @@ }, "components": { "schemas": { - "HealthResponse": { + "Health": { "properties": { "status": { "type": "string", @@ -66,31 +60,31 @@ "required": [ "status" ], - "title": "HealthResponse" + "title": "Health" }, - "NoteResponse": { + "Message": { "properties": { "id": { "type": "integer", "title": "Id" }, - "body": { + "text": { "type": "string", - "title": "Body" + "title": "Text" }, - "created_at": { + "createdAt": { "type": "string", "format": "date-time", - "title": "Created At" + "title": "Createdat" } }, "type": "object", "required": [ "id", - "body", - "created_at" + "text", + "createdAt" ], - "title": "NoteResponse" + "title": "Message" } } } diff --git a/backend/pyproject.toml b/backend/pyproject.toml index bd3aba6..d161160 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,18 +4,21 @@ version = "0.1.0" description = "FastAPI backend for the starter" requires-python = ">=3.14,<3.15" dependencies = [ - "alembic==1.20.0", "fastapi==0.149.0", "httpx==0.28.1", "psycopg[binary]==3.3.6", "pydantic==2.14.1", "pydantic-settings==2.16.0", + "rich==15.0.0", "sqlalchemy[asyncio]==2.1.5", "uvicorn==0.54.0", ] [dependency-groups] -dev = ["mypy==2.4.0"] +dev = [ + "alembic==1.20.0", + "mypy==2.4.0", +] [build-system] requires = ["hatchling==1.32.4"] @@ -30,7 +33,11 @@ src = ["src"] [tool.ruff.lint] extend-select = ["I", "UP", "B"] +[tool.ruff.lint.isort] +known-first-party = ["app"] + [tool.mypy] strict = true -files = ["src", "migrations"] +files = ["src", "scripts"] +mypy_path = "src" plugins = ["pydantic.mypy"] diff --git a/backend/scripts/generate_migration.py b/backend/scripts/generate_migration.py new file mode 100644 index 0000000..0fd620e --- /dev/null +++ b/backend/scripts/generate_migration.py @@ -0,0 +1,78 @@ +"""Write a dbmate migration with the SQL that turns the database into what the +models in app.models describe. + +Alembic's autogenerate does the comparison. Only the SQL it renders is kept, +so every migration is a plain .sql file with an up and a down section. + +Usage: python scripts/generate_migration.py <name> +""" + +import io +import sys +from datetime import UTC, datetime +from pathlib import Path + +from alembic.autogenerate import produce_migrations +from alembic.operations import Operations +from alembic.operations.ops import MigrateOperation, OpContainer +from alembic.runtime.migration import MigrationContext +from sqlalchemy import create_engine + +from app.config.settings import settings +from app.models import Base + +MIGRATIONS_DIR = Path(__file__).resolve().parents[1] / "migrations" + + +def render_sql(operation: MigrateOperation) -> str: + output = io.StringIO() + context = MigrationContext.configure( + dialect_name="postgresql", + opts={"as_sql": True, "output_buffer": output, "literal_binds": True}, + ) + operations = Operations(context) + + def run(op: MigrateOperation) -> None: + if isinstance(op, OpContainer): + for child in op.ops: + run(child) + return + operations.invoke(op) + + run(operation) + # SQLAlchemy leaves a space after each comma in CREATE TABLE. + lines = [line.rstrip() for line in output.getvalue().strip().splitlines()] + return "\n".join(lines) + + +def main() -> None: + if len(sys.argv) != 2: + sys.exit("usage: generate_migration.py <name>") + name = sys.argv[1] + + engine = create_engine(settings.database_url) + with engine.connect() as connection: + context = MigrationContext.configure( + connection, + # dbmate's bookkeeping table is not a model. Don't drop it. + opts={"include_name": lambda n, kind, _: n != "schema_migrations"}, + ) + migration = produce_migrations(context, Base.metadata) + + if migration.upgrade_ops is None or migration.upgrade_ops.is_empty(): + sys.exit("The models already match the database. No migration created.") + assert migration.downgrade_ops is not None + + timestamp = datetime.now(UTC).strftime("%Y%m%d%H%M%S") + path = MIGRATIONS_DIR / f"{timestamp}_{name}.sql" + path.write_text( + f"-- migrate:up\n{render_sql(migration.upgrade_ops)}\n\n" + f"-- migrate:down\n{render_sql(migration.downgrade_ops)}\n" + ) + print( + f"Created {path.relative_to(MIGRATIONS_DIR.parent)}. Review it before committing." + ) + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/openapi.py b/backend/scripts/openapi.py new file mode 100644 index 0000000..bc74f11 --- /dev/null +++ b/backend/scripts/openapi.py @@ -0,0 +1,14 @@ +"""Write the OpenAPI spec that FastAPI builds from the routes to backend/openapi.json.""" + +import json +import os +from pathlib import Path + +# Importing the app reads settings, but building the spec never connects to the +# database, so a placeholder lets this run in CI. +os.environ.setdefault("DATABASE_URL", "postgresql://localhost/placeholder") + +from app.main import app + +output = Path(__file__).resolve().parents[1] / "openapi.json" +output.write_text(json.dumps(app.openapi(), indent=2) + "\n") diff --git a/backend/src/app/core/__init__.py b/backend/src/app/config/__init__.py similarity index 100% rename from backend/src/app/core/__init__.py rename to backend/src/app/config/__init__.py diff --git a/backend/src/app/core/db.py b/backend/src/app/config/db.py similarity index 73% rename from backend/src/app/core/db.py rename to backend/src/app/config/db.py index 2b03556..c9d9b03 100644 --- a/backend/src/app/core/db.py +++ b/backend/src/app/config/db.py @@ -1,16 +1,16 @@ -from collections.abc import AsyncIterator +from collections.abc import AsyncGenerator from typing import Annotated from fastapi import Depends from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine -from app.core.settings import settings +from app.config.settings import settings engine = create_async_engine(settings.database_url) SessionLocal = async_sessionmaker(engine, expire_on_commit=False) -async def get_session() -> AsyncIterator[AsyncSession]: +async def get_session() -> AsyncGenerator[AsyncSession]: async with SessionLocal() as session: yield session diff --git a/backend/src/app/config/logger.py b/backend/src/app/config/logger.py new file mode 100644 index 0000000..64f4c0e --- /dev/null +++ b/backend/src/app/config/logger.py @@ -0,0 +1,25 @@ +import logging + +from rich.logging import RichHandler + +from app.config.settings import settings + + +def configure_logging() -> None: + """Colored logs from rich in development, plain lines in production. + uvicorn's logs go through the same handler.""" + development = settings.environment == "development" + logging.basicConfig( + level=settings.log_level.upper(), + handlers=[ + RichHandler(rich_tracebacks=True) + if development + else logging.StreamHandler() + ], + format="%(message)s" + if development + else "%(asctime)s %(levelname)s %(name)s: %(message)s", + force=True, + ) + # The request logger middleware replaces uvicorn's access log. + logging.getLogger("uvicorn.access").disabled = True diff --git a/backend/src/app/core/settings.py b/backend/src/app/config/settings.py similarity index 53% rename from backend/src/app/core/settings.py rename to backend/src/app/config/settings.py index 67ddffb..2f3971a 100644 --- a/backend/src/app/core/settings.py +++ b/backend/src/app/config/settings.py @@ -6,10 +6,10 @@ class Settings(BaseSettings): """Read from environment variables, e.g. DATABASE_URL and LOG_LEVEL.""" + environment: Literal["development", "production"] = "production" database_url: str - log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO" - # "json" for deployed environments, so a log service can parse each line. - log_format: Literal["text", "json"] = "text" + port: int = 3000 + log_level: Literal["debug", "info", "warning", "error"] = "info" settings = Settings() diff --git a/backend/src/app/core/http_client.py b/backend/src/app/core/http_client.py deleted file mode 100644 index cfe15d4..0000000 --- a/backend/src/app/core/http_client.py +++ /dev/null @@ -1,16 +0,0 @@ -from typing import Annotated - -import httpx -from fastapi import Depends, Request - - -def get_http_client(request: Request) -> httpx.AsyncClient: - """The app-wide client opened in main.lifespan. Reusing one client keeps - connections pooled across requests.""" - client: httpx.AsyncClient = request.app.state.http_client - return client - - -# Add `client: HttpClientDep` to a route to call another service: -# response = await client.get("https://example.com/api") -HttpClientDep = Annotated[httpx.AsyncClient, Depends(get_http_client)] diff --git a/backend/src/app/core/logging_config.py b/backend/src/app/core/logging_config.py deleted file mode 100644 index 87a82a2..0000000 --- a/backend/src/app/core/logging_config.py +++ /dev/null @@ -1,77 +0,0 @@ -import json -import logging -import logging.config -from contextvars import ContextVar -from datetime import UTC, datetime -from typing import Literal - -# Set per request by RequestLoggingMiddleware, so every log line written while -# handling a request (from any module) carries the same id. -request_id_var: ContextVar[str] = ContextVar("request_id", default="-") - -# Attributes every LogRecord has. Anything else was passed with `extra=`. -# uvicorn adds color_message, an ANSI-colored copy of the message. -_STANDARD_ATTRS = set(vars(logging.makeLogRecord({}))) | { - "message", - "request_id", - "color_message", -} - - -class RequestIdFilter(logging.Filter): - def filter(self, record: logging.LogRecord) -> bool: - record.request_id = request_id_var.get() - return True - - -class JsonFormatter(logging.Formatter): - """One JSON object per line, including any `extra=` fields.""" - - def format(self, record: logging.LogRecord) -> str: - entry: dict[str, object] = { - "time": datetime.fromtimestamp(record.created, UTC).isoformat(), - "level": record.levelname, - "logger": record.name, - "message": record.getMessage(), - "request_id": getattr(record, "request_id", "-"), - } - for key, value in vars(record).items(): - if key not in _STANDARD_ATTRS: - entry[key] = value - if record.exc_info: - entry["exception"] = self.formatException(record.exc_info) - return json.dumps(entry, default=str) - - -def configure_logging(level: str, log_format: Literal["text", "json"]) -> None: - """Send app and uvicorn logs through one handler on stdout.""" - logging.config.dictConfig( - { - "version": 1, - "disable_existing_loggers": False, - "filters": {"request_id": {"()": RequestIdFilter}}, - "formatters": { - "text": { - "format": "%(asctime)s %(levelname)-7s [%(request_id)s] " - "%(name)s: %(message)s" - }, - "json": {"()": JsonFormatter}, - }, - "handlers": { - "stdout": { - "class": "logging.StreamHandler", - "stream": "ext://sys.stdout", - "formatter": log_format, - "filters": ["request_id"], - } - }, - "root": {"level": level, "handlers": ["stdout"]}, - "loggers": { - # Re-route uvicorn's own loggers to the root handler above. - "uvicorn": {"handlers": [], "propagate": True}, - "uvicorn.error": {"handlers": [], "propagate": True}, - # RequestLoggingMiddleware replaces uvicorn's access log. - "uvicorn.access": {"handlers": [], "propagate": False}, - }, - } - ) diff --git a/backend/src/app/core/middleware.py b/backend/src/app/core/middleware.py deleted file mode 100644 index a74edb6..0000000 --- a/backend/src/app/core/middleware.py +++ /dev/null @@ -1,64 +0,0 @@ -import logging -import time -import uuid - -from starlette.types import ASGIApp, Message, Receive, Scope, Send - -from app.core.logging_config import request_id_var - -logger = logging.getLogger("app.requests") - -# Docker polls this every few seconds; logging it would bury real requests. -QUIET_PATHS = {"/api/v1/health"} - - -class RequestLoggingMiddleware: - """Give each request an id and log one line when it finishes. - - Written as plain ASGI middleware rather than `@app.middleware("http")`, - which wraps responses in a way that breaks streaming and context variables. - """ - - def __init__(self, app: ASGIApp) -> None: - self.app = app - - async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: - if scope["type"] != "http": - await self.app(scope, receive, send) - return - - request_id = uuid.uuid4().hex - request_id_var.set(request_id) - start = time.perf_counter() - status = 500 - - async def send_with_request_id(message: Message) -> None: - nonlocal status - if message["type"] == "http.response.start": - status = message["status"] - message.setdefault("headers", []).append( - (b"x-request-id", request_id.encode()) - ) - await send(message) - - try: - await self.app(scope, receive, send_with_request_id) - finally: - # On an unhandled error, status stays 500 and uvicorn logs the - # traceback right after this line, with the same request id. - if scope["path"] not in QUIET_PATHS: - duration_ms = round((time.perf_counter() - start) * 1000, 1) - logger.log( - logging.ERROR if status >= 500 else logging.INFO, - "%s %s %d %.1fms", - scope["method"], - scope["path"], - status, - duration_ms, - extra={ - "method": scope["method"], - "path": scope["path"], - "status": status, - "duration_ms": duration_ms, - }, - ) diff --git a/backend/src/app/export_openapi.py b/backend/src/app/export_openapi.py deleted file mode 100644 index 088f941..0000000 --- a/backend/src/app/export_openapi.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Write the OpenAPI contract to backend/openapi.json. - -The frontend generates its API client from that file, so run -`mise run client:generate` after changing a route or schema. With `--check`, -fail instead of writing if the file is out of date (used by CI). -""" - -import json -import sys -from pathlib import Path - -from app.main import app - -OUTPUT = Path(__file__).resolve().parents[2] / "openapi.json" - - -def main() -> None: - contract = json.dumps(app.openapi(), indent=2) + "\n" - - if "--check" not in sys.argv: - OUTPUT.write_text(contract) - print(f"wrote {OUTPUT}") - return - - if not OUTPUT.exists() or OUTPUT.read_text() != contract: - sys.exit("backend/openapi.json is out of date. Run: mise run client:generate") - print("backend/openapi.json is up to date") - - -if __name__ == "__main__": - main() diff --git a/backend/src/app/main.py b/backend/src/app/main.py index 232994a..978bd6e 100644 --- a/backend/src/app/main.py +++ b/backend/src/app/main.py @@ -1,44 +1,35 @@ -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager - -import httpx +import uvicorn from fastapi import FastAPI -from fastapi.routing import APIRoute +from pydantic.alias_generators import to_camel -from app.core.db import engine -from app.core.logging_config import configure_logging -from app.core.middleware import RequestLoggingMiddleware -from app.core.settings import settings +from app.config.logger import configure_logging +from app.config.settings import settings +from app.middleware.request_logger import log_requests from app.routes import api_router - -@asynccontextmanager -async def lifespan(app: FastAPI) -> AsyncIterator[None]: - async with httpx.AsyncClient(timeout=10) as http_client: - app.state.http_client = http_client - yield - await engine.dispose() - - -def operation_id(route: APIRoute) -> str: - """Use the handler's name as the OpenAPI operationId, so the generated - frontend client gets `listNotes()` instead of `listNotesApiV1NotesGet()`.""" - return route.name - - -def create_app() -> FastAPI: - configure_logging(settings.log_level, settings.log_format) - - app = FastAPI( - title="Starter API", - lifespan=lifespan, - docs_url="/api/v1/docs", - openapi_url="/api/v1/openapi.json", - generate_unique_id_function=operation_id, +configure_logging() + +app = FastAPI( + title="API", + version="1.0.0", + # The spec is only written to backend/openapi.json. No docs pages are served. + openapi_url=None, + docs_url=None, + redoc_url=None, + # The handler name in camelCase becomes the operationId, so the generated + # frontend client gets listMessages() instead of listMessagesApiV1MessagesGet(). + generate_unique_id_function=lambda route: to_camel(route.name), +) +app.middleware("http")(log_requests) +app.include_router(api_router) + + +if __name__ == "__main__": + development = settings.environment == "development" + uvicorn.run( + "app.main:app", + host="127.0.0.1" if development else "0.0.0.0", + port=settings.port, + reload=development, + log_config=None, ) - app.add_middleware(RequestLoggingMiddleware) - app.include_router(api_router) - return app - - -app = create_app() diff --git a/backend/src/app/middleware/__init__.py b/backend/src/app/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/app/middleware/request_logger.py b/backend/src/app/middleware/request_logger.py new file mode 100644 index 0000000..3d4916d --- /dev/null +++ b/backend/src/app/middleware/request_logger.py @@ -0,0 +1,37 @@ +import logging +import time +from collections.abc import Awaitable, Callable + +from fastapi import Request, Response + +logger = logging.getLogger("app.requests") + + +def level_for(status: int) -> int: + if status >= 500: + return logging.ERROR + if status >= 400: + return logging.WARNING + return logging.INFO + + +async def log_requests( + request: Request, call_next: Callable[[Request], Awaitable[Response]] +) -> Response: + start = time.perf_counter() + response = await call_next(request) + + status = response.status_code + if request.url.path == "/api/v1/health" and status < 400: + return response + + duration_ms = round((time.perf_counter() - start) * 1000) + logger.log( + level_for(status), + "%s %s %d %dms", + request.method, + request.url.path, + status, + duration_ms, + ) + return response diff --git a/backend/src/app/models/__init__.py b/backend/src/app/models/__init__.py index 696307e..e0b0f98 100644 --- a/backend/src/app/models/__init__.py +++ b/backend/src/app/models/__init__.py @@ -1,6 +1,4 @@ -# Import every model here. Alembic only sees tables whose model has been -# imported, so a model missing from this list gets no migration. from app.models.base import Base -from app.models.note import Note +from app.models.message import Message -__all__ = ["Base", "Note"] +__all__ = ["Base", "Message"] diff --git a/backend/src/app/models/note.py b/backend/src/app/models/message.py similarity index 77% rename from backend/src/app/models/note.py rename to backend/src/app/models/message.py index 0f1097d..4fc7e16 100644 --- a/backend/src/app/models/note.py +++ b/backend/src/app/models/message.py @@ -6,11 +6,11 @@ from app.models.base import Base -class Note(Base): - __tablename__ = "notes" +class Message(Base): + __tablename__ = "messages" id: Mapped[int] = mapped_column(primary_key=True) - body: Mapped[str] = mapped_column(Text) + text: Mapped[str] = mapped_column(Text) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() ) diff --git a/backend/src/app/routes/__init__.py b/backend/src/app/routes/__init__.py index a5df793..dcdd48b 100644 --- a/backend/src/app/routes/__init__.py +++ b/backend/src/app/routes/__init__.py @@ -1,7 +1,7 @@ from fastapi import APIRouter -from app.routes import health, notes +from app.routes import health, messages api_router = APIRouter(prefix="/api/v1") api_router.include_router(health.router) -api_router.include_router(notes.router) +api_router.include_router(messages.router) diff --git a/backend/src/app/routes/health.py b/backend/src/app/routes/health.py index e815392..56b5f60 100644 --- a/backend/src/app/routes/health.py +++ b/backend/src/app/routes/health.py @@ -1,12 +1,13 @@ from fastapi import APIRouter +from sqlalchemy import text -from app.schemas.health import HealthResponse +from app.config.db import SessionDep +from app.schemas.health import Health -router = APIRouter(tags=["health"]) +router = APIRouter() -# Async and DB-free on purpose: it runs on the event loop, so it answers right -# away even while other requests are waiting on Postgres. -@router.get("/health") -async def get_health() -> HealthResponse: - return HealthResponse(status="ok") +@router.get("/health", response_description="The API and database are reachable") +async def get_health(session: SessionDep) -> Health: + await session.execute(text("SELECT 1")) + return Health(status="ok") diff --git a/backend/src/app/routes/messages.py b/backend/src/app/routes/messages.py new file mode 100644 index 0000000..5ce520c --- /dev/null +++ b/backend/src/app/routes/messages.py @@ -0,0 +1,17 @@ +from fastapi import APIRouter +from sqlalchemy import select + +from app import models, schemas +from app.config.db import SessionDep + +router = APIRouter() + + +@router.get("/messages", response_description="The 50 most recent messages") +async def list_messages(session: SessionDep) -> list[schemas.Message]: + rows = await session.scalars( + select(models.Message) + .order_by(models.Message.created_at.desc(), models.Message.id.desc()) + .limit(50) + ) + return [schemas.Message.model_validate(row) for row in rows] diff --git a/backend/src/app/routes/notes.py b/backend/src/app/routes/notes.py deleted file mode 100644 index 60fd6ee..0000000 --- a/backend/src/app/routes/notes.py +++ /dev/null @@ -1,14 +0,0 @@ -from fastapi import APIRouter -from sqlalchemy import select - -from app.core.db import SessionDep -from app.models import Note -from app.schemas.note import NoteResponse - -router = APIRouter(prefix="/notes", tags=["notes"]) - - -@router.get("") -async def list_notes(session: SessionDep) -> list[NoteResponse]: - notes = await session.scalars(select(Note).order_by(Note.created_at, Note.id)) - return [NoteResponse.model_validate(note) for note in notes] diff --git a/backend/src/app/schemas/__init__.py b/backend/src/app/schemas/__init__.py index e69de29..ddf94ed 100644 --- a/backend/src/app/schemas/__init__.py +++ b/backend/src/app/schemas/__init__.py @@ -0,0 +1,4 @@ +from app.schemas.health import Health +from app.schemas.message import Message + +__all__ = ["Health", "Message"] diff --git a/backend/src/app/schemas/health.py b/backend/src/app/schemas/health.py index 9b69dec..3b23335 100644 --- a/backend/src/app/schemas/health.py +++ b/backend/src/app/schemas/health.py @@ -3,5 +3,5 @@ from pydantic import BaseModel -class HealthResponse(BaseModel): +class Health(BaseModel): status: Literal["ok"] diff --git a/backend/src/app/schemas/message.py b/backend/src/app/schemas/message.py new file mode 100644 index 0000000..31e78c1 --- /dev/null +++ b/backend/src/app/schemas/message.py @@ -0,0 +1,15 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + + +class Message(BaseModel): + # Fields are snake_case in Python and camelCase in JSON (createdAt). + model_config = ConfigDict( + from_attributes=True, alias_generator=to_camel, validate_by_name=True + ) + + id: int + text: str + created_at: datetime diff --git a/backend/src/app/schemas/note.py b/backend/src/app/schemas/note.py deleted file mode 100644 index 652678f..0000000 --- a/backend/src/app/schemas/note.py +++ /dev/null @@ -1,11 +0,0 @@ -from datetime import datetime - -from pydantic import BaseModel, ConfigDict - - -class NoteResponse(BaseModel): - model_config = ConfigDict(from_attributes=True) - - id: int - body: str - created_at: datetime diff --git a/backend/uv.lock b/backend/uv.lock index e4f4130..778c53f 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -52,35 +52,39 @@ name = "app" version = "0.1.0" source = { editable = "." } dependencies = [ - { name = "alembic" }, { name = "fastapi" }, { name = "httpx" }, { name = "psycopg", extra = ["binary"] }, { name = "pydantic" }, { name = "pydantic-settings" }, + { name = "rich" }, { name = "sqlalchemy", extra = ["asyncio"] }, { name = "uvicorn" }, ] [package.dev-dependencies] dev = [ + { name = "alembic" }, { name = "mypy" }, ] [package.metadata] requires-dist = [ - { name = "alembic", specifier = "==1.20.0" }, { name = "fastapi", specifier = "==0.149.0" }, { name = "httpx", specifier = "==0.28.1" }, { name = "psycopg", extras = ["binary"], specifier = "==3.3.6" }, { name = "pydantic", specifier = "==2.14.1" }, { name = "pydantic-settings", specifier = "==2.16.0" }, + { name = "rich", specifier = "==15.0.0" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = "==2.1.5" }, { name = "uvicorn", specifier = "==0.54.0" }, ] [package.metadata.requires-dev] -dev = [{ name = "mypy", specifier = "==2.4.0" }] +dev = [ + { name = "alembic", specifier = "==1.20.0" }, + { name = "mypy", specifier = "==2.4.0" }, +] [[package]] name = "ast-serialize" @@ -304,6 +308,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6d/a0/053d6af3e8f871e0073b4a36732d9e65be77a72e5434c31b94f6af78a6bb/mako-1.4.3-py3-none-any.whl", hash = "sha256:723296007c870bfd6b3f0c3230dba7198096e5269297ebf5e4eff9e7ffa39d4f", size = 80164, upload-time = "2026-09-22T20:54:33.128Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + [[package]] name = "markupsafe" version = "3.0.4" @@ -347,6 +363,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/93/63/da554b4c97a6b0ea3229ca7fe8cbfb620be81613d517f482e85958550537/markupsafe-3.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:eba154571c16e032112afac0dc2dfe9e63c2ceb7aedd07bb7eecf2ce26d4dd4c", size = 14421, upload-time = "2026-10-02T23:06:09.402Z" }, ] +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "mypy" version = "2.4.0" @@ -513,6 +538,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/53/f4/b987bf8c51e5b19a95fa66d1ee596074141e085d9c2ddf97920803c7029b/pydantic_settings-2.16.0-py3-none-any.whl", hash = "sha256:7e73acf7f61936a15e5a3b6eedaea29f133357faf7272f2607ba479b049dd7f2", size = 80364, upload-time = "2026-10-14T12:44:08.233Z" }, ] +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.4" @@ -522,6 +556,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" }, ] +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + [[package]] name = "sqlalchemy" version = "2.1.5" diff --git a/compose.override.yaml b/compose.override.yaml index 33b2efd..436d347 100644 --- a/compose.override.yaml +++ b/compose.override.yaml @@ -1,50 +1,12 @@ -# Local settings for your laptop, merged automatically on top of compose.yaml -# whenever you run `docker compose ...` (or `mise run dev`) in this folder. -# -# Differences from the deployed setup: -# - images are built from the `local` stage of each Dockerfile -# - source folders are bind-mounted, so edits on your machine hot-reload -# - ports are published to localhost -# - a .env file is required (cp .env.example .env) - services: - postgres: + db: environment: - # ":?" stops every `docker compose` command with this message until .env exists. - POSTGRES_USER: "${SERVICE_USER_POSTGRES:?not set. Create your .env file: cp .env.example .env}" - POSTGRES_PASSWORD: "${SERVICE_PASSWORD_POSTGRES:?not set. Create your .env file: cp .env.example .env}" - POSTGRES_DB: "${POSTGRES_DB:?not set. Create your .env file: cp .env.example .env}" + POSTGRES_USER: ${SERVICE_USER_POSTGRES:?missing, run cp .env.example .env} + POSTGRES_PASSWORD: ${SERVICE_PASSWORD_POSTGRES:?missing, run cp .env.example .env} + POSTGRES_DB: ${POSTGRES_DB:?missing, run cp .env.example .env} ports: - # For mise db:* tasks and database tools on your machine (psql, TablePlus, DBeaver). - # Set POSTGRES_PORT in .env if something else already uses 5432. - "127.0.0.1:${POSTGRES_PORT:-5432}:5432" - backend: - build: - target: local - environment: - LOG_FORMAT: text - volumes: - - ./backend:/app - ports: - # The API docs live at http://localhost:8000/api/v1/docs. The frontend reaches - # the API through Vite's proxy at http://localhost:5173/api/v1. - - "127.0.0.1:8000:8000" - healthcheck: - # First start installs packages, which can take a minute. - start_period: 120s - web: - build: - target: local - volumes: - - ./frontend:/app - # Keeps the container's Linux node_modules separate from the one on your machine. - - /app/node_modules ports: - - "127.0.0.1:5173:5173" - # In dev the frontend does not need to wait for the backend to be healthy. - depends_on: !reset {} - healthcheck: - test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:5173/"] - start_period: 120s + - "127.0.0.1:8080:8080" diff --git a/compose.yaml b/compose.yaml index 73cce31..f17a69a 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,49 +1,39 @@ -# Deployed stack: what runs in every deployed environment (e.g. on Coolify). -# -# Locally, `docker compose up` also merges compose.override.yaml on top of this -# file automatically. A deploy platform that reads only this file never sees it. -# -# Variables like ${SERVICE_PASSWORD_POSTGRES:-app}: -# - on Coolify: SERVICE_* are "magic" variables; Coolify generates a random -# value once per environment and keeps it across deploys. -# - locally: values come from your .env file, which compose.override.yaml -# requires. The value after ":-" is only used by `mise run deploy:check`. - -name: python-react-starter - services: - postgres: - image: postgres:18.6-alpine + db: + image: postgres:18-alpine restart: unless-stopped environment: POSTGRES_USER: ${SERVICE_USER_POSTGRES:-app} POSTGRES_PASSWORD: ${SERVICE_PASSWORD_POSTGRES:-app} POSTGRES_DB: ${POSTGRES_DB:-app} volumes: - # Postgres 18+ images store data under /var/lib/postgresql/<major>/docker - - postgres-data:/var/lib/postgresql + - db-data:/var/lib/postgresql healthcheck: test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] interval: 5s timeout: 5s retries: 10 - start_period: 10s backend: build: - context: ./backend - target: deploy + context: backend restart: unless-stopped environment: - DATABASE_URL: postgresql://${SERVICE_USER_POSTGRES:-app}:${SERVICE_PASSWORD_POSTGRES:-app}@postgres:5432/${POSTGRES_DB:-app} - LOG_LEVEL: ${LOG_LEVEL:-INFO} - # One JSON object per line, so a log service can parse them. - LOG_FORMAT: json + ENVIRONMENT: production + PORT: 3000 + DATABASE_URL: postgresql://${SERVICE_USER_POSTGRES:-app}:${SERVICE_PASSWORD_POSTGRES:-app}@db:5432/${POSTGRES_DB:-app}?sslmode=disable + LOG_LEVEL: ${LOG_LEVEL:-info} depends_on: - postgres: + db: condition: service_healthy healthcheck: - test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/v1/health', timeout=4)"] + test: + [ + "CMD", + "python", + "-c", + "import urllib.request; urllib.request.urlopen('http://127.0.0.1:3000/api/v1/health', timeout=4)", + ] interval: 10s timeout: 5s retries: 5 @@ -51,19 +41,18 @@ services: web: build: - context: ./frontend - target: deploy + context: . + dockerfile: frontend/Dockerfile restart: unless-stopped depends_on: backend: condition: service_healthy healthcheck: - # 127.0.0.1, not localhost: in Alpine, localhost resolves to IPv6 first, and nginx listens on IPv4. + # Alpine resolves localhost to IPv6 first, but nginx only listens on IPv4. test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:8080/"] interval: 10s timeout: 5s retries: 5 - start_period: 10s volumes: - postgres-data: + db-data: diff --git a/frontend/.dockerignore b/frontend/.dockerignore deleted file mode 100644 index f06235c..0000000 --- a/frontend/.dockerignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules -dist diff --git a/frontend/.oxfmtrc.json b/frontend/.oxfmtrc.json index 633cf15..03521aa 100644 --- a/frontend/.oxfmtrc.json +++ b/frontend/.oxfmtrc.json @@ -1,4 +1,3 @@ { - "$schema": "./node_modules/oxfmt/configuration_schema.json", - "ignorePatterns": ["dist/**", "src/client/**"] + "ignorePatterns": ["dist/**", "src/api/generated/**", "pnpm-lock.yaml"] } diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json deleted file mode 100644 index 53606ae..0000000 --- a/frontend/.oxlintrc.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "$schema": "./node_modules/oxlint/configuration_schema.json", - "plugins": ["typescript", "unicorn", "oxc", "react", "import"], - "categories": { - "correctness": "error", - "suspicious": "warn" - }, - "rules": { - "react/rules-of-hooks": "error", - "react/exhaustive-deps": "warn", - "react/react-in-jsx-scope": "off" - }, - "env": { - "browser": true - }, - "ignorePatterns": ["dist/**", "src/client/**"] -} diff --git a/frontend/Dockerfile b/frontend/Dockerfile index f6c190d..c6bc193 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,35 +1,13 @@ -# syntax=docker/dockerfile:1 - -# Node and pnpm versions are pinned here and in mise.toml. Keep them in sync. -FROM node:24.22.0-alpine AS base -RUN npm install --global pnpm@12.14.0 -WORKDIR /app - -# --------------------------------------------------------------------------- -# local: your laptop only, selected by compose.override.yaml. Source code comes -# from the bind mount; node_modules is a separate volume so it never clashes -# with the one on your machine. Installs packages, then runs the Vite dev -# server (hot reload). -# --------------------------------------------------------------------------- -FROM base AS local -EXPOSE 5173 -CMD ["sh", "-c", "pnpm install --frozen-lockfile && exec pnpm exec vite"] - -# --------------------------------------------------------------------------- -# build: type-check and bundle the app into static files in dist/ -# --------------------------------------------------------------------------- -FROM base AS build -COPY package.json pnpm-lock.yaml ./ +FROM node:24-slim AS build +RUN corepack enable +WORKDIR /app/frontend +COPY frontend/package.json frontend/pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile -COPY . . -RUN pnpm run build +COPY backend/openapi.json /app/backend/openapi.json +COPY frontend ./ +RUN pnpm exec openapi-ts && pnpm run build -# --------------------------------------------------------------------------- -# deploy: every deployed environment, selected by compose.yaml. nginx serves -# the static files and forwards /api requests to the backend container. Runs -# as a non-root user, so it listens on 8080 instead of 80. -# --------------------------------------------------------------------------- -FROM nginxinc/nginx-unprivileged:1.30.6-alpine AS deploy -COPY nginx.conf /etc/nginx/conf.d/default.conf -COPY --from=build /app/dist /usr/share/nginx/html +FROM nginxinc/nginx-unprivileged:1.30-alpine +COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/frontend/dist /usr/share/nginx/html EXPOSE 8080 diff --git a/frontend/index.html b/frontend/index.html index 7b5110a..6e527c8 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,7 +3,7 @@ <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> - <title>Python React Starter + Starter
diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 63d7d82..72bb533 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -1,50 +1,20 @@ -# Web server for the deployed frontend container. Locally, the Vite dev server -# does the same /api forwarding (see vite.config.ts), so nginx only runs here. - -# Access log: one JSON object per request on stdout, matching the backend's JSON -# logs so a log service can parse both. Requests from 127.0.0.1 (the container's -# own health check) are not logged. -log_format json escape=json - '{"time":"$time_iso8601","method":"$request_method","path":"$request_uri",' - '"status":$status,"bytes":$body_bytes_sent,"duration_s":$request_time,' - '"client":"$http_x_forwarded_for","user_agent":"$http_user_agent"}'; -map $remote_addr $loggable { - 127.0.0.1 0; - default 1; -} - server { listen 8080; - server_name _; root /usr/share/nginx/html; - index index.html; - access_log /var/log/nginx/access.log json if=$loggable; - # Docker's internal DNS. Looking up "backend" per request (via a variable) - # means nginx keeps working when the backend container is recreated. + # Resolving "backend" through Docker's DNS on each request keeps the proxy working + # after the backend container is recreated with a new IP. resolver 127.0.0.11 valid=10s; - set $backend http://backend:8000; + set $backend http://backend:3000; - # Forward API calls to the backend container. Same origin as the app, so no CORS. location /api/ { proxy_pass $backend; - proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; } - # Vite puts hashed, never-changing files in /assets: cache them for a long time. - location /assets/ { - expires 1y; - add_header Cache-Control "public, immutable"; - try_files $uri =404; - } - - # Everything else is the single-page app. Unknown paths fall back to - # index.html so client-side routes (e.g. /settings) work on page refresh. location / { - add_header Cache-Control "no-cache"; try_files $uri $uri/ /index.html; } } diff --git a/frontend/openapi-ts.config.ts b/frontend/openapi-ts.config.ts index becaa32..847992a 100644 --- a/frontend/openapi-ts.config.ts +++ b/frontend/openapi-ts.config.ts @@ -1,8 +1,7 @@ import { defineConfig } from "@hey-api/openapi-ts"; -// Generates a typed client from the backend's contract. Run `mise run client:generate`. export default defineConfig({ input: "../backend/openapi.json", - output: "src/client", - plugins: ["@hey-api/client-fetch"], + output: "src/api/generated", + plugins: ["@hey-api/client-fetch", "zod", { name: "@hey-api/sdk", validator: true }], }); diff --git a/frontend/oxlint.config.ts b/frontend/oxlint.config.ts new file mode 100644 index 0000000..c2dce0f --- /dev/null +++ b/frontend/oxlint.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "oxlint"; + +export default defineConfig({ + plugins: ["typescript", "unicorn", "oxc", "import", "react"], + categories: { + correctness: "error", + suspicious: "error", + }, + ignorePatterns: ["dist/**", "src/api/generated/**"], + rules: { + "typescript/no-floating-promises": "error", + "typescript/no-explicit-any": "error", + "no-console": "error", + eqeqeq: "error", + "func-style": ["error", "expression"], + "prefer-arrow-callback": "error", + "typescript/consistent-type-definitions": ["error", "type"], + "react/react-in-jsx-scope": "off", + "typescript/consistent-return": "off", + }, +}); diff --git a/frontend/package.json b/frontend/package.json index db25e0a..4208876 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,17 +4,12 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc -b && vite build", - "generate:client": "openapi-ts", - "lint": "oxlint --type-aware --deny-warnings", - "lint:fix": "oxlint --type-aware --fix", - "format": "oxfmt --check", - "format:fix": "oxfmt", - "typecheck": "tsc -b" + "build": "vite build" }, "dependencies": { "react": "19.3.1", - "react-dom": "19.3.1" + "react-dom": "19.3.1", + "zod": "4.6.7" }, "devDependencies": { "@hey-api/openapi-ts": "0.99.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index a782979..d6668f2 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -172,6 +172,9 @@ importers: react-dom: specifier: 19.3.1 version: 19.3.1(react@19.3.1) + zod: + specifier: 4.6.7 + version: 4.6.7 devDependencies: '@hey-api/openapi-ts': specifier: 0.99.0 @@ -1030,6 +1033,9 @@ packages: resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} engines: {node: '>=20'} + zod@4.6.7: + resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} + snapshots: '@hey-api/codegen-core@0.9.1': @@ -1605,3 +1611,5 @@ snapshots: dependencies: is-wsl: 3.1.1 powershell-utils: 0.1.0 + + zod@4.6.7: {} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1902154..65fc9c4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,31 @@ -import { NoteList } from "@/components/NoteList"; +import { useState } from "react"; +import { listMessages, type Message } from "./api/generated"; + +export const App = () => { + const [messages, setMessages] = useState([]); + const [error, setError] = useState(); + + const load = async () => { + const result = await listMessages(); + if (!result.data) { + setError("Could not load messages"); + return; + } + setError(undefined); + setMessages(result.data); + }; -export function App() { return (
-

Python React Starter

- + + {error &&

{error}

} +
    + {messages.map((message) => ( +
  • {message.text}
  • + ))} +
); -} +}; diff --git a/frontend/src/client/client.gen.ts b/frontend/src/client/client.gen.ts deleted file mode 100644 index 47828b7..0000000 --- a/frontend/src/client/client.gen.ts +++ /dev/null @@ -1,16 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import { type Client, type ClientOptions, type Config, createClient, createConfig } from './client'; -import type { ClientOptions as ClientOptions2 } from './types.gen'; - -/** - * The `createClientConfig()` function will be called on client initialization - * and the returned object will become the client's initial configuration. - * - * You may want to initialize your client this way instead of calling - * `setConfig()`. This is useful for example if you're using Next.js - * to ensure your client always has the correct values. - */ -export type CreateClientConfig = (override?: Config) => Config & T>; - -export const client: Client = createClient(createConfig()); diff --git a/frontend/src/client/client/client.gen.ts b/frontend/src/client/client/client.gen.ts deleted file mode 100644 index fc3f037..0000000 --- a/frontend/src/client/client/client.gen.ts +++ /dev/null @@ -1,277 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import { createSseClient } from '../core/serverSentEvents.gen'; -import type { HttpMethod } from '../core/types.gen'; -import { getValidRequestBody } from '../core/utils.gen'; -import type { Client, Config, RequestOptions, ResolvedRequestOptions } from './types.gen'; -import { - buildUrl, - createConfig, - createInterceptors, - getParseAs, - mergeConfigs, - mergeHeaders, - setAuthParams, -} from './utils.gen'; - -type ReqInit = Omit & { - body?: any; - headers: ReturnType; -}; - -export const createClient = (config: Config = {}): Client => { - let _config = mergeConfigs(createConfig(), config); - - const getConfig = (): Config => ({ ..._config }); - - const setConfig = (config: Config): Config => { - _config = mergeConfigs(_config, config); - return getConfig(); - }; - - const interceptors = createInterceptors(); - - const beforeRequest = async < - TData = unknown, - TResponseStyle extends 'data' | 'fields' = 'fields', - ThrowOnError extends boolean = boolean, - Url extends string = string, - >( - options: RequestOptions, - ) => { - const opts = { - ..._config, - ...options, - fetch: options.fetch ?? _config.fetch ?? globalThis.fetch, - headers: mergeHeaders(_config.headers, options.headers), - serializedBody: undefined as string | undefined, - }; - - if (opts.security) { - await setAuthParams(opts); - } - - if (opts.requestValidator) { - await opts.requestValidator(opts); - } - - if (opts.body !== undefined && opts.bodySerializer) { - opts.serializedBody = opts.bodySerializer(opts.body) as string | undefined; - } - - // remove Content-Type header if body is empty to avoid sending invalid requests - if (opts.body === undefined || opts.serializedBody === '') { - opts.headers.delete('Content-Type'); - } - - const resolvedOpts = opts as typeof opts & - ResolvedRequestOptions; - const url = buildUrl(resolvedOpts); - - return { opts: resolvedOpts, url }; - }; - - const request: Client['request'] = async (options) => { - const throwOnError = options.throwOnError ?? _config.throwOnError; - const responseStyle = options.responseStyle ?? _config.responseStyle; - - let request: Request | undefined; - let response: Response | undefined; - - try { - const { opts, url } = await beforeRequest(options); - const requestInit: ReqInit = { - redirect: 'follow', - ...opts, - body: getValidRequestBody(opts), - }; - - request = new Request(url, requestInit); - - for (const fn of interceptors.request.fns) { - if (fn) { - request = await fn(request, opts); - } - } - - // fetch must be assigned here, otherwise it would throw the error: - // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation - const _fetch = opts.fetch!; - - response = await _fetch(request); - - for (const fn of interceptors.response.fns) { - if (fn) { - response = await fn(response, request, opts); - } - } - - const result = { - request, - response, - }; - - if (response.ok) { - const parseAs = - (opts.parseAs === 'auto' - ? getParseAs(response.headers.get('Content-Type')) - : opts.parseAs) ?? 'json'; - - if (response.status === 204 || response.headers.get('Content-Length') === '0') { - let emptyData: any; - switch (parseAs) { - case 'arrayBuffer': - case 'blob': - case 'text': - emptyData = await response[parseAs](); - break; - case 'formData': - emptyData = new FormData(); - break; - case 'stream': - emptyData = response.body; - break; - case 'json': - default: - emptyData = {}; - break; - } - return opts.responseStyle === 'data' - ? emptyData - : { - data: emptyData, - ...result, - }; - } - - let data: any; - switch (parseAs) { - case 'arrayBuffer': - case 'blob': - case 'formData': - case 'text': - data = await response[parseAs](); - break; - case 'json': { - // Some servers return 200 with no Content-Length and empty body. - // response.json() would throw; read as text and parse if non-empty. - const text = await response.text(); - data = text ? JSON.parse(text) : {}; - break; - } - case 'stream': - return opts.responseStyle === 'data' - ? response.body - : { - data: response.body, - ...result, - }; - } - - if (parseAs === 'json') { - if (opts.responseValidator) { - await opts.responseValidator(data); - } - - if (opts.responseTransformer) { - data = await opts.responseTransformer(data); - } - } - - return opts.responseStyle === 'data' - ? data - : { - data, - ...result, - }; - } - - const textError = await response.text(); - let jsonError: unknown; - - try { - jsonError = JSON.parse(textError); - } catch { - // noop - } - - throw jsonError ?? textError; - } catch (error) { - let finalError = error; - - for (const fn of interceptors.error.fns) { - if (fn) { - finalError = await fn(finalError, response, request, options as ResolvedRequestOptions); - } - } - - finalError = finalError || {}; - - if (throwOnError) { - throw finalError; - } - - // TODO: we probably want to return error and improve types - return responseStyle === 'data' - ? undefined - : { - error: finalError, - request, - response, - }; - } - }; - - const makeMethodFn = (method: Uppercase) => (options: RequestOptions) => - request({ ...options, method }); - - const makeSseFn = (method: Uppercase) => async (options: RequestOptions) => { - const { opts, url } = await beforeRequest(options); - return createSseClient({ - ...opts, - body: opts.body as BodyInit | null | undefined, - method, - onRequest: async (url, init) => { - let request = new Request(url, init); - for (const fn of interceptors.request.fns) { - if (fn) { - request = await fn(request, opts); - } - } - return request; - }, - serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined, - url, - }); - }; - - const _buildUrl: Client['buildUrl'] = (options) => buildUrl({ ..._config, ...options }); - - return { - buildUrl: _buildUrl, - connect: makeMethodFn('CONNECT'), - delete: makeMethodFn('DELETE'), - get: makeMethodFn('GET'), - getConfig, - head: makeMethodFn('HEAD'), - interceptors, - options: makeMethodFn('OPTIONS'), - patch: makeMethodFn('PATCH'), - post: makeMethodFn('POST'), - put: makeMethodFn('PUT'), - request, - setConfig, - sse: { - connect: makeSseFn('CONNECT'), - delete: makeSseFn('DELETE'), - get: makeSseFn('GET'), - head: makeSseFn('HEAD'), - options: makeSseFn('OPTIONS'), - patch: makeSseFn('PATCH'), - post: makeSseFn('POST'), - put: makeSseFn('PUT'), - trace: makeSseFn('TRACE'), - }, - trace: makeMethodFn('TRACE'), - } as Client; -}; diff --git a/frontend/src/client/client/index.ts b/frontend/src/client/client/index.ts deleted file mode 100644 index 8c69331..0000000 --- a/frontend/src/client/client/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -export type { Auth } from '../core/auth.gen'; -export type { QuerySerializerOptions } from '../core/bodySerializer.gen'; -export { - formDataBodySerializer, - jsonBodySerializer, - urlSearchParamsBodySerializer, -} from '../core/bodySerializer.gen'; -export { buildClientParams } from '../core/params.gen'; -export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen'; -export type { ServerSentEventsResult } from '../core/serverSentEvents.gen'; -export type { ClientMeta } from '../core/types.gen'; -export { createClient } from './client.gen'; -export type { - Client, - ClientOptions, - Config, - CreateClientConfig, - Options, - RequestOptions, - RequestResult, - ResolvedRequestOptions, - ResponseStyle, - TDataShape, -} from './types.gen'; -export { createConfig, mergeHeaders } from './utils.gen'; diff --git a/frontend/src/client/client/types.gen.ts b/frontend/src/client/client/types.gen.ts deleted file mode 100644 index 193646c..0000000 --- a/frontend/src/client/client/types.gen.ts +++ /dev/null @@ -1,218 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import type { Auth } from '../core/auth.gen'; -import type { - ServerSentEventsOptions, - ServerSentEventsResult, -} from '../core/serverSentEvents.gen'; -import type { Client as CoreClient, Config as CoreConfig } from '../core/types.gen'; -import type { Middleware } from './utils.gen'; - -export type ResponseStyle = 'data' | 'fields'; - -export interface Config - extends Omit, CoreConfig { - /** - * Base URL for all requests made by this client. - */ - baseUrl?: T['baseUrl']; - /** - * Fetch API implementation. You can use this option to provide a custom - * fetch instance. - * - * @default globalThis.fetch - */ - fetch?: typeof fetch; - /** - * Please don't use the Fetch client for Next.js applications. The `next` - * options won't have any effect. - * - * Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead. - */ - next?: never; - /** - * Return the response data parsed in a specified format. By default, `auto` - * will infer the appropriate method from the `Content-Type` response header. - * You can override this behavior with any of the {@link Body} methods. - * Select `stream` if you don't want to parse response data at all. - * - * @default 'auto' - */ - parseAs?: 'arrayBuffer' | 'auto' | 'blob' | 'formData' | 'json' | 'stream' | 'text'; - /** - * Should we return only data or multiple fields (data, error, response, etc.)? - * - * @default 'fields' - */ - responseStyle?: ResponseStyle; - /** - * Throw an error instead of returning it in the response? - * - * @default false - */ - throwOnError?: T['throwOnError']; -} - -export interface RequestOptions< - TData = unknown, - TResponseStyle extends ResponseStyle = 'fields', - ThrowOnError extends boolean = boolean, - Url extends string = string, -> - extends - Config<{ - responseStyle: TResponseStyle; - throwOnError: ThrowOnError; - }>, - Pick< - ServerSentEventsOptions, - | 'onRequest' - | 'onSseError' - | 'onSseEvent' - | 'sseDefaultRetryDelay' - | 'sseMaxRetryAttempts' - | 'sseMaxRetryDelay' - > { - /** - * Any body that you want to add to your request. - * - * {@link https://developer.mozilla.org/docs/Web/API/fetch#body} - */ - body?: unknown; - path?: Record; - query?: Record; - /** - * Security mechanism(s) to use for the request. - */ - security?: ReadonlyArray; - url: Url; -} - -export interface ResolvedRequestOptions< - TResponseStyle extends ResponseStyle = 'fields', - ThrowOnError extends boolean = boolean, - Url extends string = string, -> extends RequestOptions { - headers: Headers; - serializedBody?: string; -} - -export type RequestResult< - TData = unknown, - TError = unknown, - ThrowOnError extends boolean = boolean, - TResponseStyle extends ResponseStyle = 'fields', -> = ThrowOnError extends true - ? Promise< - TResponseStyle extends 'data' - ? TData extends Record - ? TData[keyof TData] - : TData - : { - data: TData extends Record ? TData[keyof TData] : TData; - request: Request; - response: Response; - } - > - : Promise< - TResponseStyle extends 'data' - ? (TData extends Record ? TData[keyof TData] : TData) | undefined - : ( - | { - data: TData extends Record ? TData[keyof TData] : TData; - error: undefined; - } - | { - data: undefined; - error: TError extends Record ? TError[keyof TError] : TError; - } - ) & { - /** request may be undefined, because error may be from building the request object itself */ - request?: Request; - /** response may be undefined, because error may be from building the request object itself or from a network error */ - response?: Response; - } - >; - -export interface ClientOptions { - baseUrl?: string; - responseStyle?: ResponseStyle; - throwOnError?: boolean; -} - -type MethodFn = < - TData = unknown, - TError = unknown, - ThrowOnError extends boolean = false, - TResponseStyle extends ResponseStyle = 'fields', ->( - options: Omit, 'method'>, -) => RequestResult; - -type SseFn = < - TData = unknown, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _TError = unknown, - ThrowOnError extends boolean = false, - TResponseStyle extends ResponseStyle = 'fields', ->( - options: Omit, 'method'>, -) => Promise>; - -type RequestFn = < - TData = unknown, - TError = unknown, - ThrowOnError extends boolean = false, - TResponseStyle extends ResponseStyle = 'fields', ->( - options: Omit, 'method'> & - Pick>, 'method'>, -) => RequestResult; - -type BuildUrlFn = < - TData extends { - body?: unknown; - path?: Record; - query?: Record; - url: string; - }, ->( - options: TData & Options, -) => string; - -export type Client = CoreClient & { - interceptors: Middleware; -}; - -/** - * The `createClientConfig()` function will be called on client initialization - * and the returned object will become the client's initial configuration. - * - * You may want to initialize your client this way instead of calling - * `setConfig()`. This is useful for example if you're using Next.js - * to ensure your client always has the correct values. - */ -export type CreateClientConfig = ( - override?: Config, -) => Config & T>; - -export interface TDataShape { - body?: unknown; - headers?: unknown; - path?: unknown; - query?: unknown; - url: string; -} - -type OmitKeys = Pick>; - -export type Options< - TData extends TDataShape = TDataShape, - ThrowOnError extends boolean = boolean, - TResponse = unknown, - TResponseStyle extends ResponseStyle = 'fields', -> = OmitKeys< - RequestOptions, - 'body' | 'path' | 'query' | 'url' -> & - ([TData] extends [never] ? unknown : Omit); diff --git a/frontend/src/client/client/utils.gen.ts b/frontend/src/client/client/utils.gen.ts deleted file mode 100644 index d4a7284..0000000 --- a/frontend/src/client/client/utils.gen.ts +++ /dev/null @@ -1,316 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import { getAuthToken } from '../core/auth.gen'; -import type { QuerySerializerOptions } from '../core/bodySerializer.gen'; -import { jsonBodySerializer } from '../core/bodySerializer.gen'; -import { - serializeArrayParam, - serializeObjectParam, - serializePrimitiveParam, -} from '../core/pathSerializer.gen'; -import { getUrl } from '../core/utils.gen'; -import type { Client, ClientOptions, Config, RequestOptions } from './types.gen'; - -export const createQuerySerializer = ({ - parameters = {}, - ...args -}: QuerySerializerOptions = {}): ((queryParams: T) => string) => { - const querySerializer = (queryParams: T): string => { - const search: string[] = []; - if (queryParams && typeof queryParams === 'object') { - for (const name in queryParams) { - const value = queryParams[name]; - - if (value === undefined || value === null) { - continue; - } - - const options = parameters[name] || args; - - if (Array.isArray(value)) { - const serializedArray = serializeArrayParam({ - allowReserved: options.allowReserved, - explode: true, - name, - style: 'form', - value, - ...options.array, - }); - if (serializedArray) search.push(serializedArray); - } else if (typeof value === 'object') { - const serializedObject = serializeObjectParam({ - allowReserved: options.allowReserved, - explode: true, - name, - style: 'deepObject', - value: value as Record, - ...options.object, - }); - if (serializedObject) search.push(serializedObject); - } else { - const serializedPrimitive = serializePrimitiveParam({ - allowReserved: options.allowReserved, - name, - value: value as string, - }); - if (serializedPrimitive) search.push(serializedPrimitive); - } - } - } - return search.join('&'); - }; - return querySerializer; -}; - -/** - * Infers parseAs value from provided Content-Type header. - */ -export const getParseAs = (contentType: string | null): Exclude => { - if (!contentType) { - // If no Content-Type header is provided, the best we can do is return the raw response body, - // which is effectively the same as the 'stream' option. - return 'stream'; - } - - const cleanContent = contentType.split(';')[0]?.trim(); - - if (!cleanContent) { - return; - } - - if (cleanContent.startsWith('application/json') || cleanContent.endsWith('+json')) { - return 'json'; - } - - if (cleanContent === 'multipart/form-data') { - return 'formData'; - } - - if ( - ['application/', 'audio/', 'image/', 'video/'].some((type) => cleanContent.startsWith(type)) - ) { - return 'blob'; - } - - if (cleanContent.startsWith('text/')) { - return 'text'; - } - - return; -}; - -const checkForExistence = ( - options: Pick & { - headers: Headers; - }, - name?: string, -): boolean => { - if (!name) { - return false; - } - if ( - options.headers.has(name) || - options.query?.[name] || - options.headers.get('Cookie')?.includes(`${name}=`) - ) { - return true; - } - return false; -}; - -export async function setAuthParams( - options: Pick & { - headers: Headers; - }, -): Promise { - for (const auth of options.security ?? []) { - if (checkForExistence(options, auth.name)) { - continue; - } - - const token = await getAuthToken(auth, options.auth); - - if (!token) { - continue; - } - - const name = auth.name ?? 'Authorization'; - - switch (auth.in) { - case 'query': - if (!options.query) { - options.query = {}; - } - options.query[name] = token; - break; - case 'cookie': - options.headers.append('Cookie', `${name}=${token}`); - break; - case 'header': - default: - options.headers.set(name, token); - break; - } - } -} - -export const buildUrl: Client['buildUrl'] = (options) => - getUrl({ - baseUrl: options.baseUrl as string, - path: options.path, - query: options.query, - querySerializer: - typeof options.querySerializer === 'function' - ? options.querySerializer - : createQuerySerializer(options.querySerializer), - url: options.url, - }); - -export const mergeConfigs = (a: Config, b: Config): Config => { - const config = { ...a, ...b }; - if (config.baseUrl?.endsWith('/')) { - config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1); - } - config.headers = mergeHeaders(a.headers, b.headers); - return config; -}; - -const headersEntries = (headers: Headers): Array<[string, string]> => { - const entries: Array<[string, string]> = []; - headers.forEach((value, key) => { - entries.push([key, value]); - }); - return entries; -}; - -export const mergeHeaders = ( - ...headers: Array['headers'] | undefined> -): Headers => { - const mergedHeaders = new Headers(); - for (const header of headers) { - if (!header) { - continue; - } - - const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header); - - for (const [key, value] of iterator) { - if (value === null) { - mergedHeaders.delete(key); - } else if (Array.isArray(value)) { - for (const v of value) { - mergedHeaders.append(key, v as string); - } - } else if (value !== undefined) { - // assume object headers are meant to be JSON stringified, i.e., their - // content value in OpenAPI specification is 'application/json' - mergedHeaders.set( - key, - typeof value === 'object' ? JSON.stringify(value) : (value as string), - ); - } - } - } - return mergedHeaders; -}; - -type ErrInterceptor = ( - error: Err, - /** response may be undefined due to a network error where no response object is produced */ - response: Res | undefined, - /** request may be undefined, because error may be from building the request object itself */ - request: Req | undefined, - options: Options, -) => Err | Promise; - -type ReqInterceptor = (request: Req, options: Options) => Req | Promise; - -type ResInterceptor = ( - response: Res, - request: Req, - options: Options, -) => Res | Promise; - -class Interceptors { - fns: Array = []; - - clear(): void { - this.fns = []; - } - - eject(id: number | Interceptor): void { - const index = this.getInterceptorIndex(id); - if (this.fns[index]) { - this.fns[index] = null; - } - } - - exists(id: number | Interceptor): boolean { - const index = this.getInterceptorIndex(id); - return Boolean(this.fns[index]); - } - - getInterceptorIndex(id: number | Interceptor): number { - if (typeof id === 'number') { - return this.fns[id] ? id : -1; - } - return this.fns.indexOf(id); - } - - update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false { - const index = this.getInterceptorIndex(id); - if (this.fns[index]) { - this.fns[index] = fn; - return id; - } - return false; - } - - use(fn: Interceptor): number { - this.fns.push(fn); - return this.fns.length - 1; - } -} - -export interface Middleware { - error: Interceptors>; - request: Interceptors>; - response: Interceptors>; -} - -export const createInterceptors = (): Middleware< - Req, - Res, - Err, - Options -> => ({ - error: new Interceptors>(), - request: new Interceptors>(), - response: new Interceptors>(), -}); - -const defaultQuerySerializer = createQuerySerializer({ - allowReserved: false, - array: { - explode: true, - style: 'form', - }, - object: { - explode: true, - style: 'deepObject', - }, -}); - -const defaultHeaders = { - 'Content-Type': 'application/json', -}; - -export const createConfig = ( - override: Config & T> = {}, -): Config & T> => ({ - ...jsonBodySerializer, - headers: defaultHeaders, - parseAs: 'auto', - querySerializer: defaultQuerySerializer, - ...override, -}); diff --git a/frontend/src/client/core/auth.gen.ts b/frontend/src/client/core/auth.gen.ts deleted file mode 100644 index c663664..0000000 --- a/frontend/src/client/core/auth.gen.ts +++ /dev/null @@ -1,48 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -export type AuthToken = string | undefined; - -export interface Auth { - /** - * Which part of the request do we use to send the auth? - * - * @default 'header' - */ - in?: 'header' | 'query' | 'cookie'; - /** - * A unique identifier for the security scheme. - * - * Defined only when there are multiple security schemes whose `Auth` - * shape would otherwise be identical. - */ - key?: string; - /** - * Header or query parameter name. - * - * @default 'Authorization' - */ - name?: string; - scheme?: 'basic' | 'bearer'; - type: 'apiKey' | 'http'; -} - -export const getAuthToken = async ( - auth: Auth, - callback: ((auth: Auth) => Promise | AuthToken) | AuthToken, -): Promise => { - const token = typeof callback === 'function' ? await callback(auth) : callback; - - if (!token) { - return; - } - - if (auth.scheme === 'bearer') { - return `Bearer ${token}`; - } - - if (auth.scheme === 'basic') { - return `Basic ${btoa(token)}`; - } - - return token; -}; diff --git a/frontend/src/client/core/bodySerializer.gen.ts b/frontend/src/client/core/bodySerializer.gen.ts deleted file mode 100644 index 67daca6..0000000 --- a/frontend/src/client/core/bodySerializer.gen.ts +++ /dev/null @@ -1,82 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import type { ArrayStyle, ObjectStyle, SerializerOptions } from './pathSerializer.gen'; - -export type QuerySerializer = (query: Record) => string; - -export type BodySerializer = (body: unknown) => unknown; - -type QuerySerializerOptionsObject = { - allowReserved?: boolean; - array?: Partial>; - object?: Partial>; -}; - -export type QuerySerializerOptions = QuerySerializerOptionsObject & { - /** - * Per-parameter serialization overrides. When provided, these settings - * override the global array/object settings for specific parameter names. - */ - parameters?: Record; -}; - -const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => { - if (typeof value === 'string' || value instanceof Blob) { - data.append(key, value); - } else if (value instanceof Date) { - data.append(key, value.toISOString()); - } else { - data.append(key, JSON.stringify(value)); - } -}; - -const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => { - if (typeof value === 'string') { - data.append(key, value); - } else { - data.append(key, JSON.stringify(value)); - } -}; - -export const formDataBodySerializer = { - bodySerializer: (body: unknown): FormData => { - const data = new FormData(); - - Object.entries(body as Record).forEach(([key, value]) => { - if (value === undefined || value === null) { - return; - } - if (Array.isArray(value)) { - value.forEach((v) => serializeFormDataPair(data, key, v)); - } else { - serializeFormDataPair(data, key, value); - } - }); - - return data; - }, -}; - -export const jsonBodySerializer = { - bodySerializer: (body: unknown): string => - JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)), -}; - -export const urlSearchParamsBodySerializer = { - bodySerializer: (body: unknown): string => { - const data = new URLSearchParams(); - - Object.entries(body as Record).forEach(([key, value]) => { - if (value === undefined || value === null) { - return; - } - if (Array.isArray(value)) { - value.forEach((v) => serializeUrlSearchParamsPair(data, key, v)); - } else { - serializeUrlSearchParamsPair(data, key, value); - } - }); - - return data.toString(); - }, -}; diff --git a/frontend/src/client/core/params.gen.ts b/frontend/src/client/core/params.gen.ts deleted file mode 100644 index 5e8908f..0000000 --- a/frontend/src/client/core/params.gen.ts +++ /dev/null @@ -1,178 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -type Slot = 'body' | 'headers' | 'path' | 'query'; - -export type Field = - | { - in: Exclude; - /** - * Field name. This is the name we want the user to see and use. - */ - key: string; - /** - * Field mapped name. This is the name we want to use in the request. - * If omitted, we use the same value as `key`. - */ - map?: string; - } - | { - in: Extract; - /** - * Key isn't required for bodies. - */ - key?: string; - map?: string; - } - | { - /** - * Field name. This is the name we want the user to see and use. - */ - key: string; - /** - * Field mapped name. This is the name we want to use in the request. - * If `in` is omitted, `map` aliases `key` to the transport layer. - */ - map: Slot; - }; - -export interface Fields { - allowExtra?: Partial>; - args?: ReadonlyArray; -} - -export type FieldsConfig = ReadonlyArray; - -const extraPrefixesMap: Record = { - $body_: 'body', - $headers_: 'headers', - $path_: 'path', - $query_: 'query', -}; -const extraPrefixes = Object.entries(extraPrefixesMap); - -type KeyMap = Map< - string, - | { - in: Slot; - map?: string; - } - | { - in?: never; - map: Slot; - } ->; - -function buildKeyMap(fields: FieldsConfig, map?: KeyMap): KeyMap { - if (!map) { - map = new Map(); - } - - for (const config of fields) { - if ('in' in config) { - if (config.key) { - map.set(config.key, { - in: config.in, - map: config.map, - }); - } - } else if ('key' in config) { - map.set(config.key, { - map: config.map, - }); - } else if (config.args) { - buildKeyMap(config.args, map); - } - } - - return map; -} - -interface Params { - body?: unknown; - headers: Record; - path: Record; - query: Record; -} - -function stripEmptySlots(params: Params): void { - for (const [slot, value] of Object.entries(params)) { - if (slot === 'body') continue; - if (value && typeof value === 'object' && !Array.isArray(value) && !Object.keys(value).length) { - delete params[slot as Slot]; - } - } -} - -export function buildClientParams(args: ReadonlyArray, fields: FieldsConfig): Params { - const params: Params = { - headers: Object.create(null), - path: Object.create(null), - query: Object.create(null), - }; - - const map = buildKeyMap(fields); - - function writeSlot(slot: Slot, key: string, value: unknown): void { - let record = params[slot] as Record | undefined; - if (record === undefined) { - record = Object.create(null) as Record; - params[slot] = record; - } - record[key] = value; - } - - let config: FieldsConfig[number] | undefined; - - for (const [index, arg] of args.entries()) { - if (fields[index]) { - config = fields[index]; - } - - if (!config) { - continue; - } - - if ('in' in config) { - if (config.key) { - const field = map.get(config.key)!; - const name = field.map || config.key; - if (field.in) { - writeSlot(field.in, name, arg); - } - } else { - params.body = arg; - } - } else { - for (const [key, value] of Object.entries(arg ?? {})) { - const field = map.get(key); - - if (field) { - if (field.in) { - const name = field.map || key; - writeSlot(field.in, name, value); - } else { - params[field.map] = value; - } - } else { - const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix)); - - if (extra) { - const [prefix, slot] = extra; - writeSlot(slot, key.slice(prefix.length), value); - } else if ('allowExtra' in config && config.allowExtra) { - for (const [slot, allowed] of Object.entries(config.allowExtra)) { - if (allowed) { - writeSlot(slot as Slot, key, value); - break; - } - } - } - } - } - } - } - - stripEmptySlots(params); - - return params; -} diff --git a/frontend/src/client/core/pathSerializer.gen.ts b/frontend/src/client/core/pathSerializer.gen.ts deleted file mode 100644 index fab1ed4..0000000 --- a/frontend/src/client/core/pathSerializer.gen.ts +++ /dev/null @@ -1,171 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -interface SerializeOptions extends SerializePrimitiveOptions, SerializerOptions {} - -interface SerializePrimitiveOptions { - allowReserved?: boolean; - name: string; -} - -export interface SerializerOptions { - /** - * @default true - */ - explode: boolean; - style: T; -} - -export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited'; -export type ArraySeparatorStyle = ArrayStyle | MatrixStyle; -type MatrixStyle = 'label' | 'matrix' | 'simple'; -export type ObjectStyle = 'form' | 'deepObject'; -type ObjectSeparatorStyle = ObjectStyle | MatrixStyle; - -interface SerializePrimitiveParam extends SerializePrimitiveOptions { - value: string; -} - -export const separatorArrayExplode = (style: ArraySeparatorStyle): '.' | ';' | ',' | '&' => { - switch (style) { - case 'label': - return '.'; - case 'matrix': - return ';'; - case 'simple': - return ','; - default: - return '&'; - } -}; - -export const separatorArrayNoExplode = (style: ArraySeparatorStyle): ',' | '|' | '%20' => { - switch (style) { - case 'form': - return ','; - case 'pipeDelimited': - return '|'; - case 'spaceDelimited': - return '%20'; - default: - return ','; - } -}; - -export const separatorObjectExplode = (style: ObjectSeparatorStyle): '.' | ';' | ',' | '&' => { - switch (style) { - case 'label': - return '.'; - case 'matrix': - return ';'; - case 'simple': - return ','; - default: - return '&'; - } -}; - -export const serializeArrayParam = ({ - allowReserved, - explode, - name, - style, - value, -}: SerializeOptions & { - value: unknown[]; -}): string => { - if (!explode) { - const joinedValues = ( - allowReserved ? value : value.map((v) => encodeURIComponent(v as string)) - ).join(separatorArrayNoExplode(style)); - switch (style) { - case 'label': - return `.${joinedValues}`; - case 'matrix': - return `;${name}=${joinedValues}`; - case 'simple': - return joinedValues; - default: - return `${name}=${joinedValues}`; - } - } - - const separator = separatorArrayExplode(style); - const joinedValues = value - .map((v) => { - if (style === 'label' || style === 'simple') { - return allowReserved ? v : encodeURIComponent(v as string); - } - - return serializePrimitiveParam({ - allowReserved, - name, - value: v as string, - }); - }) - .join(separator); - return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues; -}; - -export const serializePrimitiveParam = ({ - allowReserved, - name, - value, -}: SerializePrimitiveParam): string => { - if (value === undefined || value === null) { - return ''; - } - - if (typeof value === 'object') { - throw new Error( - 'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.', - ); - } - - return `${name}=${allowReserved ? value : encodeURIComponent(value)}`; -}; - -export const serializeObjectParam = ({ - allowReserved, - explode, - name, - style, - value, - valueOnly, -}: SerializeOptions & { - value: Record | Date; - valueOnly?: boolean; -}): string => { - if (value instanceof Date) { - return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`; - } - - if (style !== 'deepObject' && !explode) { - let values: string[] = []; - Object.entries(value).forEach(([key, v]) => { - values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)]; - }); - const joinedValues = values.join(','); - switch (style) { - case 'form': - return `${name}=${joinedValues}`; - case 'label': - return `.${joinedValues}`; - case 'matrix': - return `;${name}=${joinedValues}`; - default: - return joinedValues; - } - } - - const separator = separatorObjectExplode(style); - const joinedValues = Object.entries(value) - .map(([key, v]) => - serializePrimitiveParam({ - allowReserved, - name: style === 'deepObject' ? `${name}[${key}]` : key, - value: v as string, - }), - ) - .join(separator); - return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues; -}; diff --git a/frontend/src/client/core/queryKeySerializer.gen.ts b/frontend/src/client/core/queryKeySerializer.gen.ts deleted file mode 100644 index 773b065..0000000 --- a/frontend/src/client/core/queryKeySerializer.gen.ts +++ /dev/null @@ -1,117 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -/** - * JSON-friendly union that mirrors what Pinia Colada can hash. - */ -export type JsonValue = - | null - | string - | number - | boolean - | JsonValue[] - | { [key: string]: JsonValue }; - -/** - * Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes. - */ -export const queryKeyJsonReplacer = (_key: string, value: unknown): unknown | undefined => { - if (value === undefined || typeof value === 'function' || typeof value === 'symbol') { - return undefined; - } - if (typeof value === 'bigint') { - return value.toString(); - } - if (value instanceof Date) { - return value.toISOString(); - } - return value; -}; - -/** - * Safely stringifies a value and parses it back into a JsonValue. - */ -export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => { - try { - const json = JSON.stringify(input, queryKeyJsonReplacer); - if (json === undefined) { - return undefined; - } - return JSON.parse(json) as JsonValue; - } catch { - return undefined; - } -}; - -/** - * Detects plain objects (including objects with a null prototype). - */ -const isPlainObject = (value: unknown): value is Record => { - if (value === null || typeof value !== 'object') { - return false; - } - const prototype = Object.getPrototypeOf(value as object); - return prototype === Object.prototype || prototype === null; -}; - -/** - * Turns URLSearchParams into a sorted JSON object for deterministic keys. - */ -const serializeSearchParams = (params: URLSearchParams): JsonValue => { - const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b)); - const result: Record = {}; - - for (const [key, value] of entries) { - const existing = result[key]; - if (existing === undefined) { - result[key] = value; - continue; - } - - if (Array.isArray(existing)) { - (existing as string[]).push(value); - } else { - result[key] = [existing, value]; - } - } - - return result; -}; - -/** - * Normalizes any accepted value into a JSON-friendly shape for query keys. - */ -export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined => { - if (value === null) { - return null; - } - - if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { - return value; - } - - if (value === undefined || typeof value === 'function' || typeof value === 'symbol') { - return undefined; - } - - if (typeof value === 'bigint') { - return value.toString(); - } - - if (value instanceof Date) { - return value.toISOString(); - } - - if (Array.isArray(value)) { - return stringifyToJsonValue(value); - } - - if (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams) { - return serializeSearchParams(value); - } - - if (isPlainObject(value)) { - return stringifyToJsonValue(value); - } - - return undefined; -}; diff --git a/frontend/src/client/core/serverSentEvents.gen.ts b/frontend/src/client/core/serverSentEvents.gen.ts deleted file mode 100644 index ddf3c4d..0000000 --- a/frontend/src/client/core/serverSentEvents.gen.ts +++ /dev/null @@ -1,242 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import type { Config } from './types.gen'; - -export type ServerSentEventsOptions = Omit & - Pick & { - /** - * Fetch API implementation. You can use this option to provide a custom - * fetch instance. - * - * @default globalThis.fetch - */ - fetch?: typeof fetch; - /** - * Implementing clients can call request interceptors inside this hook. - */ - onRequest?: (url: string, init: RequestInit) => Promise; - /** - * Callback invoked when a network or parsing error occurs during streaming. - * - * This option applies only if the endpoint returns a stream of events. - * - * @param error The error that occurred. - */ - onSseError?: (error: unknown) => void; - /** - * Callback invoked when an event is streamed from the server. - * - * This option applies only if the endpoint returns a stream of events. - * - * @param event Event streamed from the server. - * @returns Nothing (void). - */ - onSseEvent?: (event: StreamEvent) => void; - serializedBody?: RequestInit['body']; - /** - * Default retry delay in milliseconds. - * - * This option applies only if the endpoint returns a stream of events. - * - * @default 3000 - */ - sseDefaultRetryDelay?: number; - /** - * Maximum number of retry attempts before giving up. - */ - sseMaxRetryAttempts?: number; - /** - * Maximum retry delay in milliseconds. - * - * Applies only when exponential backoff is used. - * - * This option applies only if the endpoint returns a stream of events. - * - * @default 30000 - */ - sseMaxRetryDelay?: number; - /** - * Optional sleep function for retry backoff. - * - * Defaults to using `setTimeout`. - */ - sseSleepFn?: (ms: number) => Promise; - url: string; - }; - -export interface StreamEvent { - data: TData; - event?: string; - id?: string; - retry?: number; -} - -export type ServerSentEventsResult = { - stream: AsyncGenerator< - TData extends Record ? TData[keyof TData] : TData, - TReturn, - TNext - >; -}; - -export function createSseClient({ - onRequest, - onSseError, - onSseEvent, - responseTransformer, - responseValidator, - sseDefaultRetryDelay, - sseMaxRetryAttempts, - sseMaxRetryDelay, - sseSleepFn, - url, - ...options -}: ServerSentEventsOptions): ServerSentEventsResult { - let lastEventId: string | undefined; - - const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); - - const createStream = async function* () { - let retryDelay: number = sseDefaultRetryDelay ?? 3000; - let attempt = 0; - const signal = options.signal ?? new AbortController().signal; - - while (true) { - if (signal.aborted) break; - - attempt++; - - const headers = - options.headers instanceof Headers - ? options.headers - : new Headers(options.headers as Record | undefined); - - if (lastEventId !== undefined) { - headers.set('Last-Event-ID', lastEventId); - } - - try { - const requestInit: RequestInit = { - redirect: 'follow', - ...options, - body: options.serializedBody, - headers, - signal, - }; - let request = new Request(url, requestInit); - if (onRequest) { - request = await onRequest(url, requestInit); - } - // fetch must be assigned here, otherwise it would throw the error: - // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation - const _fetch = options.fetch ?? globalThis.fetch; - const response = await _fetch(request); - - if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`); - - if (!response.body) throw new Error('No body in SSE response'); - - const reader = response.body.pipeThrough(new TextDecoderStream()).getReader(); - - let buffer = ''; - - const abortHandler = () => { - try { - reader.cancel(); - } catch { - // noop - } - }; - - signal.addEventListener('abort', abortHandler); - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += value; - buffer = buffer.replace(/\r\n?/g, '\n'); // normalize line endings - - const chunks = buffer.split('\n\n'); - buffer = chunks.pop() ?? ''; - - for (const chunk of chunks) { - const lines = chunk.split('\n'); - const dataLines: Array = []; - let eventName: string | undefined; - - for (const line of lines) { - if (line.startsWith('data:')) { - dataLines.push(line.replace(/^data:\s*/, '')); - } else if (line.startsWith('event:')) { - eventName = line.replace(/^event:\s*/, ''); - } else if (line.startsWith('id:')) { - lastEventId = line.replace(/^id:\s*/, ''); - } else if (line.startsWith('retry:')) { - const parsed = Number.parseInt(line.replace(/^retry:\s*/, ''), 10); - if (!Number.isNaN(parsed)) { - retryDelay = parsed; - } - } - } - - let data: unknown; - let parsedJson = false; - - if (dataLines.length) { - const rawData = dataLines.join('\n'); - try { - data = JSON.parse(rawData); - parsedJson = true; - } catch { - data = rawData; - } - } - - if (parsedJson) { - if (responseValidator) { - await responseValidator(data); - } - - if (responseTransformer) { - data = await responseTransformer(data); - } - } - - onSseEvent?.({ - data, - event: eventName, - id: lastEventId, - retry: retryDelay, - }); - - if (dataLines.length) { - yield data as any; - } - } - } - } finally { - signal.removeEventListener('abort', abortHandler); - reader.releaseLock(); - } - - break; // exit loop on normal completion - } catch (error) { - // connection failed or aborted; retry after delay - onSseError?.(error); - - if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) { - break; // stop after firing error - } - - // exponential backoff: double retry each attempt, cap at 30s - const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000); - await sleep(backoff); - } - } - }; - - const stream = createStream(); - - return { stream }; -} diff --git a/frontend/src/client/core/types.gen.ts b/frontend/src/client/core/types.gen.ts deleted file mode 100644 index c657c85..0000000 --- a/frontend/src/client/core/types.gen.ts +++ /dev/null @@ -1,110 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import type { Auth, AuthToken } from './auth.gen'; -import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from './bodySerializer.gen'; - -export type HttpMethod = - | 'connect' - | 'delete' - | 'get' - | 'head' - | 'options' - | 'patch' - | 'post' - | 'put' - | 'trace'; - -export type Client< - RequestFn = never, - Config = unknown, - MethodFn = never, - BuildUrlFn = never, - SseFn = never, -> = { - /** - * Returns the final request URL. - */ - buildUrl: BuildUrlFn; - getConfig: () => Config; - request: RequestFn; - setConfig: (config: Config) => Config; -} & { - [K in HttpMethod]: MethodFn; -} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } }); - -export interface Config { - /** - * Auth token or a function returning auth token. The resolved value will be - * added to the request payload as defined by its `security` array. - */ - auth?: ((auth: Auth) => Promise | AuthToken) | AuthToken; - /** - * A function for serializing request body parameter. By default, - * {@link JSON.stringify()} will be used. - */ - bodySerializer?: BodySerializer | null; - /** - * An object containing any HTTP headers that you want to pre-populate your - * `Headers` object with. - * - * {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more} - */ - headers?: - | RequestInit['headers'] - | Record< - string, - string | number | boolean | (string | number | boolean)[] | null | undefined | unknown - >; - /** - * The request method. - * - * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more} - */ - method?: Uppercase; - /** - * A function for serializing request query parameters. By default, arrays - * will be exploded in form style, objects will be exploded in deepObject - * style, and reserved characters are percent-encoded. - * - * This method will have no effect if the native `paramsSerializer()` Axios - * API function is used. - * - * {@link https://swagger.io/docs/specification/serialization/#query View examples} - */ - querySerializer?: QuerySerializer | QuerySerializerOptions; - /** - * A function validating request data. This is useful if you want to ensure - * the request conforms to the desired shape, so it can be safely sent to - * the server. - */ - requestValidator?: (data: unknown) => Promise; - /** - * A function transforming response data before it's returned. This is useful - * for post-processing data, e.g., converting ISO strings into Date objects. - */ - responseTransformer?: (data: unknown) => Promise; - /** - * A function validating response data. This is useful if you want to ensure - * the response conforms to the desired shape, so it can be safely passed to - * the transformers and returned to the user. - */ - responseValidator?: (data: unknown) => Promise; -} - -/** - * Arbitrary metadata passed through the `meta` request option. - */ -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface ClientMeta {} - -type IsExactlyNeverOrNeverUndefined = [T] extends [never] - ? true - : [T] extends [never | undefined] - ? [undefined] extends [T] - ? false - : true - : false; - -export type OmitNever> = { - [K in keyof T as IsExactlyNeverOrNeverUndefined extends true ? never : K]: T[K]; -}; diff --git a/frontend/src/client/core/utils.gen.ts b/frontend/src/client/core/utils.gen.ts deleted file mode 100644 index af56e07..0000000 --- a/frontend/src/client/core/utils.gen.ts +++ /dev/null @@ -1,140 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import type { BodySerializer, QuerySerializer } from './bodySerializer.gen'; -import { - type ArraySeparatorStyle, - serializeArrayParam, - serializeObjectParam, - serializePrimitiveParam, -} from './pathSerializer.gen'; - -export interface PathSerializer { - path: Record; - url: string; -} - -export const PATH_PARAM_RE: RegExp = /\{[^{}]+\}/g; - -export const defaultPathSerializer = ({ path, url: _url }: PathSerializer): string => { - let url = _url; - const matches = _url.match(PATH_PARAM_RE); - if (matches) { - for (const match of matches) { - let explode = false; - let name = match.substring(1, match.length - 1); - let style: ArraySeparatorStyle = 'simple'; - - if (name.endsWith('*')) { - explode = true; - name = name.substring(0, name.length - 1); - } - - if (name.startsWith('.')) { - name = name.substring(1); - style = 'label'; - } else if (name.startsWith(';')) { - name = name.substring(1); - style = 'matrix'; - } - - const value = path[name]; - - if (value === undefined || value === null) { - continue; - } - - if (Array.isArray(value)) { - url = url.replace(match, serializeArrayParam({ explode, name, style, value })); - continue; - } - - if (typeof value === 'object') { - url = url.replace( - match, - serializeObjectParam({ - explode, - name, - style, - value: value as Record, - valueOnly: true, - }), - ); - continue; - } - - if (style === 'matrix') { - url = url.replace( - match, - `;${serializePrimitiveParam({ - name, - value: value as string, - })}`, - ); - continue; - } - - const replaceValue = encodeURIComponent( - style === 'label' ? `.${value as string}` : (value as string), - ); - url = url.replace(match, replaceValue); - } - } - return url; -}; - -export const getUrl = ({ - baseUrl, - path, - query, - querySerializer, - url: _url, -}: { - baseUrl?: string; - path?: Record; - query?: Record; - querySerializer: QuerySerializer; - url: string; -}): string => { - const pathUrl = _url.startsWith('/') ? _url : `/${_url}`; - let url = (baseUrl ?? '') + pathUrl; - if (path) { - url = defaultPathSerializer({ path, url }); - } - let search = query ? querySerializer(query) : ''; - if (search.startsWith('?')) { - search = search.substring(1); - } - if (search) { - url += `?${search}`; - } - return url; -}; - -export function getValidRequestBody(options: { - body?: unknown; - bodySerializer?: BodySerializer | null; - serializedBody?: unknown; -}): unknown { - const hasBody = options.body !== undefined; - const isSerializedBody = hasBody && options.bodySerializer; - - if (isSerializedBody) { - if ('serializedBody' in options) { - const hasSerializedBody = - options.serializedBody !== undefined && options.serializedBody !== ''; - - return hasSerializedBody ? options.serializedBody : null; - } - - // not all clients implement a serializedBody property (i.e., client-axios) - return options.body !== '' ? options.body : null; - } - - // plain/text body - if (hasBody) { - return options.body; - } - - // no body was provided - return undefined; -} diff --git a/frontend/src/client/index.ts b/frontend/src/client/index.ts deleted file mode 100644 index 5d92911..0000000 --- a/frontend/src/client/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -export { getHealth, listNotes, type Options } from './sdk.gen'; -export type { ClientOptions, GetHealthData, GetHealthResponse, GetHealthResponses, HealthResponse, ListNotesData, ListNotesResponse, ListNotesResponses, NoteResponse } from './types.gen'; diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts deleted file mode 100644 index 668aa73..0000000 --- a/frontend/src/client/sdk.gen.ts +++ /dev/null @@ -1,29 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import type { Client, ClientMeta, Options as Options2, RequestResult, TDataShape } from './client'; -import { client } from './client.gen'; -import type { GetHealthData, GetHealthResponses, ListNotesData, ListNotesResponses } from './types.gen'; - -export type Options = Options2 & { - /** - * You can provide a client instance returned by `createClient()` instead of - * individual options. This might be also useful if you want to implement a - * custom client. - */ - client?: Client; - /** - * You can pass arbitrary values through the `meta` object. This can be - * used to access values that aren't defined as part of the SDK function. - */ - meta?: keyof ClientMeta extends never ? Record : ClientMeta; -}; - -/** - * Get Health - */ -export const getHealth = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/v1/health', ...options }); - -/** - * List Notes - */ -export const listNotes = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/v1/notes', ...options }); diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts deleted file mode 100644 index eef6f6f..0000000 --- a/frontend/src/client/types.gen.ts +++ /dev/null @@ -1,67 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -export type ClientOptions = { - baseUrl: `${string}://${string}` | (string & {}); -}; - -/** - * HealthResponse - */ -export type HealthResponse = { - /** - * Status - */ - status: 'ok'; -}; - -/** - * NoteResponse - */ -export type NoteResponse = { - /** - * Id - */ - id: number; - /** - * Body - */ - body: string; - /** - * Created At - */ - created_at: string; -}; - -export type GetHealthData = { - body?: never; - path?: never; - query?: never; - url: '/api/v1/health'; -}; - -export type GetHealthResponses = { - /** - * Successful Response - */ - 200: HealthResponse; -}; - -export type GetHealthResponse = GetHealthResponses[keyof GetHealthResponses]; - -export type ListNotesData = { - body?: never; - path?: never; - query?: never; - url: '/api/v1/notes'; -}; - -export type ListNotesResponses = { - /** - * Response List Notes - * - * Successful Response - */ - 200: Array; -}; - -export type ListNotesResponse = ListNotesResponses[keyof ListNotesResponses]; diff --git a/frontend/src/components/NoteList.tsx b/frontend/src/components/NoteList.tsx deleted file mode 100644 index 0afda85..0000000 --- a/frontend/src/components/NoteList.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { useState } from "react"; - -import { listNotes } from "@/client"; -import type { NoteResponse } from "@/client"; - -export function NoteList() { - const [notes, setNotes] = useState(null); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(false); - - async function loadNotes() { - setLoading(true); - setError(null); - try { - const { data } = await listNotes({ throwOnError: true }); - setNotes(data); - } catch { - setError("Could not load notes. Is the backend running?"); - } finally { - setLoading(false); - } - } - - return ( -
- - - {error &&

{error}

} - {notes?.length === 0 &&

No notes yet.

} - {notes && notes.length > 0 && ( -
    - {notes.map((note) => ( -
  • {note.body}
  • - ))} -
- )} -
- ); -} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index ac1c592..96f2ecb 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,10 +1,9 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; - -import { App } from "@/App"; +import { App } from "./App"; const root = document.getElementById("root"); -if (!root) throw new Error("index.html is missing
"); +if (!root) throw new Error("Missing #root element in index.html"); createRoot(root).render( diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json deleted file mode 100644 index 2d8028c..0000000 --- a/frontend/tsconfig.app.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "target": "ES2023", - "lib": ["ES2023", "DOM", "DOM.Iterable"], - "module": "ESNext", - "moduleResolution": "bundler", - "moduleDetection": "force", - "jsx": "react-jsx", - "noEmit": true, - "skipLibCheck": true, - - "strict": true, - "noUncheckedIndexedAccess": true, - "noImplicitOverride": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true, - "erasableSyntaxOnly": true, - "verbatimModuleSyntax": true, - - "paths": { - "@/*": ["./src/*"] - } - }, - "include": ["src"] -} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index d32ff68..0a201fb 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,4 +1,22 @@ { - "files": [], - "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "types": ["vite/client"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "verbatimModuleSyntax": true, + "erasableSyntaxOnly": true, + "isolatedModules": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src", "vite.config.ts", "openapi-ts.config.ts", "oxlint.config.ts"] } diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json deleted file mode 100644 index 2c06519..0000000 --- a/frontend/tsconfig.node.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", - "target": "ES2023", - "lib": ["ES2023"], - "module": "ESNext", - "moduleResolution": "bundler", - "moduleDetection": "force", - "types": ["node"], - "noEmit": true, - "skipLibCheck": true, - - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "erasableSyntaxOnly": true, - "verbatimModuleSyntax": true - }, - "include": ["vite.config.ts", "openapi-ts.config.ts"] -} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index d268c24..714cf87 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,22 +1,13 @@ -import { fileURLToPath } from "node:url"; - import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; export default defineConfig({ plugins: [react()], - resolve: { - alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) }, - }, server: { - // Listen on all interfaces so the browser on your machine can reach the container. - host: true, port: 5173, strictPort: true, - // Forward /api to the backend container, mirroring what nginx does when deployed. - // Code always calls relative URLs like /api/v1/notes. proxy: { - "/api": "http://backend:8000", + "/api": "http://127.0.0.1:3000", }, }, }); diff --git a/mise.toml b/mise.toml index ad16e59..eb6976f 100644 --- a/mise.toml +++ b/mise.toml @@ -1,178 +1,189 @@ [tools] node = "24.22.0" pnpm = "12.14.0" +"npm:prisma" = "7.9.1" python = "3.14.8" uv = "0.13.1" ruff = "0.17.1" +dbmate = "2.37.0" [env] -# Loads DATABASE_URL and friends for the tasks below. Missing in CI, which is fine. _.file = ".env" +DBMATE_MIGRATIONS_DIR = "{{config_root}}/backend/migrations" +DBMATE_NO_DUMP_SCHEMA = "true" -# ─── setup ──────────────────────────────────────────────────────────────────── - -[tasks.install] -description = "Install frontend and backend dependencies on your machine (for editors and checks)" -run = ["uv sync --directory backend", "pnpm install --dir frontend"] - -# ─── dev ────────────────────────────────────────────────────────────────────── +[tasks."setup:dev"] +description = "Install dependencies, start and migrate the database, and generate the contract" +run = [ + "uv sync --directory backend", + "pnpm install --dir frontend", + { task = "db:dev:migrate:up" }, + { task = "contract" }, +] [tasks.dev] -description = "Start Postgres, the backend and the frontend with hot reload (http://localhost:5173)" -run = "docker compose up --build" +description = "Run the backend and frontend with hot reload" +depends = ["dev:backend", "dev:frontend"] -[tasks.down] -description = "Stop every container (data in Postgres is kept)" -run = "docker compose down" +[tasks."dev:backend"] +description = "Run the backend on :3000" +depends = ["db:dev:start"] +dir = "backend" +env = { ENVIRONMENT = "development" } +run = "uv run python -m app.main" + +[tasks."dev:frontend"] +description = "Run the frontend on :5173" +dir = "frontend" +run = "pnpm exec vite" -[tasks.logs] -description = "Tail logs from every container (arg: service name, e.g. backend)" -usage = 'arg "[service]" default=""' -run = 'docker compose logs -f $usage_service' +[tasks."stack:dev:up"] +description = "Build and start web, backend and db in Docker (web on :8080)" +run = "docker compose up --build --wait" -# ─── database ───────────────────────────────────────────────────────────────── +[tasks."stack:dev:down"] +description = "Stop every container" +run = "docker compose down" -[tasks."db:migrate"] -description = "Apply pending migrations (the backend container also does this on start)" -dir = "backend" -run = "uv run alembic upgrade head" +[tasks."stack:dev:logs"] +description = "Follow the logs of every container" +run = "docker compose logs -f" -[tasks."db:rollback"] -description = "Roll back the latest migration" -dir = "backend" -run = "uv run alembic downgrade -1" +[tasks."db:dev:start"] +description = "Start the development database" +run = "docker compose up -d db --wait" -[tasks."db:revision"] -description = "Generate a migration from changes to the models in backend/src/app/models (arg: short description)" -usage = 'arg ""' -dir = "backend" -run = 'uv run alembic revision --autogenerate -m "$usage_message"' +[tasks."db:dev:stop"] +description = "Stop the development database" +run = "docker compose stop db" -[tasks."db:psql"] -description = "Open psql on the local database" -run = 'docker compose exec postgres psql -U "$SERVICE_USER_POSTGRES" -d "$POSTGRES_DB"' +[tasks."db:dev:down"] +description = "Remove the development database container. Its data is kept." +run = "docker compose down db" -[tasks."db:reset"] -description = "Delete the local database volume and start fresh" -confirm = "Delete all local database data?" -run = "docker compose down --volumes" +[tasks."db:dev:studio"] +description = "Open Prisma Studio on the development database (http://localhost:5555)" +depends = ["db:dev:start"] +run = 'prisma studio --port 5555 --url "$DATABASE_URL"' -# ─── contract ───────────────────────────────────────────────────────────────── +[tasks."db:dev:psql"] +description = "Open psql in the development database" +depends = ["db:dev:start"] +run = "docker compose exec db sh -c 'psql -U \"$POSTGRES_USER\" -d \"$POSTGRES_DB\"'" -[tasks."contract:export"] -description = "Write the backend's OpenAPI contract to backend/openapi.json" +[tasks."db:dev:migrate:generate"] +description = "Generate a SQL migration, with up and down sections, from changes to the models (arg: name)" +usage = 'arg "" help="snake_case description, e.g. add_users"' +depends = ["db:dev:migrate:up"] +dir = "backend" +run = 'uv run python scripts/generate_migration.py "$usage_name"' + +[tasks."db:dev:migrate:up"] +description = "Apply pending migrations to the development database" +depends = ["db:dev:start"] +run = "dbmate migrate" + +[tasks."db:dev:migrate:status"] +description = "Show whether the development database is missing any migrations" +depends = ["db:dev:start"] +run = "dbmate status" + +[tasks."db:dev:migrate:down"] +description = "Roll back the latest applied migration using its -- migrate:down section" +confirm = "Roll back the latest migration? This can delete data." +depends = ["db:dev:start"] +run = "dbmate rollback" + +[tasks."db:dev:reset"] +description = "Drop the development database and re-apply every migration" +confirm = "Delete all data in the development database?" +depends = ["db:dev:start"] +run = ["dbmate drop", "dbmate up"] + +[tasks.contract] +description = "Regenerate backend/openapi.json, then the frontend client from it" +run = [{ task = "contract:backend:generate" }, { task = "contract:frontend:generate" }] + +[tasks."contract:backend:generate"] +description = "Write the backend's OpenAPI spec to backend/openapi.json" dir = "backend" -run = "uv run python -m app.export_openapi" +run = "uv run python scripts/openapi.py" -[tasks."client:generate"] -description = "Export the contract, then regenerate the typed frontend client in frontend/src/client" -depends = ["contract:export"] -dir = "frontend" -run = "pnpm run generate:client" +[tasks."contract:frontend:generate"] +description = "Generate the frontend client in frontend/src/api/generated from backend/openapi.json" +run = "pnpm --dir frontend exec openapi-ts" [tasks."contract:check"] -description = "Fail if backend/openapi.json doesn't match the routes" -dir = "backend" -run = "uv run python -m app.export_openapi --check" - -[tasks."client:check"] -description = "Fail if frontend/src/client doesn't match backend/openapi.json" -dir = "frontend" -run = ''' -tmp=$(mktemp -d) -trap 'rm -rf "$tmp"' EXIT -pnpm exec openapi-ts --silent --output "$tmp/client" -if ! diff -r src/client "$tmp/client" > /dev/null; then - echo "frontend/src/client is out of date. Run: mise run client:generate" - exit 1 -fi -echo "frontend/src/client is up to date" -''' - -# ─── lint and format ────────────────────────────────────────────────────────── +description = "Fail if backend/openapi.json is out of date with the backend code" +depends = ["contract:backend:generate"] +run = "git diff --exit-code --stat backend/openapi.json || (echo 'backend/openapi.json is out of date. Run mise run contract and commit it.' >&2; exit 1)" [tasks."lint:backend"] -description = "Lint the backend (ruff)" +description = "Lint and typecheck the backend" dir = "backend" -run = "ruff check ." +run = ["ruff check .", "uv run mypy"] [tasks."lint:backend:fix"] -description = "Fix the backend's lint issues" -dir = "backend" -run = "ruff check --fix ." +description = "Fix backend lint issues" +run = "ruff check --fix backend" [tasks."lint:frontend"] -description = "Lint the frontend (oxlint)" +description = "Lint and typecheck the frontend" +depends = ["contract:frontend:generate"] dir = "frontend" -run = "pnpm run lint" +run = ["pnpm exec oxlint --type-aware", "pnpm exec tsc --noEmit"] [tasks."lint:frontend:fix"] -description = "Fix the frontend's lint issues" +description = "Fix frontend lint issues" dir = "frontend" -run = "pnpm run lint:fix" +run = "pnpm exec oxlint --type-aware --fix" [tasks.lint] -description = "Lint the backend and frontend" +description = "Lint and typecheck everything" depends = ["lint:backend", "lint:frontend"] [tasks."lint:fix"] -description = "Fix lint issues in the backend and frontend" +description = "Fix lint issues everywhere" depends = ["lint:backend:fix", "lint:frontend:fix"] [tasks."fmt:backend"] -description = "Check the backend's formatting (ruff)" -dir = "backend" -run = "ruff format --check ." +alias = "format:backend" +description = "Check backend formatting" +run = "ruff format --check backend" [tasks."fmt:backend:fix"] +alias = "format:backend:fix" description = "Format the backend" -dir = "backend" -run = "ruff format ." +run = "ruff format backend" [tasks."fmt:frontend"] -description = "Check the frontend's formatting (oxfmt)" +alias = "format:frontend" +description = "Check frontend formatting" dir = "frontend" -run = "pnpm run format" +run = "pnpm exec oxfmt --check" [tasks."fmt:frontend:fix"] +alias = "format:frontend:fix" description = "Format the frontend" dir = "frontend" -run = "pnpm run format:fix" +run = "pnpm exec oxfmt" [tasks.fmt] -alias = ["format", "fmt:check"] -description = "Check formatting for the backend and frontend" +alias = "format" +description = "Check formatting everywhere" depends = ["fmt:backend", "fmt:frontend"] [tasks."fmt:fix"] alias = "format:fix" -description = "Format the backend and frontend" +description = "Format everything" depends = ["fmt:backend:fix", "fmt:frontend:fix"] -# ─── typecheck, build ───────────────────────────────────────────────────────── - -[tasks."typecheck:backend"] -description = "Typecheck the backend (mypy --strict)" -dir = "backend" -run = "uv run mypy" - -[tasks."typecheck:frontend"] -description = "Typecheck the frontend (tsc)" +[tasks.build] +description = "Build the backend and frontend" +depends = ["contract:frontend:generate"] dir = "frontend" -run = "pnpm run typecheck" - -[tasks.typecheck] -description = "Typecheck the backend and frontend" -depends = ["typecheck:backend", "typecheck:frontend"] +run = "pnpm run build" [tasks.check] description = "Run everything CI runs" -depends = ["fmt", "lint", "typecheck", "contract:check", "client:check"] - -# A separate project name keeps this from replacing your `mise run dev` containers. -[tasks."deploy:check"] -description = "Build the deployed stack (nginx, no hot reload), wait until every container is healthy, then stop it" -run = [ - "docker compose -p python-react-starter-deploy -f compose.yaml up --build --wait", - "docker compose -p python-react-starter-deploy -f compose.yaml down --volumes", -] +depends = ["fmt", "lint", "contract:check"] From 06b32004ee1818646ca85ba1e953a469e473a74d Mon Sep 17 00:00:00 2001 From: Anish Sahoo Date: Mon, 19 Oct 2026 20:06:16 -0400 Subject: [PATCH 3/6] docs: cut README down to setup, dev and migrations - add a short docs/ARCHITECTURE.md with the request flow --- README.md | 153 +++++++------------------------------------ docs/ARCHITECTURE.md | 16 +++++ 2 files changed, 39 insertions(+), 130 deletions(-) create mode 100644 docs/ARCHITECTURE.md diff --git a/README.md b/README.md index a3d296f..17042bc 100644 --- a/README.md +++ b/README.md @@ -1,151 +1,44 @@ # Python React starter -A three-container web app: a React frontend, a FastAPI backend and a Postgres -database. It ships with one example feature (a list of messages) that shows a request -going from React to FastAPI to Postgres and back. Replace it with your own. +React, FastAPI and Postgres. `mise tasks` lists every task. -There are matching TypeScript (Hono) and Java (Spring Boot) starters with the same -API, the same frontend and the same `mise` tasks. +## Install mise -| Part | Stack | -| -------- | ------------------------------------------------------------------------------ | -| Frontend | React, Vite, a client generated from the API contract by `@hey-api/openapi-ts` | -| Backend | FastAPI, pydantic, SQLAlchemy (async), httpx, logging with rich | -| Database | Postgres 18, migrations generated by Alembic and applied by dbmate | -| Tooling | mise, uv, ruff, mypy, oxlint, oxfmt, strict TypeScript, Docker Compose | - -## Layout - -``` -mise.toml Tool versions and every task you will run -compose.yaml The deployed stack: web, backend, db. Coolify reads this file. -compose.override.yaml Local additions: published ports, a required .env -.env.example Local settings. Copy it to .env. -backend/ - src/app/main.py Creates the app. `python -m app.main` runs it. - src/app/config/ Settings, database session and logging - src/app/routes/ HTTP handlers, one file per resource - src/app/schemas/ pydantic request and response models - src/app/models/ SQLAlchemy tables. The source of truth for the schema. - src/app/middleware/ Request logging - migrations/ Generated SQL migrations. Commit these. - openapi.json The API contract, generated from the routes. Commit it. - scripts/ Writes openapi.json and generates migrations -frontend/ - src/App.tsx The page - src/api/generated/ Client generated from the contract (not committed) - nginx.conf Deployed web server: serves the app and proxies /api -.agents/rules/ Extra rules for AI agents, per folder -AGENTS.md Rules for agents and humans +```sh +curl https://mise.run | sh ``` -## Getting started +Then follow the printed step to activate it in your shell. See + for other installers. You also need +Docker. -You need [mise](https://mise.jdx.dev) and Docker. +## Set up ```sh mise install cp .env.example .env mise run setup:dev -mise run dev ``` -Open and press the button. The API runs on -, with docs at . - -`mise tasks` lists every task. The ones you will use most: - -| Task | What it does | -| ----------------------------------------- | --------------------------------------------------------------- | -| `mise run dev` | Start the db, then run the backend and frontend with hot reload | -| `mise run contract` | Regenerate `backend/openapi.json` and the frontend client | -| `mise run db:dev:migrate:generate ` | Write a SQL migration from your changes to the models | -| `mise run db:dev:migrate:up` | Apply pending migrations | -| `mise run db:dev:migrate:status` | Show whether the database is missing migrations | -| `mise run db:dev:migrate:down` | Roll back the latest migration | -| `mise run db:dev:studio` | Open Prisma Studio on the dev database (:5555) | -| `mise run db:dev:psql` | Open psql in the database | -| `mise run check` | Run what CI runs: format check, lint, typecheck, contract check | -| `mise run fmt:fix` | Format everything | -| `mise run stack:dev:up` | Build and run all three containers, as deployed, on :8080 | - -## The API contract - -The routes and pydantic schemas are the source of truth. `mise run contract` does two -things: - -1. `contract:generate` writes `backend/openapi.json` from the app. FastAPI builds the - spec itself; `scripts/openapi.py` only saves it. Commit this file. -2. `contract:client` generates a typed client with zod validators in - `frontend/src/api/generated/`. This folder is gitignored. - -`mise run check` fails if `backend/openapi.json` is out of date with the routes. - -To add a route, copy `backend/src/app/routes/messages.py`, add its router in -`routes/__init__.py` and run `mise run contract`. The frontend then has a new function -named after the handler, in camelCase. - -## The database - -The models in `backend/src/app/models/` describe every table. Migrations are plain SQL -files in `backend/migrations/`, with an up and a down section. +## Run -1. Edit a model, or add one and import it in `models/__init__.py`. -2. Run `mise run db:dev:migrate:generate describe_your_change`. Alembic compares the - models with your database, and `scripts/generate_migration.py` writes the SQL for - the difference to a new file. Nothing is written if nothing changed. -3. Read the SQL, then run `mise run db:dev:migrate:up`. -4. Commit the model and the migration together. - -[dbmate](https://github.com/amacneil/dbmate) applies the files. Each one has a -`-- migrate:up` section and a `-- migrate:down` section, and -`mise run db:dev:migrate:down` runs the down section of the latest one. - -After pulling a teammate's migration, `mise run db:dev:migrate:status` tells you -whether your database is behind, and `mise run db:dev:migrate:up` catches it up. The -backend container applies pending migrations every time it starts, so deployed -environments stay in sync on their own. - -Queries are SQL too. Use `select()`, `insert()`, `update()`, `delete()` or `text()`, -as `routes/messages.py` does, not `session.query()` or `session.add()`. - -`mise run db:dev:studio` opens Prisma Studio on , already -connected to the dev database, to browse and edit rows. For raw SQL, use -`mise run db:dev:psql`. - -`mise run db:dev:reset` wipes the local database and reapplies everything. - -## Logging - -Logs use the standard `logging` module. In development (`mise run dev`) they go -through rich, with colors and readable tracebacks. When deployed they are plain lines -on stdout. Get a logger with `logging.getLogger(__name__)`. - -## Environment variables +```sh +mise run dev +``` -`.env` holds your local values and is never committed. `.env.example` lists every -variable with values that work locally. mise loads `.env` for every task, and Docker -Compose reads it too. If port 5432 is taken, change `POSTGRES_PORT` and the port in -`DATABASE_URL`. +Open . The API is on . -To add a variable, add it to `.env.example`, to `Settings` in -`backend/src/app/config/settings.py`, and to the `backend` service in `compose.yaml`. -Secrets belong in the backend only: anything the frontend uses ends up in the browser. +## Migrations -## Deploying on Coolify +Tables are defined in `backend/src/app/models/`. Change them there, then generate a migration. -1. Create a resource from this repo with the **Docker Compose** build pack, base - directory `/` and compose file `/compose.yaml`. -2. Give the `web` service a domain that includes its port, for example - `https://myapp.example.com:8080`. Do not give `backend` or `db` a domain. -3. Deploy. Coolify generates `SERVICE_USER_POSTGRES` and `SERVICE_PASSWORD_POSTGRES` - once and keeps them for that environment. +```sh +mise run db:dev:migrate:generate add_something # write SQL from your model changes +mise run db:dev:migrate:up # apply pending migrations +mise run db:dev:migrate:status # is the database behind? +mise run db:dev:migrate:down # undo the latest migration +``` -## Removing the example +Read the generated SQL before committing it. -1. Delete `routes/messages.py` and its line in `routes/__init__.py`, and - `schemas/message.py`. -2. Delete `models/message.py` and its import in `models/__init__.py`, run - `mise run db:dev:migrate:generate remove_messages`, then - `mise run db:dev:migrate:up`. -3. Replace `frontend/src/App.tsx`, then run `mise run contract`. +`mise run db:dev:studio` opens Prisma Studio on . diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..a54cd75 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,16 @@ +# Architecture + +Local development: + +``` +browser -> Vite :5173 -> /api -> FastAPI :3000 -> Postgres :5432 (Docker) +``` + +Deployed (`compose.yaml`): + +``` +browser -> nginx :8080 -> /api -> FastAPI :3000 -> Postgres +``` + +The frontend only calls relative `/api/...` URLs. Vite forwards them in development and +nginx does it when deployed. From 193a46f44474d20e9330efb4027326d3faff4fe4 Mon Sep 17 00:00:00 2001 From: Anish Sahoo Date: Mon, 19 Oct 2026 20:19:37 -0400 Subject: [PATCH 4/6] chore: simplify health check and dev tasks - health no longer queries the database - rename stack:dev:* to docker:dev:* - fold db:dev:down into db:dev:stop, which keeps the data - make func-style a lint warning --- backend/openapi.json | 2 +- backend/src/app/config/db.py | 2 ++ backend/src/app/main.py | 1 - backend/src/app/middleware/request_logger.py | 2 ++ backend/src/app/models/base.py | 2 ++ backend/src/app/routes/health.py | 7 ++----- frontend/oxlint.config.ts | 4 +++- mise.toml | 12 ++++-------- 8 files changed, 16 insertions(+), 16 deletions(-) diff --git a/backend/openapi.json b/backend/openapi.json index 23ccd96..c40908f 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -11,7 +11,7 @@ "operationId": "getHealth", "responses": { "200": { - "description": "The API and database are reachable", + "description": "The API is reachable", "content": { "application/json": { "schema": { diff --git a/backend/src/app/config/db.py b/backend/src/app/config/db.py index c9d9b03..702888e 100644 --- a/backend/src/app/config/db.py +++ b/backend/src/app/config/db.py @@ -10,6 +10,8 @@ SessionLocal = async_sessionmaker(engine, expire_on_commit=False) +# FastAPI calls this once per request that asks for SessionDep: it opens a +# session, hands it to the route, and closes it when the response is sent. async def get_session() -> AsyncGenerator[AsyncSession]: async with SessionLocal() as session: yield session diff --git a/backend/src/app/main.py b/backend/src/app/main.py index 978bd6e..d441489 100644 --- a/backend/src/app/main.py +++ b/backend/src/app/main.py @@ -12,7 +12,6 @@ app = FastAPI( title="API", version="1.0.0", - # The spec is only written to backend/openapi.json. No docs pages are served. openapi_url=None, docs_url=None, redoc_url=None, diff --git a/backend/src/app/middleware/request_logger.py b/backend/src/app/middleware/request_logger.py index 3d4916d..01683ad 100644 --- a/backend/src/app/middleware/request_logger.py +++ b/backend/src/app/middleware/request_logger.py @@ -22,6 +22,8 @@ async def log_requests( response = await call_next(request) status = response.status_code + + # no logs for healthchecks to reduce unnecessary noise if request.url.path == "/api/v1/health" and status < 400: return response diff --git a/backend/src/app/models/base.py b/backend/src/app/models/base.py index fa2b68a..4734849 100644 --- a/backend/src/app/models/base.py +++ b/backend/src/app/models/base.py @@ -1,5 +1,7 @@ from sqlalchemy.orm import DeclarativeBase +# this is needed to support sqlalchemy migration generations +# from python entities class Base(DeclarativeBase): pass diff --git a/backend/src/app/routes/health.py b/backend/src/app/routes/health.py index 56b5f60..280c2f7 100644 --- a/backend/src/app/routes/health.py +++ b/backend/src/app/routes/health.py @@ -1,13 +1,10 @@ from fastapi import APIRouter -from sqlalchemy import text -from app.config.db import SessionDep from app.schemas.health import Health router = APIRouter() -@router.get("/health", response_description="The API and database are reachable") -async def get_health(session: SessionDep) -> Health: - await session.execute(text("SELECT 1")) +@router.get("/health", response_description="The API is reachable") +async def get_health() -> Health: return Health(status="ok") diff --git a/frontend/oxlint.config.ts b/frontend/oxlint.config.ts index c2dce0f..d924fc5 100644 --- a/frontend/oxlint.config.ts +++ b/frontend/oxlint.config.ts @@ -12,7 +12,9 @@ export default defineConfig({ "typescript/no-explicit-any": "error", "no-console": "error", eqeqeq: "error", - "func-style": ["error", "expression"], + // A warning, not an error: TypeScript overloads and hoisted helpers need + // function declarations. + "func-style": ["warn", "expression"], "prefer-arrow-callback": "error", "typescript/consistent-type-definitions": ["error", "type"], "react/react-in-jsx-scope": "off", diff --git a/mise.toml b/mise.toml index eb6976f..611e6a4 100644 --- a/mise.toml +++ b/mise.toml @@ -37,15 +37,15 @@ description = "Run the frontend on :5173" dir = "frontend" run = "pnpm exec vite" -[tasks."stack:dev:up"] +[tasks."docker:dev:up"] description = "Build and start web, backend and db in Docker (web on :8080)" run = "docker compose up --build --wait" -[tasks."stack:dev:down"] +[tasks."docker:dev:down"] description = "Stop every container" run = "docker compose down" -[tasks."stack:dev:logs"] +[tasks."docker:dev:logs"] description = "Follow the logs of every container" run = "docker compose logs -f" @@ -54,11 +54,7 @@ description = "Start the development database" run = "docker compose up -d db --wait" [tasks."db:dev:stop"] -description = "Stop the development database" -run = "docker compose stop db" - -[tasks."db:dev:down"] -description = "Remove the development database container. Its data is kept." +description = "Stop and remove the development database container. Its data is kept." run = "docker compose down db" [tasks."db:dev:studio"] From 71e677abedae511cde1ba5040f24660d7588f885 Mon Sep 17 00:00:00 2001 From: Anish Sahoo Date: Mon, 19 Oct 2026 20:28:59 -0400 Subject: [PATCH 5/6] fix: keep log colors behind the mise task prefix - force colors for tasks, since mise pipes their output - print one colored line per log record instead of RichHandler columns - drop the logger name from log lines - rename db:dev:start and db:dev:stop to db:dev:up and db:dev:down --- backend/src/app/config/logger.py | 39 ++++++++++++++++++++++++-------- mise.toml | 21 +++++++++-------- 2 files changed, 42 insertions(+), 18 deletions(-) diff --git a/backend/src/app/config/logger.py b/backend/src/app/config/logger.py index 64f4c0e..69ad56f 100644 --- a/backend/src/app/config/logger.py +++ b/backend/src/app/config/logger.py @@ -1,24 +1,45 @@ import logging +from datetime import datetime -from rich.logging import RichHandler +from rich.console import Console +from rich.text import Text from app.config.settings import settings +class ColorHandler(logging.Handler): + """One colored line per log record, printed with rich. + + rich's own RichHandler lays logs out in fixed-width columns, and behind + mise's [dev:backend] prefix it can't measure the terminal, so it wraps every + long line. Printing a single line lets the terminal do the wrapping.""" + + def __init__(self) -> None: + super().__init__() + self.console = Console(soft_wrap=True) + + def emit(self, record: logging.LogRecord) -> None: + time = datetime.fromtimestamp(record.created).strftime("%H:%M:%S") + self.console.print( + Text(time, style="dim"), + Text( + f"{record.levelname:<7}", + style=f"logging.level.{record.levelname.lower()}", + ), + self.console.highlighter(self.format(record)), + ) + + def configure_logging() -> None: - """Colored logs from rich in development, plain lines in production. - uvicorn's logs go through the same handler.""" + """Colored logs in development, plain lines in production. uvicorn's logs + go through the same handler.""" development = settings.environment == "development" logging.basicConfig( level=settings.log_level.upper(), - handlers=[ - RichHandler(rich_tracebacks=True) - if development - else logging.StreamHandler() - ], + handlers=[ColorHandler() if development else logging.StreamHandler()], format="%(message)s" if development - else "%(asctime)s %(levelname)s %(name)s: %(message)s", + else "%(asctime)s %(levelname)s %(message)s", force=True, ) # The request logger middleware replaces uvicorn's access log. diff --git a/mise.toml b/mise.toml index 611e6a4..266e17d 100644 --- a/mise.toml +++ b/mise.toml @@ -9,6 +9,9 @@ dbmate = "2.37.0" [env] _.file = ".env" +# mise pipes task output to add the [task] prefix. Without this, tools see a pipe +# instead of a terminal and drop their colors. +FORCE_COLOR = "1" DBMATE_MIGRATIONS_DIR = "{{config_root}}/backend/migrations" DBMATE_NO_DUMP_SCHEMA = "true" @@ -27,7 +30,7 @@ depends = ["dev:backend", "dev:frontend"] [tasks."dev:backend"] description = "Run the backend on :3000" -depends = ["db:dev:start"] +depends = ["db:dev:up"] dir = "backend" env = { ENVIRONMENT = "development" } run = "uv run python -m app.main" @@ -49,22 +52,22 @@ run = "docker compose down" description = "Follow the logs of every container" run = "docker compose logs -f" -[tasks."db:dev:start"] +[tasks."db:dev:up"] description = "Start the development database" run = "docker compose up -d db --wait" -[tasks."db:dev:stop"] +[tasks."db:dev:down"] description = "Stop and remove the development database container. Its data is kept." run = "docker compose down db" [tasks."db:dev:studio"] description = "Open Prisma Studio on the development database (http://localhost:5555)" -depends = ["db:dev:start"] +depends = ["db:dev:up"] run = 'prisma studio --port 5555 --url "$DATABASE_URL"' [tasks."db:dev:psql"] description = "Open psql in the development database" -depends = ["db:dev:start"] +depends = ["db:dev:up"] run = "docker compose exec db sh -c 'psql -U \"$POSTGRES_USER\" -d \"$POSTGRES_DB\"'" [tasks."db:dev:migrate:generate"] @@ -76,24 +79,24 @@ run = 'uv run python scripts/generate_migration.py "$usage_name"' [tasks."db:dev:migrate:up"] description = "Apply pending migrations to the development database" -depends = ["db:dev:start"] +depends = ["db:dev:up"] run = "dbmate migrate" [tasks."db:dev:migrate:status"] description = "Show whether the development database is missing any migrations" -depends = ["db:dev:start"] +depends = ["db:dev:up"] run = "dbmate status" [tasks."db:dev:migrate:down"] description = "Roll back the latest applied migration using its -- migrate:down section" confirm = "Roll back the latest migration? This can delete data." -depends = ["db:dev:start"] +depends = ["db:dev:up"] run = "dbmate rollback" [tasks."db:dev:reset"] description = "Drop the development database and re-apply every migration" confirm = "Delete all data in the development database?" -depends = ["db:dev:start"] +depends = ["db:dev:up"] run = ["dbmate drop", "dbmate up"] [tasks.contract] From 4f3e424d62cc4510baf6263c3ceb1d5324d9df9a Mon Sep 17 00:00:00 2001 From: Anish Sahoo Date: Mon, 19 Oct 2026 21:08:18 -0400 Subject: [PATCH 6/6] chore: rename setup:dev to setup --- README.md | 2 +- mise.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 17042bc..949d651 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Docker. ```sh mise install cp .env.example .env -mise run setup:dev +mise run setup ``` ## Run diff --git a/mise.toml b/mise.toml index 266e17d..94fec5f 100644 --- a/mise.toml +++ b/mise.toml @@ -15,7 +15,7 @@ FORCE_COLOR = "1" DBMATE_MIGRATIONS_DIR = "{{config_root}}/backend/migrations" DBMATE_NO_DUMP_SCHEMA = "true" -[tasks."setup:dev"] +[tasks.setup] description = "Install dependencies, start and migrate the database, and generate the contract" run = [ "uv sync --directory backend",