patx/makeitpdf
Add FastAPI + Jinja2 image-to-PDF service
Commit c073366 · patx · 2026-08-09T00:57:52-04:00
Add FastAPI + Jinja2 image-to-PDF service Server-side implementation: Pillow conversion, 10-image limit, EXIF orientation, A4 output, progressive-enhancement front end. Checkpointed before the client-side rewrite so this version stays recoverable. Co-Authored-By: Claude Opus 5 <[email protected]>
Comments
No comments yet.
Diff
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..814c4ab
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+.venv/
+__pycache__/
+*.py[cod]
+.pytest_cache/
+.ruff_cache/
+*.pdf
+.DS_Store
diff --git a/.python-version b/.python-version
new file mode 100644
index 0000000..6324d40
--- /dev/null
+++ b/.python-version
@@ -0,0 +1 @@
+3.14
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..fe0f3e5
--- /dev/null
+++ b/README.md
@@ -0,0 +1,112 @@
+# MakeItPDF
+
+Upload up to 10 images, get back a single PDF. FastAPI on the back, Jinja2 and
+plain JavaScript on the front.
+
+## Run it
+
+```bash
+uv sync
+uv run uvicorn app.main:app --reload
+```
+
+Then open http://127.0.0.1:8000.
+
+## Tests
+
+```bash
+uv run pytest
+```
+
+## How it works
+
+`GET /` renders the composer. `POST /convert` takes a multipart body of
+`images` (repeated) and responds with `application/pdf` as an attachment.
+
+```bash
+curl -X POST http://127.0.0.1:8000/convert \
+ -F "[email protected]" \
+ -F "[email protected]" \
+ -o out.pdf
+```
+
+Page order follows the order the parts arrive in.
+
+### Page size
+
+Pages are **A4**. The interface offers no choice — change
+`DEFAULT_PAGE_SIZE` in `app/config.py` to `letter` for US paper.
+
+Each page takes the orientation of its own image, so a batch can mix portrait
+and landscape. The image is centred inside a 24 pt margin.
+
+The other sizes stay reachable through the API with an optional `page_size`
+field (`a4`, `letter`, `fit`):
+
+```bash
+curl -X POST http://127.0.0.1:8000/convert \
+ -F "page_size=fit" -F "[email protected]" -o out.pdf
+```
+
+`fit` gives every page the size of its own image. It reads the image's DPI when
+the file records a plausible one (36–1200, which is what scanners write), and
+otherwise assumes 150 DPI — a 4032 px phone photo becomes a 27 in page rather
+than the 56 in you would get from the 72 DPI PDF default.
+
+Every page is rasterised at 150 DPI (`RENDER_DPI`) so one resolution describes
+the whole document. Pillow's PDF writer applies a single resolution to all
+frames, so mixing per-image densities would silently distort pages.
+
+### Limits
+
+Set in `app/config.py`; the template feeds the same numbers to the browser so
+client and server can't drift apart.
+
+- 10 images per PDF
+- 25 MB per image, 80 MB per batch
+- 80 megapixels per image (decompression-bomb guard)
+- JPEG, PNG, WebP, GIF, BMP, TIFF
+
+Uploads are read in 1 MB chunks and abandoned the moment they pass their
+budget, so an oversized file is rejected without being buffered whole.
+
+### Handling notes
+
+- Format is determined by decoding the bytes, not by the browser's
+ `Content-Type` header.
+- EXIF orientation is baked in, so sideways phone photos come out upright.
+- Transparency is composited onto white; PDF has no alpha here, so a
+ transparent PNG would otherwise render on black.
+- Multi-frame files (animated GIF/WebP) contribute their first frame.
+
+Nothing is written to disk and nothing is stored — the PDF is built in memory
+and streamed back.
+
+## Front end
+
+The form works without JavaScript: pick files with the native input, submit,
+get a PDF. Errors re-render the page with a message.
+
+With JavaScript, `app/static/composer.js` takes over and adds a thumbnail grid,
+drag-to-reorder (plus arrow buttons, since dragging isn't reachable by keyboard
+and doesn't work on touch), per-image removal, client-side validation against
+the same limits, and a "your PDF is ready" step that keeps you on the page
+instead of firing a download at you. It posts with `Accept: application/json`
+so the server answers errors as JSON instead of HTML.
+
+The design is one typeface (Figtree), a white ground, and a single red used in
+exactly two places: the logo and the button you press.
+
+## Layout
+
+```
+app/
+ main.py routes, upload limits, error negotiation
+ converter.py images -> PDF, no web imports, directly testable
+ config.py limits and page geometry
+ templates/ base.html, index.html
+ static/ style.css, composer.js
+tests/
+ test_converter.py geometry, formats, EXIF, rejections
+ test_api.py endpoints, limits, content negotiation
+```
diff --git a/app/config.py b/app/config.py
new file mode 100644
index 0000000..42430a2
--- /dev/null
+++ b/app/config.py
@@ -0,0 +1,43 @@
+"""Limits and tunables for the upload/convert pipeline."""
+
+from __future__ import annotations
+
+MAX_FILES = 10
+"""Hard cap on images per PDF."""
+
+MAX_FILE_BYTES = 25 * 1024 * 1024
+"""Largest single upload accepted (25 MB)."""
+
+MAX_TOTAL_BYTES = 80 * 1024 * 1024
+"""Largest combined upload accepted (80 MB)."""
+
+MAX_PIXELS = 80_000_000
+"""Decompression-bomb guard: reject images above 80 megapixels."""
+
+RENDER_DPI = 150
+"""Every page is rasterised at this density, so one resolution describes the
+whole document and page geometry stays exact."""
+
+FIT_FALLBACK_DPI = 150
+"""Assumed density for 'fit' pages whose file carries no sane DPI metadata.
+A 4032px phone photo becomes a ~27in page at 150 DPI rather than 56in at 72."""
+
+MARGIN_PT = 24
+"""Margin for fixed page sizes, in PostScript points (1/72in)."""
+
+ACCEPTED_FORMATS = {"JPEG", "PNG", "WEBP", "GIF", "BMP", "TIFF", "MPO"}
+"""Pillow format names we accept. MPO is what iPhone burst/HDR JPEGs decode as."""
+
+ACCEPT_ATTRIBUTE = "image/jpeg,image/png,image/webp,image/gif,image/bmp,image/tiff"
+"""Value for the file input's accept attribute."""
+
+# Page sizes in points, portrait orientation.
+PAGE_SIZES: dict[str, tuple[float, float] | None] = {
+ "fit": None,
+ "a4": (595.28, 841.89),
+ "letter": (612.0, 792.0),
+}
+
+DEFAULT_PAGE_SIZE = "a4"
+"""What the web form produces. The others stay reachable through the API;
+switch this to 'letter' for US paper."""
diff --git a/app/converter.py b/app/converter.py
new file mode 100644
index 0000000..4107b94
--- /dev/null
+++ b/app/converter.py
@@ -0,0 +1,166 @@
+"""Turn a batch of image bytes into a single PDF.
+
+Kept free of web-framework imports so it can be exercised directly in tests.
+
+Every page follows the same path: work out the page box in points, rasterise
+the image into a canvas sized for that box at ``RENDER_DPI``, then hand the
+whole run to Pillow with one resolution. A single global DPI keeps page
+geometry exact -- Pillow's PDF writer applies one resolution to every frame,
+so per-image DPI would otherwise silently distort pages.
+"""
+
+from __future__ import annotations
+
+import io
+from dataclasses import dataclass
+
+from PIL import Image, ImageOps, UnidentifiedImageError
+
+from app.config import (
+ ACCEPTED_FORMATS,
+ FIT_FALLBACK_DPI,
+ MARGIN_PT,
+ MAX_PIXELS,
+ PAGE_SIZES,
+ RENDER_DPI,
+)
+
+
+class ConversionError(Exception):
+ """Raised when a batch cannot be turned into a PDF."""
+
+
+@dataclass(frozen=True)
+class SourceImage:
+ """One uploaded image, already read into memory."""
+
+ filename: str
+ data: bytes
+
+
+def _sane_dpi(image: Image.Image) -> float | None:
+ """Return the file's own DPI when it looks trustworthy.
+
+ Scanners write real values here; phone cameras and screenshots often write
+ nothing, a placeholder 1, or a bogus 72 alongside 4000px of detail.
+ """
+ dpi = image.info.get("dpi")
+ if not dpi:
+ return None
+ try:
+ horizontal = float(dpi[0])
+ except (TypeError, ValueError, IndexError):
+ return None
+ if 36.0 <= horizontal <= 1200.0:
+ return horizontal
+ return None
+
+
+def _flatten(image: Image.Image) -> Image.Image:
+ """Drop to RGB, compositing any transparency onto white.
+
+ PDF has no alpha channel here, so a transparent PNG would otherwise render
+ with a black background.
+ """
+ if image.mode in ("RGBA", "LA") or (
+ image.mode == "P" and "transparency" in image.info
+ ):
+ rgba = image.convert("RGBA")
+ canvas = Image.new("RGB", rgba.size, "white")
+ canvas.paste(rgba, mask=rgba.split()[-1])
+ return canvas
+ if image.mode != "RGB":
+ return image.convert("RGB")
+ return image
+
+
+def _decode(source: SourceImage) -> Image.Image:
+ """Decode bytes into a flattened, upright RGB image.
+
+ The format is read from the decoded bytes rather than the browser's
+ content-type header, which is attacker-controlled.
+ """
+ try:
+ with Image.open(io.BytesIO(source.data)) as opened:
+ if opened.format not in ACCEPTED_FORMATS:
+ raise ConversionError(
+ f"{source.filename}: {opened.format or 'unknown'} files aren't supported."
+ )
+ width, height = opened.size
+ if width * height > MAX_PIXELS:
+ raise ConversionError(
+ f"{source.filename}: {width}x{height} is past the "
+ f"{MAX_PIXELS // 1_000_000} megapixel limit."
+ )
+ opened.load()
+ # exif_transpose bakes in the orientation tag; phone photos are
+ # otherwise stored sideways and would come out rotated.
+ upright = ImageOps.exif_transpose(opened) or opened
+ flattened = _flatten(upright)
+ if flattened is upright and upright is opened:
+ flattened = opened.copy()
+ flattened.info = dict(opened.info)
+ return flattened
+ except ConversionError:
+ raise
+ except UnidentifiedImageError:
+ raise ConversionError(f"{source.filename} isn't an image we can read.") from None
+ except OSError as exc:
+ raise ConversionError(f"{source.filename} is damaged or incomplete.") from exc
+
+
+def _page_box(image: Image.Image, page_size: tuple[float, float] | None) -> tuple[float, float]:
+ """Page dimensions in points, oriented to match the image."""
+ width, height = image.size
+ if page_size is None:
+ dpi = _sane_dpi(image) or FIT_FALLBACK_DPI
+ return (width * 72.0 / dpi, height * 72.0 / dpi)
+ short, long = page_size
+ return (long, short) if width > height else (short, long)
+
+
+def _render(image: Image.Image, page_size: tuple[float, float] | None) -> Image.Image:
+ """Rasterise one image into its page canvas at RENDER_DPI."""
+ page_w_pt, page_h_pt = _page_box(image, page_size)
+ canvas_w = max(1, round(page_w_pt * RENDER_DPI / 72.0))
+ canvas_h = max(1, round(page_h_pt * RENDER_DPI / 72.0))
+
+ if page_size is None:
+ # The page *is* the image; only resample when the DPI assumption
+ # actually implies a different pixel count.
+ if (canvas_w, canvas_h) == image.size:
+ return image
+ return image.resize((canvas_w, canvas_h), Image.LANCZOS)
+
+ margin = round(MARGIN_PT * RENDER_DPI / 72.0)
+ box_w = max(1, canvas_w - margin * 2)
+ box_h = max(1, canvas_h - margin * 2)
+ scale = min(box_w / image.width, box_h / image.height)
+ target = (max(1, round(image.width * scale)), max(1, round(image.height * scale)))
+
+ fitted = image.resize(target, Image.LANCZOS) if target != image.size else image
+ page = Image.new("RGB", (canvas_w, canvas_h), "white")
+ page.paste(fitted, ((canvas_w - target[0]) // 2, (canvas_h - target[1]) // 2))
+ return page
+
+
+def images_to_pdf(sources: list[SourceImage], page_size: str = "fit") -> bytes:
+ """Convert images to a single PDF, one page per image, in list order."""
+ if not sources:
+ raise ConversionError("Add at least one image.")
+ if page_size not in PAGE_SIZES:
+ raise ConversionError(f"{page_size!r} isn't a page size we offer.")
+
+ box = PAGE_SIZES[page_size]
+ pages = [_render(_decode(source), box) for source in sources]
+
+ buffer = io.BytesIO()
+ first, rest = pages[0], pages[1:]
+ first.save(
+ buffer,
+ format="PDF",
+ save_all=True,
+ append_images=rest,
+ resolution=float(RENDER_DPI),
+ )
+ return buffer.getvalue()
diff --git a/app/main.py b/app/main.py
new file mode 100644
index 0000000..cdd76f4
--- /dev/null
+++ b/app/main.py
@@ -0,0 +1,125 @@
+"""MakeItPDF -- stack up to ten images into one PDF."""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from pathlib import Path
+
+from fastapi import FastAPI, File, Form, Request, UploadFile
+from fastapi.responses import HTMLResponse, JSONResponse, Response
+from fastapi.staticfiles import StaticFiles
+from fastapi.templating import Jinja2Templates
+from starlette.concurrency import run_in_threadpool
+
+from app.config import (
+ ACCEPT_ATTRIBUTE,
+ DEFAULT_PAGE_SIZE,
+ MAX_FILE_BYTES,
+ MAX_FILES,
+ MAX_TOTAL_BYTES,
+ PAGE_SIZES,
+)
+from app.converter import ConversionError, SourceImage, images_to_pdf
+
+BASE_DIR = Path(__file__).resolve().parent
+
+app = FastAPI(title="MakeItPDF", docs_url=None, redoc_url=None)
+app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
+templates = Jinja2Templates(directory=BASE_DIR / "templates")
+
+CHUNK_BYTES = 1024 * 1024
+
+
+def _template_context(request: Request) -> dict:
+ return {
+ "request": request,
+ "max_files": MAX_FILES,
+ "max_file_mb": MAX_FILE_BYTES // (1024 * 1024),
+ "max_total_mb": MAX_TOTAL_BYTES // (1024 * 1024),
+ "accept": ACCEPT_ATTRIBUTE,
+ }
+
+
+def _wants_json(request: Request) -> bool:
+ """The JS layer asks for JSON; a plain form post gets HTML back."""
+ return "application/json" in request.headers.get("accept", "")
+
+
+def _fail(request: Request, message: str, status: int = 400) -> Response:
+ if _wants_json(request):
+ return JSONResponse({"error": message}, status_code=status)
+ context = _template_context(request) | {"error": message}
+ return templates.TemplateResponse(request, "index.html", context, status_code=status)
+
+
+async def _read_capped(upload: UploadFile, budget: int) -> bytes | None:
+ """Read an upload, stopping as soon as it exceeds ``budget`` bytes.
+
+ Uploads are streamed rather than read whole so an oversized file is
+ rejected without first pulling all of it into memory.
+ """
+ chunks: list[bytes] = []
+ total = 0
+ while chunk := await upload.read(CHUNK_BYTES):
+ total += len(chunk)
+ if total > budget:
+ return None
+ chunks.append(chunk)
+ return b"".join(chunks)
+
+
[email protected]("/", response_class=HTMLResponse)
+async def index(request: Request) -> Response:
+ return templates.TemplateResponse(request, "index.html", _template_context(request))
+
+
[email protected]("/healthz")
+async def healthz() -> dict[str, str]:
+ return {"status": "ok"}
+
+
[email protected]("/convert")
+async def convert(
+ request: Request,
+ images: list[UploadFile] = File(default=[]),
+ page_size: str = Form(default=DEFAULT_PAGE_SIZE),
+) -> Response:
+ named = [f for f in images if f.filename]
+ if not named:
+ return _fail(request, "Choose at least one image.")
+ if len(named) > MAX_FILES:
+ return _fail(request, f"That's {len(named)} images. The limit is {MAX_FILES}.")
+ if page_size not in PAGE_SIZES:
+ return _fail(request, "Pick a page size from the list.")
+
+ sources: list[SourceImage] = []
+ total = 0
+ for upload in named:
+ remaining = min(MAX_FILE_BYTES, MAX_TOTAL_BYTES - total)
+ data = await _read_capped(upload, remaining)
+ if data is None:
+ limit_mb = MAX_FILE_BYTES // (1024 * 1024)
+ total_mb = MAX_TOTAL_BYTES // (1024 * 1024)
+ return _fail(
+ request,
+ f"{upload.filename} pushes the upload past its limit "
+ f"({limit_mb} MB per image, {total_mb} MB total).",
+ status=413,
+ )
+ total += len(data)
+ sources.append(SourceImage(filename=upload.filename or "image", data=data))
+
+ try:
+ pdf = await run_in_threadpool(images_to_pdf, sources, page_size)
+ except ConversionError as exc:
+ return _fail(request, str(exc))
+
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
+ return Response(
+ content=pdf,
+ media_type="application/pdf",
+ headers={
+ "Content-Disposition": f'attachment; filename="makeitpdf-{stamp}.pdf"',
+ "X-Page-Count": str(len(sources)),
+ },
+ )
diff --git a/app/static/composer.js b/app/static/composer.js
new file mode 100644
index 0000000..ec5d918
--- /dev/null
+++ b/app/static/composer.js
@@ -0,0 +1,280 @@
+/* Progressive enhancement.
+ Without this file the form still posts natively: pick files, press the
+ button, get a PDF. With it you get thumbnails, reordering, and a
+ download step that keeps you on the page. */
+
+(() => {
+ "use strict";
+
+ const form = document.getElementById("composer");
+ const dropzone = document.getElementById("dropzone");
+ const tray = document.getElementById("tray");
+ const trayCount = document.getElementById("tray-count");
+ const sheetsEl = document.getElementById("sheets");
+ const picker = document.getElementById("picker");
+ const template = document.getElementById("sheet-template");
+ const buildBtn = document.getElementById("build");
+ const clearBtn = document.getElementById("clear");
+ const progress = document.getElementById("progress");
+ const done = document.getElementById("done");
+ const doneMeta = document.getElementById("done-meta");
+ const downloadLink = document.getElementById("download");
+ const restartBtn = document.getElementById("restart");
+
+ if (!form || !template) return;
+
+ // Limits come from the server so the two layers can't drift apart.
+ const MAX_FILES = Number(form.dataset.maxFiles);
+ const MAX_FILE_BYTES = Number(form.dataset.maxFileBytes);
+ const MAX_TOTAL_BYTES = Number(form.dataset.maxTotalBytes);
+
+ /** @type {{id:number,file:File,url:string}[]} */
+ let pages = [];
+ let nextId = 1;
+ let dragId = null;
+ let busy = false;
+ let lastUrl = null;
+
+ const formatBytes = (bytes) => {
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+ };
+
+ const totalBytes = () => pages.reduce((sum, page) => sum + page.file.size, 0);
+
+ function setAlert(message) {
+ let alert = document.querySelector(".alert");
+ if (!message) {
+ if (alert) alert.remove();
+ return;
+ }
+ if (!alert) {
+ alert = document.createElement("p");
+ alert.className = "alert";
+ alert.setAttribute("role", "alert");
+ form.parentNode.insertBefore(alert, form);
+ }
+ alert.textContent = message;
+ }
+
+ /* ---- intake ---------------------------------------------------------- */
+
+ function accept(fileList) {
+ const problems = [];
+ let room = MAX_FILES - pages.length;
+ let budget = MAX_TOTAL_BYTES - totalBytes();
+
+ for (const file of Array.from(fileList)) {
+ if (room <= 0) {
+ problems.push(`${MAX_FILES} images is the limit — skipped the rest.`);
+ break;
+ }
+ if (!file.type.startsWith("image/")) {
+ problems.push(`${file.name} isn't an image.`);
+ continue;
+ }
+ if (file.size > MAX_FILE_BYTES) {
+ problems.push(`${file.name} is ${formatBytes(file.size)}, over the ${formatBytes(MAX_FILE_BYTES)} limit.`);
+ continue;
+ }
+ if (file.size > budget) {
+ problems.push(`${file.name} pushes the batch past ${formatBytes(MAX_TOTAL_BYTES)}.`);
+ continue;
+ }
+ pages.push({ id: nextId++, file, url: URL.createObjectURL(file) });
+ room -= 1;
+ budget -= file.size;
+ }
+
+ setAlert(problems.length ? problems[0] : "");
+ showComposer();
+ render();
+ }
+
+ function remove(id) {
+ const index = pages.findIndex((page) => page.id === id);
+ if (index === -1) return;
+ URL.revokeObjectURL(pages[index].url);
+ pages.splice(index, 1);
+ render();
+ }
+
+ function move(id, delta) {
+ const from = pages.findIndex((page) => page.id === id);
+ const to = from + delta;
+ if (from === -1 || to < 0 || to >= pages.length) return;
+ [pages[from], pages[to]] = [pages[to], pages[from]];
+ render();
+ const arrow = sheetsEl.querySelector(
+ `.card[data-id="${id}"] .card__arrow--${delta < 0 ? "up" : "down"}`
+ );
+ if (arrow && !arrow.disabled) arrow.focus();
+ }
+
+ function clearAll() {
+ pages.forEach((page) => URL.revokeObjectURL(page.url));
+ pages = [];
+ setAlert("");
+ render();
+ }
+
+ /* ---- render ---------------------------------------------------------- */
+
+ function render() {
+ sheetsEl.textContent = "";
+
+ pages.forEach((page, index) => {
+ const node = template.content.firstElementChild.cloneNode(true);
+ node.dataset.id = String(page.id);
+ node.querySelector(".card__num").textContent = String(index + 1);
+ node.querySelector(".card__name").textContent = page.file.name;
+ node.querySelector(".card__img").src = page.url;
+
+ const up = node.querySelector(".card__arrow--up");
+ const down = node.querySelector(".card__arrow--down");
+ up.disabled = index === 0;
+ down.disabled = index === pages.length - 1;
+ up.addEventListener("click", () => move(page.id, -1));
+ down.addEventListener("click", () => move(page.id, 1));
+ node.querySelector(".card__remove").addEventListener("click", () => remove(page.id));
+
+ node.addEventListener("dragstart", (event) => {
+ dragId = page.id;
+ node.classList.add("is-lifted");
+ event.dataTransfer.effectAllowed = "move";
+ // Firefox refuses to start a drag without payload.
+ event.dataTransfer.setData("text/plain", String(page.id));
+ });
+ node.addEventListener("dragend", () => {
+ dragId = null;
+ node.classList.remove("is-lifted");
+ sheetsEl.querySelectorAll(".is-over").forEach((el) => el.classList.remove("is-over"));
+ });
+ node.addEventListener("dragover", (event) => {
+ if (dragId === null || dragId === page.id) return;
+ event.preventDefault();
+ node.classList.add("is-over");
+ });
+ node.addEventListener("dragleave", () => node.classList.remove("is-over"));
+ node.addEventListener("drop", (event) => {
+ if (dragId === null) return;
+ event.preventDefault();
+ event.stopPropagation();
+ node.classList.remove("is-over");
+ const from = pages.findIndex((p) => p.id === dragId);
+ const to = pages.findIndex((p) => p.id === page.id);
+ if (from === -1 || to === -1 || from === to) return;
+ pages.splice(to, 0, pages.splice(from, 1)[0]);
+ render();
+ });
+
+ sheetsEl.appendChild(node);
+ });
+
+ const count = pages.length;
+ form.classList.toggle("has-pages", count > 0);
+ tray.hidden = count === 0;
+ trayCount.textContent = `${count} ${count === 1 ? "image" : "images"} · ${formatBytes(totalBytes())}`;
+ buildBtn.disabled = count === 0 || busy;
+ buildBtn.hidden = count === 0;
+ }
+
+ /* ---- view switching --------------------------------------------------- */
+
+ function showComposer() {
+ done.hidden = true;
+ form.hidden = false;
+ }
+
+ function showDone(blob, filename) {
+ if (lastUrl) URL.revokeObjectURL(lastUrl);
+ lastUrl = URL.createObjectURL(blob);
+ downloadLink.href = lastUrl;
+ downloadLink.download = filename;
+ doneMeta.textContent =
+ `${pages.length} ${pages.length === 1 ? "page" : "pages"} · ${formatBytes(blob.size)}`;
+ form.hidden = true;
+ done.hidden = false;
+ downloadLink.focus();
+ }
+
+ /* ---- wiring ---------------------------------------------------------- */
+
+ picker.addEventListener("change", () => {
+ accept(picker.files);
+ picker.value = "";
+ });
+
+ clearBtn.addEventListener("click", clearAll);
+
+ restartBtn.addEventListener("click", () => {
+ clearAll();
+ showComposer();
+ document.getElementById("picker").focus();
+ });
+
+ ["dragenter", "dragover"].forEach((type) => {
+ dropzone.addEventListener(type, (event) => {
+ if (!event.dataTransfer?.types.includes("Files")) return;
+ event.preventDefault();
+ dropzone.classList.add("is-target");
+ });
+ });
+
+ ["dragleave", "drop"].forEach((type) => {
+ dropzone.addEventListener(type, (event) => {
+ if (type === "dragleave" && dropzone.contains(event.relatedTarget)) return;
+ dropzone.classList.remove("is-target");
+ });
+ });
+
+ dropzone.addEventListener("drop", (event) => {
+ if (!event.dataTransfer?.files.length) return;
+ event.preventDefault();
+ accept(event.dataTransfer.files);
+ });
+
+ /* ---- submit ---------------------------------------------------------- */
+
+ form.addEventListener("submit", async (event) => {
+ if (!pages.length) return;
+ event.preventDefault();
+ if (busy) return;
+
+ busy = true;
+ setAlert("");
+ progress.classList.add("is-running");
+ buildBtn.disabled = true;
+ buildBtn.textContent = "Converting…";
+
+ const payload = new FormData();
+ pages.forEach((page) => payload.append("images", page.file, page.file.name));
+
+ try {
+ const response = await fetch(form.action, {
+ method: "POST",
+ body: payload,
+ headers: { Accept: "application/json" },
+ });
+
+ if (!response.ok) {
+ const body = await response.json().catch(() => ({}));
+ throw new Error(body.error || `The server returned ${response.status}.`);
+ }
+
+ const blob = await response.blob();
+ const match = (response.headers.get("Content-Disposition") || "").match(/filename="([^"]+)"/);
+ showDone(blob, match ? match[1] : "makeitpdf.pdf");
+ } catch (error) {
+ setAlert(error.message);
+ } finally {
+ busy = false;
+ progress.classList.remove("is-running");
+ buildBtn.textContent = "Convert to PDF";
+ buildBtn.disabled = pages.length === 0;
+ }
+ });
+
+ render();
+})();
diff --git a/app/static/style.css b/app/static/style.css
new file mode 100644
index 0000000..79f5b51
--- /dev/null
+++ b/app/static/style.css
@@ -0,0 +1,377 @@
+/* MakeItPDF -- white ground, one typeface, one accent.
+ The red appears in exactly two places: the logo and the button you press. */
+
+:root {
+ --bg: #ffffff;
+ --ink: #101113;
+ --muted: #6e7278;
+ --line: #e6e8eb;
+ --surface: #f7f8f9;
+ --accent: #f4402d;
+ --accent-hover: #db3524;
+ --radius: 14px;
+
+ --font: "Figtree", ui-sans-serif, system-ui, -apple-system, sans-serif;
+}
+
+*, *::before, *::after { box-sizing: border-box; }
+
+body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--ink);
+ font-family: var(--font);
+ font-size: 16px;
+ line-height: 1.55;
+ font-feature-settings: "tnum" 1;
+ -webkit-font-smoothing: antialiased;
+}
+
+h1, h2, p, ol { margin: 0; }
+ol { padding: 0; list-style: none; }
+
+/* Layout classes below set display, which would otherwise beat [hidden]. */
+[hidden] { display: none !important; }
+
+.visually-hidden {
+ position: absolute;
+ width: 1px; height: 1px;
+ margin: -1px; padding: 0;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+}
+
+:where(a, button, input, label, [tabindex]):focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+ border-radius: 6px;
+}
+
+/* ---- Logo --------------------------------------------------------------- */
+
+/* The logo is the page heading -- there is no separate title. */
+.logo {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: clamp(0.5rem, 1.6vw, 0.8rem);
+ color: var(--accent);
+ text-decoration: none;
+}
+
+.logo__mark {
+ display: block;
+ width: clamp(2.3rem, 7vw, 3.1rem);
+ height: clamp(2.3rem, 7vw, 3.1rem);
+}
+
+.logo__text {
+ color: var(--ink);
+ font-size: clamp(1.95rem, 6vw, 2.7rem);
+ font-weight: 700;
+ line-height: 1.1;
+ letter-spacing: -0.038em;
+}
+
+.logo__accent { color: var(--accent); }
+
+/* ---- Page --------------------------------------------------------------- */
+
+.page {
+ max-width: 43rem;
+ margin: 0 auto;
+ padding: clamp(3rem, 9vw, 5rem) 1.25rem 4rem;
+}
+
+.intro { text-align: center; }
+
+.intro__sub {
+ margin: 0.85rem auto 0;
+ max-width: 27rem;
+ color: var(--muted);
+ font-size: 1.06rem;
+}
+
+.alert {
+ margin-top: 1.75rem;
+ padding: 0.8rem 1rem;
+ border-radius: 10px;
+ background: color-mix(in srgb, var(--accent) 8%, var(--bg));
+ color: var(--accent-hover);
+ font-size: 0.94rem;
+ font-weight: 500;
+ text-align: center;
+}
+
+.footnote {
+ margin-top: 2.5rem;
+ color: var(--muted);
+ font-size: 0.85rem;
+ text-align: center;
+}
+
+/* ---- Buttons ------------------------------------------------------------ */
+
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.7rem 1.4rem;
+ border: 0;
+ border-radius: 10px;
+ background: var(--ink);
+ color: #fff;
+ font-family: inherit;
+ font-size: 0.98rem;
+ font-weight: 600;
+ text-decoration: none;
+ cursor: pointer;
+ transition: background 130ms ease, opacity 130ms ease;
+}
+
+.btn--accent { background: var(--accent); }
+.btn:hover:not(:disabled) { background: #2b2d31; }
+.btn--accent:hover:not(:disabled) { background: var(--accent-hover); }
+
+.btn--lg { padding: 0.85rem 1.9rem; font-size: 1.02rem; }
+
+.btn--block {
+ display: flex;
+ width: 100%;
+ margin-top: 1.75rem;
+ padding: 0.95rem 1.5rem;
+ font-size: 1.05rem;
+}
+
+.btn:disabled {
+ background: var(--surface);
+ color: var(--muted);
+ cursor: default;
+}
+
+.link {
+ padding: 0;
+ border: 0;
+ background: none;
+ color: var(--muted);
+ font-family: inherit;
+ font-size: 0.92rem;
+ font-weight: 500;
+ cursor: pointer;
+}
+
+.link:hover { color: var(--ink); }
+
+/* ---- Dropzone ----------------------------------------------------------- */
+
+/* Visible only to assistive tech and the no-JS fallback below. */
+.js .picker {
+ position: absolute;
+ width: 1px; height: 1px;
+ opacity: 0;
+}
+
+.picker { display: block; margin: 1.5rem auto 0; }
+
+.dropzone {
+ display: grid;
+ justify-items: center;
+ gap: 0.35rem;
+ margin-top: 2rem;
+ padding: clamp(2.5rem, 8vw, 4rem) 1.5rem;
+ border: 1.5px dashed var(--line);
+ border-radius: var(--radius);
+ background: var(--surface);
+ text-align: center;
+ transition: border-color 130ms ease, background 130ms ease;
+}
+
+.dropzone.is-target {
+ border-color: var(--accent);
+ background: color-mix(in srgb, var(--accent) 5%, var(--bg));
+}
+
+.dropzone__hint { margin-top: 0.9rem; color: var(--muted); font-size: 0.95rem; }
+
+.dropzone__formats {
+ margin-top: 0.15rem;
+ color: var(--muted);
+ font-size: 0.8rem;
+ opacity: 0.8;
+}
+
+/* Once images are in, the dropzone steps back to a slim bar. */
+.composer.has-pages .dropzone {
+ margin-top: 1rem;
+ padding: 1.15rem;
+ gap: 0;
+}
+
+.composer.has-pages .dropzone__hint,
+.composer.has-pages .dropzone__formats { display: none; }
+
+.composer.has-pages .btn--lg { padding: 0.6rem 1.3rem; font-size: 0.95rem; }
+
+/* ---- Tray --------------------------------------------------------------- */
+
+.tray { margin-top: 2rem; }
+
+.tray__head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 1rem;
+ margin-bottom: 0.85rem;
+}
+
+.tray__count { font-size: 1rem; font-weight: 600; }
+
+.grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr));
+ gap: 0.9rem;
+}
+
+.card { cursor: grab; }
+.card.is-lifted { opacity: 0.4; }
+
+.card__frame {
+ position: relative;
+ display: grid;
+ place-items: center;
+ aspect-ratio: 3 / 4;
+ padding: 0.5rem;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: var(--surface);
+ overflow: hidden;
+}
+
+.card.is-over .card__frame { border-color: var(--accent); }
+
+.card__img {
+ max-width: 100%;
+ max-height: 100%;
+ display: block;
+ border-radius: 2px;
+}
+
+.card__num {
+ position: absolute;
+ top: 0.4rem;
+ left: 0.4rem;
+ min-width: 1.35rem;
+ padding: 0.05rem 0.35rem;
+ border-radius: 99px;
+ background: color-mix(in srgb, var(--ink) 82%, transparent);
+ color: #fff;
+ font-size: 0.72rem;
+ font-weight: 600;
+ text-align: center;
+}
+
+.card__remove,
+.card__arrow {
+ display: grid;
+ place-items: center;
+ padding: 0;
+ border: 0;
+ border-radius: 99px;
+ background: color-mix(in srgb, var(--ink) 82%, transparent);
+ color: #fff;
+ cursor: pointer;
+ transition: background 120ms ease;
+}
+
+.card__remove {
+ position: absolute;
+ top: 0.4rem;
+ right: 0.4rem;
+ width: 1.35rem;
+ height: 1.35rem;
+}
+
+.card__remove svg { width: 0.85rem; height: 0.85rem; }
+.card__remove:hover { background: var(--accent); }
+
+.card__move {
+ position: absolute;
+ inset-inline: 0.4rem;
+ bottom: 0.4rem;
+ display: flex;
+ justify-content: space-between;
+ opacity: 0;
+ transition: opacity 120ms ease;
+}
+
+.card__arrow { width: 1.35rem; height: 1.35rem; }
+.card__arrow svg { width: 0.8rem; height: 0.8rem; }
+.card__arrow:hover:not(:disabled) { background: var(--accent); }
+.card__arrow:disabled { opacity: 0.3; cursor: default; }
+
+.card:hover .card__move,
+.card:focus-within .card__move { opacity: 1; }
+
+/* Touch has no hover, so the reorder controls stay put. */
+@media (pointer: coarse) {
+ .card__move { opacity: 1; }
+}
+
+.card__name {
+ display: block;
+ margin-top: 0.45rem;
+ color: var(--muted);
+ font-size: 0.78rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* ---- Progress ----------------------------------------------------------- */
+
+.progress {
+ position: fixed;
+ top: 0; left: 0;
+ height: 3px;
+ width: 100%;
+ background: var(--accent);
+ transform: scaleX(0);
+ transform-origin: 0 50%;
+ opacity: 0;
+ z-index: 10;
+}
+
+.progress.is-running {
+ opacity: 1;
+ animation: crawl 2.2s cubic-bezier(0.15, 0.7, 0.3, 1) infinite;
+}
+
+@keyframes crawl {
+ 0% { transform: scaleX(0); }
+ 100% { transform: scaleX(1); }
+}
+
+/* ---- Done --------------------------------------------------------------- */
+
+.done {
+ display: grid;
+ justify-items: center;
+ gap: 0.4rem;
+ margin-top: 2rem;
+ padding: clamp(2.25rem, 7vw, 3.25rem) 1.5rem;
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ text-align: center;
+}
+
+.done__tick { width: 2.6rem; height: 2.6rem; color: var(--accent); }
+.done__title { margin-top: 0.7rem; font-size: 1.4rem; font-weight: 700; letter-spacing: -0.02em; }
+.done__meta { color: var(--muted); font-size: 0.92rem; }
+.done #download, .done .btn { margin-top: 1.1rem; }
+.done .link { margin-top: 0.9rem; }
+
+@media (prefers-reduced-motion: reduce) {
+ .progress.is-running { animation: none; transform: scaleX(1); opacity: 0.55; }
+ .btn, .dropzone, .card__move, .card__remove, .card__arrow { transition: none; }
+}
diff --git a/app/templates/base.html b/app/templates/base.html
new file mode 100644
index 0000000..96fa7b0
--- /dev/null
+++ b/app/templates/base.html
@@ -0,0 +1,19 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{% block title %}MakeItPDF{% endblock %}</title>
+<meta name="description" content="Combine up to 10 images into a single PDF. Free, no account.">
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Figtree:wght@400;500;600;700&display=swap" rel="stylesheet">
+<link rel="stylesheet" href="{{ url_for('static', path='/style.css') }}">
+<script>document.documentElement.classList.add("js");</script>
+<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='9' fill='%23f4402d'/><rect x='7.5' y='5' width='11' height='15' rx='2.4' fill='%23fff' opacity='.5'/><rect x='13.5' y='11' width='11' height='15' rx='2.4' fill='%23fff' stroke='%23f4402d' stroke-width='2.2'/></svg>">
+</head>
+<body>
+{% block body %}{% endblock %}
+{% block scripts %}{% endblock %}
+</body>
+</html>
diff --git a/app/templates/index.html b/app/templates/index.html
new file mode 100644
index 0000000..6f4f6f4
--- /dev/null
+++ b/app/templates/index.html
@@ -0,0 +1,102 @@
+{% extends "base.html" %}
+
+{% block body %}
+<div class="progress" id="progress" aria-hidden="true"></div>
+
+<main class="page">
+ <div class="intro">
+ <h1 class="logo">
+ <!-- Two sheets becoming one. The front sheet is stroked in the accent so
+ the gap between them reads even at favicon size. -->
+ <svg class="logo__mark" viewBox="0 0 32 32" aria-hidden="true">
+ <rect width="32" height="32" rx="9" fill="currentColor"/>
+ <rect x="7.5" y="5" width="11" height="15" rx="2.4" fill="#fff" opacity=".5"/>
+ <rect x="13.5" y="11" width="11" height="15" rx="2.4" fill="#fff"
+ stroke="currentColor" stroke-width="2.2"/>
+ </svg>
+ <span class="logo__text">MakeIt<span class="logo__accent">PDF</span></span>
+ </h1>
+ <p class="intro__sub">Combine up to {{ max_files }} images into a single PDF.
+ Free, and nothing is stored.</p>
+ </div>
+
+ {% if error %}
+ <p class="alert" role="alert">{{ error }}</p>
+ {% endif %}
+
+ <form class="composer" id="composer" method="post" action="/convert" enctype="multipart/form-data"
+ data-max-files="{{ max_files }}"
+ data-max-file-bytes="{{ max_file_mb * 1024 * 1024 }}"
+ data-max-total-bytes="{{ max_total_mb * 1024 * 1024 }}">
+
+ <input class="picker" id="picker" type="file" name="images" multiple accept="{{ accept }}">
+
+ <div class="dropzone" id="dropzone">
+ <label class="btn btn--accent btn--lg" for="picker">Select images</label>
+ <p class="dropzone__hint">or drop them here</p>
+ <p class="dropzone__formats">JPG · PNG · WEBP · GIF · BMP · TIFF
+ · up to {{ max_file_mb }} MB each</p>
+ </div>
+
+ <section class="tray" id="tray" hidden>
+ <div class="tray__head">
+ <h2 class="tray__count" id="tray-count">0 images</h2>
+ <button class="link" id="clear" type="button">Clear all</button>
+ </div>
+ <ol class="grid" id="sheets"></ol>
+ </section>
+
+ <button class="btn btn--accent btn--block" id="build" type="submit">Convert to PDF</button>
+ </form>
+
+ <section class="done" id="done" hidden>
+ <svg class="done__tick" viewBox="0 0 48 48" aria-hidden="true">
+ <circle cx="24" cy="24" r="23" fill="none" stroke="currentColor" stroke-width="2"/>
+ <path d="M15 24.5l6.5 6.5L33 18" fill="none" stroke="currentColor"
+ stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
+ </svg>
+ <h2 class="done__title">Your PDF is ready</h2>
+ <p class="done__meta" id="done-meta"></p>
+ <a class="btn btn--accent btn--lg" id="download" download>Download PDF</a>
+ <button class="link" id="restart" type="button">Convert more images</button>
+ </section>
+
+ <p class="footnote">Images are converted on the server and discarded the moment
+ the PDF is sent.</p>
+</main>
+
+<template id="sheet-template">
+ <li class="card" draggable="true">
+ <span class="card__frame">
+ <img class="card__img" alt="">
+ <span class="card__num"></span>
+ <button class="card__remove" type="button">
+ <svg viewBox="0 0 16 16" aria-hidden="true">
+ <path d="M4.5 4.5l7 7M11.5 4.5l-7 7" stroke="currentColor"
+ stroke-width="1.75" stroke-linecap="round" fill="none"/>
+ </svg>
+ <span class="visually-hidden">Remove</span>
+ </button>
+ <span class="card__move">
+ <button class="card__arrow card__arrow--up" type="button">
+ <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M10 3.5L5.5 8l4.5 4.5"
+ stroke="currentColor" stroke-width="1.75" stroke-linecap="round"
+ stroke-linejoin="round" fill="none"/></svg>
+ <span class="visually-hidden">Move earlier</span>
+ </button>
+ <button class="card__arrow card__arrow--down" type="button">
+ <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M6 3.5L10.5 8 6 12.5"
+ stroke="currentColor" stroke-width="1.75" stroke-linecap="round"
+ stroke-linejoin="round" fill="none"/></svg>
+ <span class="visually-hidden">Move later</span>
+ </button>
+ </span>
+ </span>
+ <span class="card__name"></span>
+ </li>
+</template>
+{% endblock %}
+
+{% block scripts %}
+<script src="{{ url_for('static', path='/composer.js') }}" defer></script>
+{% endblock %}
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..05e4a4b
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,23 @@
+[project]
+name = "makeitpdf"
+version = "0.1.0"
+description = "Stack up to ten images into a single PDF"
+readme = "README.md"
+requires-python = ">=3.14"
+dependencies = [
+ "fastapi>=0.115",
+ "jinja2>=3.1",
+ "pillow>=11.0",
+ "python-multipart>=0.0.12",
+ "uvicorn[standard]>=0.32",
+]
+
+[dependency-groups]
+dev = [
+ "httpx>=0.28.1",
+ "pytest>=9.1.1",
+]
+
+[tool.pytest.ini_options]
+pythonpath = ["."]
+testpaths = ["tests"]
diff --git a/tests/test_api.py b/tests/test_api.py
new file mode 100644
index 0000000..4716aa1
--- /dev/null
+++ b/tests/test_api.py
@@ -0,0 +1,130 @@
+import io
+import re
+
+import pytest
+from fastapi.testclient import TestClient
+from PIL import Image
+
+from app.config import MAX_FILES
+from app.main import app
+
+
[email protected]
+def client():
+ with TestClient(app) as test_client:
+ yield test_client
+
+
+def image_bytes(size=(120, 160), fmt="PNG") -> bytes:
+ buffer = io.BytesIO()
+ Image.new("RGB", size, "teal").save(buffer, format=fmt)
+ return buffer.getvalue()
+
+
+def upload(name="page.png", data=None, content_type="image/png"):
+ return ("images", (name, data or image_bytes(), content_type))
+
+
+def test_index_renders_the_composer(client):
+ response = client.get("/")
+ assert response.status_code == 200
+ assert 'enctype="multipart/form-data"' in response.text
+ assert 'name="images"' in response.text
+ assert f"Combine up to {MAX_FILES} images" in response.text
+
+
+def test_index_offers_no_page_size_control(client):
+ """Page size is fixed; the form must not ship a picker for it."""
+ assert 'name="page_size"' not in client.get("/").text
+
+
+def test_healthz(client):
+ assert client.get("/healthz").json() == {"status": "ok"}
+
+
+def test_convert_returns_a_pdf_attachment(client):
+ response = client.post("/convert", files=[upload(), upload("second.png")])
+ assert response.status_code == 200
+ assert response.headers["content-type"] == "application/pdf"
+ assert response.headers["x-page-count"] == "2"
+ assert 'attachment; filename="makeitpdf-' in response.headers["content-disposition"]
+ assert response.content.startswith(b"%PDF-")
+
+
+def test_convert_accepts_the_full_batch(client):
+ files = [upload(f"page-{n}.png") for n in range(MAX_FILES)]
+ response = client.post("/convert", files=files)
+ assert response.status_code == 200
+ assert response.headers["x-page-count"] == str(MAX_FILES)
+
+
+def test_convert_rejects_more_than_the_limit(client):
+ files = [upload(f"page-{n}.png") for n in range(MAX_FILES + 1)]
+ response = client.post("/convert", files=files, headers={"accept": "application/json"})
+ assert response.status_code == 400
+ assert str(MAX_FILES) in response.json()["error"]
+
+
+def test_convert_without_files_is_rejected(client):
+ response = client.post("/convert", headers={"accept": "application/json"})
+ assert response.status_code == 400
+ assert "at least one image" in response.json()["error"]
+
+
+def test_convert_rejects_unknown_page_size(client):
+ response = client.post(
+ "/convert",
+ files=[upload()],
+ data={"page_size": "billboard"},
+ headers={"accept": "application/json"},
+ )
+ assert response.status_code == 400
+
+
+def media_box(pdf: bytes) -> tuple[int, int]:
+ box = re.search(rb"/MediaBox \[ 0 0 ([\d.]+) ([\d.]+) \]", pdf)
+ assert box is not None
+ return round(float(box[1])), round(float(box[2]))
+
+
+def test_default_page_size_is_a4(client):
+ """The form sends no page_size, so the server's default decides."""
+ response = client.post("/convert", files=[upload()])
+ assert response.status_code == 200
+ assert media_box(response.content) == (595, 842)
+
+
+def test_page_size_stays_available_over_the_api(client):
+ response = client.post("/convert", files=[upload()], data={"page_size": "letter"})
+ assert response.status_code == 200
+ assert media_box(response.content) == (612, 792)
+
+
+def test_lying_content_type_is_caught_by_decoding(client):
+ """A .png content-type on non-image bytes must not get through."""
+ response = client.post(
+ "/convert",
+ files=[upload("trojan.png", b"#!/bin/sh\necho hi")],
+ headers={"accept": "application/json"},
+ )
+ assert response.status_code == 400
+ assert "trojan.png" in response.json()["error"]
+
+
+def test_oversized_upload_is_rejected(client, monkeypatch):
+ monkeypatch.setattr("app.main.MAX_FILE_BYTES", 1024)
+ response = client.post(
+ "/convert",
+ files=[upload("big.png", image_bytes(size=(900, 900)))],
+ headers={"accept": "application/json"},
+ )
+ assert response.status_code == 413
+ assert "big.png" in response.json()["error"]
+
+
+def test_browser_form_post_gets_html_error_back(client):
+ """No-JS clients re-render the page with the message instead of JSON."""
+ response = client.post("/convert", files=[upload("notes.txt", b"nope")])
+ assert response.status_code == 400
+ assert "text/html" in response.headers["content-type"]
+ assert "notes.txt" in response.text
diff --git a/tests/test_converter.py b/tests/test_converter.py
new file mode 100644
index 0000000..6394325
--- /dev/null
+++ b/tests/test_converter.py
@@ -0,0 +1,147 @@
+import io
+
+import pytest
+from PIL import Image
+
+from app.config import MAX_PIXELS
+from app.converter import ConversionError, SourceImage, images_to_pdf
+
+
+def make_image(
+ size: tuple[int, int] = (200, 300),
+ fmt: str = "PNG",
+ mode: str = "RGB",
+ color: str | tuple = "red",
+ **save_kwargs,
+) -> bytes:
+ buffer = io.BytesIO()
+ Image.new(mode, size, color).save(buffer, format=fmt, **save_kwargs)
+ return buffer.getvalue()
+
+
+def source(name: str = "page.png", **kwargs) -> SourceImage:
+ return SourceImage(filename=name, data=make_image(**kwargs))
+
+
+def page_boxes(pdf: bytes) -> list[tuple[float, float]]:
+ """Pull MediaBox dimensions out of the generated PDF."""
+ boxes = []
+ for chunk in pdf.split(b"/MediaBox [ ")[1:]:
+ numbers = chunk.split(b"]")[0].split()
+ boxes.append((float(numbers[2]), float(numbers[3])))
+ return boxes
+
+
+def test_single_image_makes_one_page():
+ pdf = images_to_pdf([source()])
+ assert pdf.startswith(b"%PDF-")
+ assert len(page_boxes(pdf)) == 1
+
+
+def test_page_count_matches_input_order():
+ sources = [source(f"page-{n}.png") for n in range(5)]
+ assert len(page_boxes(images_to_pdf(sources))) == 5
+
+
+def test_fit_uses_fallback_density_for_dpi_less_images():
+ # 300x600px at the 150 DPI fallback is a 2x4in page = 144x288pt.
+ pdf = images_to_pdf([source(size=(300, 600))])
+ width, height = page_boxes(pdf)[0]
+ assert round(width) == 144
+ assert round(height) == 288
+
+
+def test_fit_honours_embedded_dpi():
+ data = make_image(size=(300, 600), dpi=(300, 300))
+ pdf = images_to_pdf([SourceImage("scan.png", data)])
+ width, height = page_boxes(pdf)[0]
+ assert round(width) == 72 # 300px at 300 DPI = 1in
+ assert round(height) == 144
+
+
+def test_fit_ignores_implausible_dpi():
+ data = make_image(size=(300, 600), dpi=(1, 1))
+ pdf = images_to_pdf([SourceImage("odd.png", data)])
+ assert round(page_boxes(pdf)[0][0]) == 144 # falls back to 150 DPI
+
+
[email protected](
+ ("page_size", "expected"),
+ [("a4", (595, 842)), ("letter", (612, 792))],
+)
+def test_fixed_page_sizes(page_size, expected):
+ pdf = images_to_pdf([source(size=(200, 400))], page_size=page_size)
+ width, height = page_boxes(pdf)[0]
+ assert (round(width), round(height)) == expected
+
+
+def test_landscape_image_gets_landscape_page():
+ pdf = images_to_pdf([source(size=(800, 400))], page_size="a4")
+ width, height = page_boxes(pdf)[0]
+ assert width > height
+ assert (round(width), round(height)) == (842, 595)
+
+
+def test_mixed_orientations_keep_their_own_pages():
+ sources = [source("tall.png", size=(400, 800)), source("wide.png", size=(800, 400))]
+ boxes = page_boxes(images_to_pdf(sources, page_size="letter"))
+ assert (round(boxes[0][0]), round(boxes[0][1])) == (612, 792)
+ assert (round(boxes[1][0]), round(boxes[1][1])) == (792, 612)
+
+
+def test_transparency_is_flattened_onto_white():
+ data = make_image(mode="RGBA", color=(0, 0, 0, 0))
+ pdf = images_to_pdf([SourceImage("clear.png", data)])
+ assert pdf.startswith(b"%PDF-")
+
+
[email protected]("fmt", ["PNG", "JPEG", "WEBP", "GIF", "BMP", "TIFF"])
+def test_accepted_formats(fmt):
+ mode = "P" if fmt == "GIF" else "RGB"
+ pdf = images_to_pdf([SourceImage(f"a.{fmt.lower()}", make_image(fmt=fmt, mode=mode))])
+ assert pdf.startswith(b"%PDF-")
+
+
+def test_empty_batch_is_rejected():
+ with pytest.raises(ConversionError, match="at least one image"):
+ images_to_pdf([])
+
+
+def test_unknown_page_size_is_rejected():
+ with pytest.raises(ConversionError, match="page size"):
+ images_to_pdf([source()], page_size="a3")
+
+
+def test_non_image_bytes_are_rejected_by_name():
+ with pytest.raises(ConversionError, match="notes.txt"):
+ images_to_pdf([SourceImage("notes.txt", b"just some text, not an image")])
+
+
+def test_truncated_image_is_rejected():
+ broken = make_image()[:60]
+ with pytest.raises(ConversionError):
+ images_to_pdf([SourceImage("cut.png", broken)])
+
+
+def test_oversized_image_is_rejected(monkeypatch):
+ monkeypatch.setattr("app.converter.MAX_PIXELS", 1000)
+ with pytest.raises(ConversionError, match="megapixel"):
+ images_to_pdf([source(size=(100, 100))])
+
+
+def test_pixel_guard_matches_config():
+ assert MAX_PIXELS > 0
+
+
+def test_exif_orientation_is_applied():
+ # Orientation 6 means "rotate 90° CW to display": a 400x200 stored image
+ # should come out 200x400 on the page.
+ buffer = io.BytesIO()
+ image = Image.new("RGB", (400, 200), "blue")
+ exif = image.getexif()
+ exif[274] = 6
+ image.save(buffer, format="JPEG", exif=exif)
+
+ pdf = images_to_pdf([SourceImage("rotated.jpg", buffer.getvalue())])
+ width, height = page_boxes(pdf)[0]
+ assert height > width
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 0000000..7d56652
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,596 @@
+version = 1
+revision = 1
+requires-python = ">=3.14"
+
+[[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 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302 },
+]
+
+[[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 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427 },
+]
+
+[[package]]
+name = "anyio"
+version = "4.14.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813 },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.7.22"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983 },
+]
+
+[[package]]
+name = "click"
+version = "8.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243 },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
+]
+
+[[package]]
+name = "fastapi"
+version = "0.141.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "annotated-doc" },
+ { name = "pydantic" },
+ { name = "starlette" },
+ { name = "typing-extensions" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954 },
+]
+
+[[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 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 },
+]
+
+[[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 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 },
+]
+
+[[package]]
+name = "httptools"
+version = "0.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183 },
+ { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079 },
+ { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596 },
+ { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865 },
+ { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189 },
+ { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610 },
+ { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705 },
+ { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023 },
+ { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405 },
+ { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497 },
+ { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585 },
+ { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297 },
+ { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535 },
+ { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209 },
+]
+
+[[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 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 },
+]
+
+[[package]]
+name = "idna"
+version = "3.18"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455 },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 },
+]
+
+[[package]]
+name = "jinja2"
+version = "3.1.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 },
+]
+
+[[package]]
+name = "makeitpdf"
+version = "0.1.0"
+source = { virtual = "." }
+dependencies = [
+ { name = "fastapi" },
+ { name = "jinja2" },
+ { name = "pillow" },
+ { name = "python-multipart" },
+ { name = "uvicorn", extra = ["standard"] },
+]
+
+[package.dev-dependencies]
+dev = [
+ { name = "httpx" },
+ { name = "pytest" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "fastapi", specifier = ">=0.115" },
+ { name = "jinja2", specifier = ">=3.1" },
+ { name = "pillow", specifier = ">=11.0" },
+ { name = "python-multipart", specifier = ">=0.0.12" },
+ { name = "uvicorn", extras = ["standard"], specifier = ">=0.32" },
+]
+
+[package.metadata.requires-dev]
+dev = [
+ { name = "httpx", specifier = ">=0.28.1" },
+ { name = "pytest", specifier = ">=9.1.1" },
+]
+
+[[package]]
+name = "markupsafe"
+version = "3.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619 },
+ { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029 },
+ { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408 },
+ { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005 },
+ { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048 },
+ { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821 },
+ { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606 },
+ { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043 },
+ { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747 },
+ { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341 },
+ { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073 },
+ { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661 },
+ { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069 },
+ { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670 },
+ { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598 },
+ { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261 },
+ { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835 },
+ { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733 },
+ { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672 },
+ { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819 },
+ { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426 },
+ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146 },
+]
+
+[[package]]
+name = "packaging"
+version = "26.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956 },
+]
+
+[[package]]
+name = "pillow"
+version = "12.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736 },
+ { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435 },
+ { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262 },
+ { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344 },
+ { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131 },
+ { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757 },
+ { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962 },
+ { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171 },
+ { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116 },
+ { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209 },
+ { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707 },
+ { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995 },
+ { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503 },
+ { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956 },
+ { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855 },
+ { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642 },
+ { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281 },
+ { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716 },
+ { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125 },
+ { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939 },
+ { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506 },
+ { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063 },
+ { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549 },
+ { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331 },
+ { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370 },
+ { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147 },
+ { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659 },
+ { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439 },
+ { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577 },
+ { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394 },
+ { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375 },
+ { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048 },
+ { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006 },
+ { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509 },
+ { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167 },
+ { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237 },
+ { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047 },
+ { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440 },
+ { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895 },
+ { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384 },
+ { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537 },
+ { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491 },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 },
+]
+
+[[package]]
+name = "pydantic"
+version = "2.13.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "annotated-types" },
+ { name = "pydantic-core" },
+ { name = "typing-extensions" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262 },
+]
+
+[[package]]
+name = "pydantic-core"
+version = "2.46.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079 },
+ { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179 },
+ { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926 },
+ { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785 },
+ { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733 },
+ { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534 },
+ { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732 },
+ { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627 },
+ { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141 },
+ { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325 },
+ { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990 },
+ { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978 },
+ { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354 },
+ { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238 },
+ { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251 },
+ { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593 },
+ { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226 },
+ { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605 },
+ { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777 },
+ { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641 },
+ { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404 },
+ { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219 },
+ { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594 },
+ { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542 },
+ { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146 },
+ { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309 },
+ { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736 },
+ { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575 },
+ { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624 },
+ { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325 },
+]
+
+[[package]]
+name = "pygments"
+version = "2.20.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 },
+]
+
+[[package]]
+name = "pytest"
+version = "9.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536 },
+]
+
+[[package]]
+name = "python-dotenv"
+version = "1.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101 },
+]
+
+[[package]]
+name = "python-multipart"
+version = "0.0.32"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042 },
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814 },
+ { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809 },
+ { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454 },
+ { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355 },
+ { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175 },
+ { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228 },
+ { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194 },
+ { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 },
+ { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 },
+ { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108 },
+ { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641 },
+ { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901 },
+ { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132 },
+ { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261 },
+ { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272 },
+ { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923 },
+ { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 },
+ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 },
+]
+
+[[package]]
+name = "starlette"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969 },
+]
+
+[[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 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571 },
+]
+
+[[package]]
+name = "typing-inspection"
+version = "0.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 },
+]
+
+[[package]]
+name = "uvicorn"
+version = "0.52.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/03/18/ccce41535dee1be77735592bd19965f3972c82e07ee703d324709496b716/uvicorn-0.52.1.tar.gz", hash = "sha256:112ec661814189acbccd3f7b86460147cc065fc92c0821afa78918780e4354dd", size = 100571 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/d5/68e6e9bca63c0badf67002890a46d3784c958de45b65e1275ec583ca1f06/uvicorn-0.52.1-py3-none-any.whl", hash = "sha256:e4403f9d93188cf9d1088e9f40e3acd12630e2df8675316704379a7fc20fff6a", size = 79859 },
+]
+
+[package.optional-dependencies]
+standard = [
+ { name = "httptools" },
+ { name = "python-dotenv" },
+ { name = "pyyaml" },
+ { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
+ { name = "watchfiles" },
+ { name = "websockets" },
+]
+
+[[package]]
+name = "uvloop"
+version = "0.22.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067 },
+ { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423 },
+ { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437 },
+ { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101 },
+ { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158 },
+ { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360 },
+ { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790 },
+ { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783 },
+ { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548 },
+ { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065 },
+ { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384 },
+ { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730 },
+]
+
+[[package]]
+name = "watchfiles"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205 },
+ { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508 },
+ { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448 },
+ { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605 },
+ { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757 },
+ { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672 },
+ { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197 },
+ { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181 },
+ { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109 },
+ { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653 },
+ { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838 },
+ { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108 },
+ { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441 },
+ { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684 },
+ { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857 },
+ { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413 },
+ { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409 },
+ { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827 },
+ { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104 },
+ { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360 },
+ { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644 },
+ { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771 },
+ { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494 },
+ { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383 },
+ { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093 },
+ { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109 },
+ { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167 },
+ { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372 },
+ { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596 },
+ { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869 },
+ { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641 },
+ { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444 },
+ { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593 },
+ { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096 },
+ { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638 },
+ { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684 },
+]
+
+[[package]]
+name = "websockets"
+version = "17.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f7/96/e01084f83a64bcb3a27994bd0cb0db68ff29d9c6707fae37ec19b18ba990/websockets-17.0.1.tar.gz", hash = "sha256:5baa9bc0dfbae8c507e51c8cf1b6d4628086f7a87bbd3a9952bd5f035451f1cc", size = 183298 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/98/dc/cadab608924ac605647031472fb1f8792d7d4ea07565ba1899ec42028e0d/websockets-17.0.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:53b90c00bc6201ab6695c7ff51a04d0e425514c37515e9eeecd2c1b978ac6c0e", size = 212640 },
+ { url = "https://files.pythonhosted.org/packages/16/7a/b034d13ca181211bbd58bb50835cb196a7784cd505b5a2079d4d03374f9f/websockets-17.0.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5f33a649bfcb8312524173cc4bbafa7dbb236e18eee9aa31a1d324ca0ddda28c", size = 210332 },
+ { url = "https://files.pythonhosted.org/packages/2f/4d/943ede39b53744768edf1ed84a3f9401527388228a3d6c1249c02c3d6bd7/websockets-17.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cddc675ec31bca65473321f9a9794e488b43b3b8de5d02c8ef4810c5d5792163", size = 210546 },
+ { url = "https://files.pythonhosted.org/packages/cf/e3/88dc159d2ae66743c669443246243f28d873b0c5e58271b8cc1ca0440334/websockets-17.0.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b3ff0ad440ad52dda64138f16895f66403f40192365e39b1010e889f289746b0", size = 219928 },
+ { url = "https://files.pythonhosted.org/packages/fe/f2/ff27eaefa15851a5cf7f004ab827a022bf2d6632cb520f89cb100db7e84b/websockets-17.0.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:72d7f2a5aeb4e82daa4ee18f125b4277f427033359be5c745ad709608446cc2c", size = 220279 },
+ { url = "https://files.pythonhosted.org/packages/19/2e/a5166149f363d2449c1cb2dde6486a245521979509d53b87a09f3e79662b/websockets-17.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6fd88365da261c53d3e943fb37e0d0721b9cde119f6b2e3fc84369b6ab234d63", size = 221525 },
+ { url = "https://files.pythonhosted.org/packages/9b/b5/f46931269b3ff3bde65d27c65ddb22f9bb8ce92ac2c6c4df0910128f6219/websockets-17.0.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ab9f962a5b64a5c3c845d556b7dc4e6fb683f7b67179f8205e814bb2e0213ffe", size = 222897 },
+ { url = "https://files.pythonhosted.org/packages/42/f4/deccf3439f35df953ec35e13fe07986821c5f1ab5785d69614283bdb9034/websockets-17.0.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8c07f145d0b9e90cbd96035f31fb79199aef4da1872854e36ebeb258e3d57594", size = 222129 },
+ { url = "https://files.pythonhosted.org/packages/35/a5/e1b57a59da92ade37fd021567a17b518ea8267b28e5530075844cdb525fe/websockets-17.0.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9f7747d3daa41a11f25f7cca5dc988fc51da97b311bed4c9d843860f79779283", size = 220875 },
+ { url = "https://files.pythonhosted.org/packages/f0/30/e7d0889c790a854156de424575fd67af79ddbaed9ff3157ae863dfd1c1dc/websockets-17.0.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2abb1ba0a5133b7d2ef3c1c9f4b0c1e8a101012dce0b594ab2b2888d9a64820e", size = 218160 },
+ { url = "https://files.pythonhosted.org/packages/09/2e/43db785d6ed9ae7594fae7b62bbc9cb4dfee2b015e06a1005f1e5ce283b6/websockets-17.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f3fd9a1f87f8f0f3f8e9f9bd0195f7516562d13f5b178db8c5784d1f60b60bed", size = 220951 },
+ { url = "https://files.pythonhosted.org/packages/eb/f5/4ac3cab3d5e8a830657a822f64a8910e3803229c6783e59c3fd9a3487427/websockets-17.0.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2bc14b481e05e331811108daa1aeb41a5e237a5564ef2f02ec5a356a0f102f78", size = 219460 },
+ { url = "https://files.pythonhosted.org/packages/03/0e/c3a4020673ffc17c82cf1a467835038a196a555d3b4f2a50f0f063cf8ccc/websockets-17.0.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:57d2ee9b24b404ce75f3814f92073c0ed88106c950148d2427fe8d25ca254d1f", size = 220248 },
+ { url = "https://files.pythonhosted.org/packages/7d/87/e47a6a278cc1dfade38444c893ce18322943c25d4b780a74450d9d164be1/websockets-17.0.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1b363bfd72a52c0658a3154a4cff219f15a474b35a235057d38853bf151acce7", size = 221421 },
+ { url = "https://files.pythonhosted.org/packages/da/8f/473d5fc4e3836e375b0233c6ef26777e6e5e3f7bfc84ccd524eae4090ed5/websockets-17.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:10b1587c599fa0f2c89154587c80e0fda98ade6c9fa8c0260a2823fb1800b685", size = 218975 },
+ { url = "https://files.pythonhosted.org/packages/d4/b9/819ec2dcdf69031d7e9cab11247f3a6ff9bbc8c7c53ada1dbcb9055b227b/websockets-17.0.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d7d72843691f50b91127c50688df10cb72ec6f4c4b1d7e2c11ab33b16acf8e51", size = 219925 },
+ { url = "https://files.pythonhosted.org/packages/85/b9/6c0da301f6118502e079cf92f4e864adf28e56b3f8c0f6085076ced7b876/websockets-17.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90973a3a00f23afdfd1c9b06fb84289bf0220f247ef8a62501a1967c7af54f7b", size = 220218 },
+ { url = "https://files.pythonhosted.org/packages/55/f9/cba32dc9dd856565263d6272f594255bd0e2781deb8cd982c026a54760ad/websockets-17.0.1-cp314-cp314-win32.whl", hash = "sha256:599b03beb77633bffc095334338fad79cafc2b01fbd58953838130a9ae967d7b", size = 212626 },
+ { url = "https://files.pythonhosted.org/packages/fa/95/91cdd8c192287d7ea741f37cf7d64fdc1a14410f06f73805e428a1a590af/websockets-17.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:81ce19c6046ace11da7001781be7317bb1dc389f399af4b2ed962190f76f9add", size = 212969 },
+ { url = "https://files.pythonhosted.org/packages/8e/fd/8c98a1e431960661c5769ab1a4dd66494e87ab02d791cc79e51e0d9a289f/websockets-17.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:efe0ae052a8d023b87198921e8a7ce1dc7768816bcd2fbc20df171ac73a04891", size = 212850 },
+ { url = "https://files.pythonhosted.org/packages/13/c1/142f5186ee7dc3beee0426b998a79e223e067b7689afcaa95890d64aa800/websockets-17.0.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ab56439c9f74c52770690c7b2f616b3bf775cb3920453ee355ac765c032d8bbf", size = 212967 },
+ { url = "https://files.pythonhosted.org/packages/02/de/4b03ed316c9dee180365286c298219809ff247be39beeaaf9958b21167ab/websockets-17.0.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:20a92f78ac8250984ed459faa9ca48c285adbfc0038ddc3fdac6046990a9c9ed", size = 210504 },
+ { url = "https://files.pythonhosted.org/packages/cd/d8/ad2b3e8f867e1e8cac3077e2f33ffb60b71bd763d6cfc71bd916f113c3bf/websockets-17.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6a434e59962a4fb9016bea327e1d14d6cd67670ecfb8942b4f4a0c24036634ce", size = 210702 },
+ { url = "https://files.pythonhosted.org/packages/48/18/7a77a82ce9d6f831c07b176da3942f7e71acd0f115f3ecdb1d00a040eb01/websockets-17.0.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2503c7e2a5049a12d5dac917a46d5d52591283a766165b8176bb167560421b38", size = 220290 },
+ { url = "https://files.pythonhosted.org/packages/f5/af/43c3e3c3ea7ba4693c2181743f3221957df28bededadcbd9fc8a0661bde0/websockets-17.0.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:28012a54510fe8301bb893ef143cec30a2780a2d3bc20b7bbdf4379d7a63945d", size = 220573 },
+ { url = "https://files.pythonhosted.org/packages/1f/bd/ed48eca15725743ee7e2dc172e15c61de29e85ec98dace7b14257f366836/websockets-17.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22bd00f8bae2bccdb5dbe41e20f58ba44ca9fff0b4b561aaf39099c35da762ed", size = 221747 },
+ { url = "https://files.pythonhosted.org/packages/99/50/838deb7937a8225c4925dd4a977eafea473fabf444178a99de0bc7e92bb0/websockets-17.0.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e98ec9ec61cce5bc4b8b218322ad090b0994eb060bb04da704c62ef0a3d864e6", size = 223891 },
+ { url = "https://files.pythonhosted.org/packages/f8/e9/657fb70c6eb6bcd01adfa5d2b06496e9911e1c1a8813d353b8c00f7591cd/websockets-17.0.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e387adb0c692c6b5571bdeafc8ac9d1901ea30f10309134780b16ecd35e6605", size = 222317 },
+ { url = "https://files.pythonhosted.org/packages/07/4c/82cb722afa5428fed981331210c4c07600570db01bb1620579f655b5adaf/websockets-17.0.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd1470d2c53fe53269bf5619da7725d30dd9b9693f1689f7a85eab8dea734442", size = 221047 },
+ { url = "https://files.pythonhosted.org/packages/90/84/bd6d67d6bc65f0de0cb50de55dab42f256a9876a351c4736522eb168fda0/websockets-17.0.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:884af729b8ab50486acd94d9768c2b60914bf39b579ebba0a5cb73bfdfd61fd2", size = 218626 },
+ { url = "https://files.pythonhosted.org/packages/96/cb/6a372c8553976f0d8f97f5115826ed47e34b3be6b8bf0d0249af249a7416/websockets-17.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a60fa1a25cca1bcc2bf87b8d6be37a741f0a3239fb5e9cfb7a37173b68ffcf87", size = 221299 },
+ { url = "https://files.pythonhosted.org/packages/bc/31/f966e8472337974f74d788b3ef6c6f3b8b9a5f201efd16a843c91d269fa5/websockets-17.0.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:e8208f2729cba030ff872a92064c97584eeb9502f53d32a05a0f05d5a17ca6c6", size = 219789 },
+ { url = "https://files.pythonhosted.org/packages/57/34/404e83a6cc7b0efcac810b7041bffd72ff76900e6fd0aa45a26c92fb2ffe/websockets-17.0.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:54cdcaa56f5d3eafd57058f0fa4a3de93a310b43a3c4699f06efc4c0bd054a5a", size = 220678 },
+ { url = "https://files.pythonhosted.org/packages/e5/70/8946188c2a68d67251859b589a3634918cf7867bf0b891347a5ecaa43d30/websockets-17.0.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4d41c0a1d47a478bc432b3b9068097bee1ce0c5b19327ea6f75c2ab34ab1f2fb", size = 221697 },
+ { url = "https://files.pythonhosted.org/packages/04/16/ee73fc2083a2938ac6209f4ec804960496835b20a0068dbcfe8424957c04/websockets-17.0.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f991247276797d0c61ab7770bc9791eadc16f683b4d83517f624932adc1a8bab", size = 219390 },
+ { url = "https://files.pythonhosted.org/packages/94/6a/d5f88033c69932af6cdaa72da62516ade47c257e3bf69f4c0ba5f40e12a2/websockets-17.0.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:733e3cc7171fa1b899edbe725ef9382d0e960657dc1fd933f3281ae910c01dab", size = 220161 },
+ { url = "https://files.pythonhosted.org/packages/e1/2e/6183dd2c0370287ecf4afe0bb33aca364208e5e7b0e1a286adcaecc0c78b/websockets-17.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:810cb3fb5fa6e447216f4e82d9a85cb8aed0929ae3538153ddfe8a6e3121a58d", size = 220591 },
+ { url = "https://files.pythonhosted.org/packages/26/93/70f6516d85b9744f7eac224c4b1b9ef4e84133f80b53be02080cb1c3e663/websockets-17.0.1-cp314-cp314t-win32.whl", hash = "sha256:17ac37716c0244e82c9e384c41653c090b1864c6610224ca3857e7f7b58fce10", size = 212755 },
+ { url = "https://files.pythonhosted.org/packages/f1/2c/9d9c1da5a7ea9af307b386d25f64d1dead4729644198d2b92e36db5dfd41/websockets-17.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bb31f42ea095ea826463c770829aa188a86c9a5c976b1467cbbf583c811de833", size = 213094 },
+ { url = "https://files.pythonhosted.org/packages/5b/24/a585e7573e128070605d003b5544729bcd58d9756c7e99d550818ca4b916/websockets-17.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dbfae8e75b342e31fc6fd1a8bbb393b7cbb91d6cfd581650300a94381e7b7e2b", size = 213009 },
+ { url = "https://files.pythonhosted.org/packages/09/ce/3929538b2b9918f5eee623fbf3346893973191f6df93f19bbda097bd7bb7/websockets-17.0.1-py3-none-any.whl", hash = "sha256:c6be9cba65c65cc76dfa3d4619e359ff02a4476c74e179b215236c11a0b32345", size = 206718 },
+]