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:
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:
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¶
examples/uploads.pyfor a runnable upload demo.- Forms for URL-encoded and model-backed form parameters.
- Requests & responses for query, header, cookie, and body parameter patterns.