Skip to content

Uploads

Most hunts leave tracks: images, logs, CSVs, archives. VelociPy receives them through standard multipart/form-data requests using the UploadFile parameter. Small bodies stay in memory; large ones stream to disk so a single upload cannot consume all available RAM.

File uploads require the multipart extra. Install it before using UploadFile:

pip install "velocipy[multipart]"

If python-multipart is missing, VelociPy raises a clear runtime error when a multipart request arrives.

Basic single-file upload

Declare an UploadFile parameter with the field name you expect from the client:

from typing import Any

from velocipy import UploadFile, VelociPy

app = VelociPy()


@app.post("/upload")
async def upload(file: UploadFile) -> dict[str, Any]:
    content = await file.read()
    return {
        "filename": file.filename,
        "content_type": file.content_type,
        "size": len(content),
    }

Send a file with the same field name:

curl -F "[email protected]" http://localhost:8000/upload

UploadFile exposes the metadata sent by the client:

Field Type Description
filename str Original filename from the filename part option.
content_type str Value of the part's Content-Type header, if any.
size int \| None Number of bytes received, if known during parsing.
headers dict[str, str] All headers from the upload part.

Optional uploads

Make a file optional by giving it a default of None:

from typing import Any

from velocipy import UploadFile, VelociPy

app = VelociPy()


@app.post("/optional")
async def optional_upload(file: UploadFile | None = None) -> dict[str, Any]:
    if file is None:
        return {"file": None}
    content = await file.read()
    return {"filename": file.filename, "size": len(content)}

A request without a file field returns {"file": None}. A request with the field behaves like the basic upload example.

Mixed form fields and files

A multipart body can carry both files and ordinary text fields. Use a Request parameter and read the full form when you need the extra fields:

from typing import Any

from velocipy import Request, UploadFile, VelociPy

app = VelociPy()


@app.post("/profile")
async def update_profile(
    avatar: UploadFile,
    request: Request,
) -> dict[str, Any]:
    form = await request.form()
    username = form.get("username", "")
    content = await avatar.read()
    return {
        "username": username,
        "avatar": avatar.filename,
        "size": len(content),
    }

Send both parts together:

curl -F "username=alice" -F "[email protected]" http://localhost:8000/profile

Multiple files

The UploadFile parameter binds the first file for a given field name. To receive several files with the same field name, read the full form and use getlist:

from typing import Any

from velocipy import Request, UploadFile, VelociPy

app = VelociPy()


@app.post("/batch")
async def batch_upload(request: Request) -> dict[str, Any]:
    files = (await request.form()).getlist("files")
    return {
        "count": len(files),
        "names": [f.filename for f in files if isinstance(f, UploadFile)],
    }
curl -F "[email protected]" -F "[email protected]" http://localhost:8000/batch

Streaming and memory thresholds

By default, VelociPy buffers multipart bodies in memory up to multipart_max_memory_size (1 MiB). Bodies larger than the threshold are streamed to a temporary disk file. Configure the threshold when you create the app:

# Stream uploads larger than 5 MiB to disk.
app = VelociPy(multipart_max_memory_size=5 * 1024 * 1024)

The threshold applies per request, not per file. A 10 MiB request with ten 1 MiB files stays in memory with the default setting; a single 2 MiB file streams to disk. Choose a value that fits your expected traffic and host memory.

UploadFile.read() works the same whether the underlying file is in memory or on disk.

Validation and safe handling

Never trust filenames or content types from the client. Validate uploads before using them:

from pathlib import Path
from typing import Any

from velocipy import UploadFile, VelociPy
from velocipy.status import HTTP_400_BAD_REQUEST
from velocipy.exceptions import HTTPException

app = VelociPy()

ALLOWED_TYPES = {"image/png", "image/jpeg"}
MAX_SIZE = 5 * 1024 * 1024


@app.post("/avatar")
async def upload_avatar(file: UploadFile) -> dict[str, Any]:
    if file.content_type not in ALLOWED_TYPES:
        raise HTTPException(
            status_code=HTTP_400_BAD_REQUEST,
            detail="Only PNG and JPEG images are allowed.",
        )

    content = await file.read()
    if len(content) > MAX_SIZE:
        raise HTTPException(
            status_code=HTTP_400_BAD_REQUEST,
            detail="File exceeds 5 MiB.",
        )

    return {"filename": file.filename, "size": len(content)}

Close uploaded files explicitly when you are done if you keep them open past the handler:

await file.close()

Saving uploads to disk

Read the file in chunks and write it to a safe location. Avoid using the raw filename as a path:

import shutil
from pathlib import Path
from typing import Any

from velocipy import UploadFile, VelociPy

app = VelociPy()
UPLOAD_DIR = Path("./uploads")
UPLOAD_DIR.mkdir(exist_ok=True)


@app.post("/save")
async def save_upload(file: UploadFile) -> dict[str, Any]:
    target = UPLOAD_DIR / Path(file.filename).name
    with target.open("wb") as buffer:
        shutil.copyfileobj(file.file, buffer)
    await file.close()
    return {"saved": target.name, "size": target.stat().st_size}

Testing uploads

Use the built-in test clients with the files argument:

import pytest

from velocipy import UploadFile, VelociPy
from velocipy.testing import TestClient

app = VelociPy()


@app.post("/upload")
async def upload(file: UploadFile) -> dict[str, str]:
    content = await file.read()
    return {"filename": file.filename, "size": str(len(content))}


def test_upload() -> None:
    with TestClient(app) as client:
        response = client.post(
            "/upload",
            files={"file": ("test.txt", b"hello world", "text/plain")},
        )
        assert response.status_code == 200
        assert response.json() == {
            "filename": "test.txt",
            "size": "11",
        }

For optional files, send an empty form body. For mixed forms, pass both data and files:

response = client.post(
    "/profile",
    data={"username": "alice"},
    files={"avatar": ("alice.png", b"\x89PNG", "image/png")},
)

Testing basics

See Testing for async tests, RSGI-interface tests, headers, cookies, the QUERY method, and dependency overrides.

OpenAPI

Routes that declare UploadFile parameters are documented automatically as multipart/form-data operations. The generated schema marks the parameter as a binary file, and required uploads appear as required request fields.

At a glance

Pattern How
Required single file file: UploadFile
Optional file file: UploadFile \| None = None
Mixed form fields UploadFile + request.form()
Multiple files (await request.form()).getlist("files")
Stream large bodies Set multipart_max_memory_size on the app
Validate uploads Check content_type, size, and filename
Test uploads client.post(..., files={"file": (name, data, type)})

See also