Starlette multipart form uploads parsed with python-multipart, tested offline: working versions (tested 2026-10-15)
Parse multipart text fields and file uploads in Starlette with python-multipart, then exercise the app fully in-process through httpx.ASGITransport with no network.
passed5 passed · 0 failed2026-10-15 18:10 UTC- Status
- passed
- Last passed
- 2026-10-15 18:10 UTC
- Tests
- 5 / 5
- Language
- Python
- Runtime
- Python 3.13.16
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt .venv/bin/python -m pytest -q
Python 3.13.16 linux 6.17.0-1022-azure x64 runner: github-actions 38783276983
The code
Exactly the files that passed. Your agent can pull them with get_recipe("python-multipart-starlette-forms") (install), or see them on GitHub.
python-multipart==0.0.32 starlette==1.8.0 httpx==0.28.1 pytest==9.1.1 pytest-asyncio==1.4.0
from __future__ import annotations
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.routing import Route
async def inspect_upload(request: Request) -> JSONResponse:
# request.form() is an async context manager, so the UploadFile handles are closed on exit.
async with request.form() as form:
files = form.getlist("files")
contents = [await item.read() for item in files]
return JSONResponse(
{
"first": files[0].filename if files else None,
"names": [item.filename for item in files],
"types": [item.content_type for item in files],
"sizes": [item.size for item in files],
"texts": [data.decode() for data in contents],
"tags": form.getlist("tags"),
"note": form.get("note"),
}
)
async def digest(request: Request) -> JSONResponse:
form = await request.form()
item = form["files"]
# read() advances the underlying SpooledTemporaryFile, so read again only after seek(0).
head = await item.read(4)
rest = await item.read()
await item.seek(0)
whole = await item.read()
return JSONResponse({"head": head.decode(), "rest": rest.decode(), "whole": whole.decode()})
async def capped(request: Request) -> JSONResponse:
# A MultiPartException raised inside a routed request turns into an HTTP 400.
form = await request.form(max_files=1)
return JSONResponse({"count": len(form.getlist("files"))})
app = Starlette(
routes=[
Route("/inspect", inspect_upload, methods=["POST"]),
Route("/digest", digest, methods=["POST"]),
Route("/capped", capped, methods=["POST"]),
]
)
import httpx
import pytest
from app import app
@pytest.fixture
async def client():
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as c:
yield c
async def test_single_upload_is_parsed(client):
r = await client.post(
"/inspect",
data={"note": "hi"},
files={"files": ("a.txt", b"hello", "text/plain")},
)
assert r.status_code == 200
body = r.json()
assert body["first"] == "a.txt"
assert body["names"] == ["a.txt"]
assert body["types"] == ["text/plain"]
assert body["sizes"] == [5]
assert body["texts"] == ["hello"]
assert body["note"] == "hi"
async def test_repeated_file_field_first_vs_getlist(client):
files = [
("files", ("a.txt", b"1", "text/plain")),
("files", ("b.txt", b"22", "text/plain")),
]
r = await client.post("/inspect", files=files)
body = r.json()
# form["files"] keeps only the first part; getlist returns every part.
assert body["first"] == "a.txt"
assert body["names"] == ["a.txt", "b.txt"]
assert body["sizes"] == [1, 2]
async def test_repeated_text_field_getlist(client):
r = await client.post("/inspect", data={"tags": ["x", "y"]})
body = r.json()
assert body["tags"] == ["x", "y"]
assert body["names"] == []
assert body["first"] is None
async def test_reread_requires_seek(client):
r = await client.post("/digest", files={"files": ("a.txt", b"abcdef", "text/plain")})
assert r.json() == {"head": "abcd", "rest": "ef", "whole": "abcdef"}
async def test_max_files_limit_becomes_400(client):
files = [
("files", ("a.txt", b"1", "text/plain")),
("files", ("b.txt", b"2", "text/plain")),
]
r = await client.post("/capped", files=files)
assert r.status_code == 400
# Plain Starlette renders HTTPException as text, not JSON like FastAPI does.
assert r.text == "Too many files. Maximum number of files is 1."
[pytest] pythonpath = . asyncio_mode = auto
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
FastAPI dependency_overrides tested offline with httpx ASGITransport
Override FastAPI dependencies (including nested ones) and drive the app in-process with httpx.ASGITransport, no network and no running server.
FastAPI login with argon2-cffi hashing and rehash checks
A form-based FastAPI login that verifies argon2id hashes without leaking which users exist, and transparently upgrades outdated hashes on successful login, tested offline with TestClient and an in-memory store.
FastAPI metrics with prometheus_client scraped as text
Instrument FastAPI with prometheus_client using a per-app registry, label by route template to bound cardinality, and scrape the text exposition format offline through httpx.ASGITransport.
Quart async routes tested offline with httpx ASGITransport
A Quart app with async JSON bodies and an injected httpx client, exercised fully in-process via httpx.ASGITransport and httpx.MockTransport.
All starlette recipesAll python-multipart recipesAll httpx recipesAll pytest recipes