Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"fastapi",
"python-multipart",
"pytest",
"pytest-asyncio<1.2.0",
]
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,20 @@
BASE_URL = "http://testserver"


def _with_content_length(headers, body):
"""Ensure Content-Length is present when a body is provided."""
hdrs = dict(headers or {})
if body is not None and not any(k.lower() == "content-length" for k in hdrs):
length = len(body.encode() if isinstance(body, str) else body)
hdrs["Content-Length"] = str(length)
return hdrs


async def fetch(app, path, env=None, method="GET", headers=None, body=None):
request = Request(
f"{BASE_URL}{path}",
method=method,
headers=dict(headers or {}),
headers=_with_content_length(headers, body),
body=body,
)
return await asgi.fetch(app, request, env or {})
Expand All @@ -24,3 +33,34 @@ async def read_json(response):
async def get_json(app, path, **kwargs):
response = await fetch(app, path, **kwargs)
return response, await read_json(response)


async def post_json(app, path, data, **kwargs):
return await fetch(
app,
path,
method="POST",
headers={"Content-Type": "application/json"},
body=_json.dumps(data),
**kwargs,
)


def build_multipart(files, boundary="----WebTestBoundary"):
"""Build a multipart/form-data body from a list of (name, filename, content) tuples."""
lines = []
for name, filename, content in files:
if isinstance(content, bytes):
content = content.decode()
lines.append(f"--{boundary}")
lines.append(
f'Content-Disposition: form-data; name="{name}"; filename="{filename}"'
)
lines.append("Content-Type: application/octet-stream")
lines.append("")
lines.append(content)
lines.append(f"--{boundary}--")
lines.append("")
body = "\r\n".join(lines)
content_type = f"multipart/form-data; boundary={boundary}"
return body, content_type
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"""Tests for FastAPI features that rely on anyio.to_thread.run_sync.

The workers runtime is single-threaded, so ``anyio.to_thread.run_sync`` is
patched to run the callable inline. Without the patch every test in this file
would fail with ``RuntimeError: can't start new thread``.

Covered call-sites:
- Sync ``def`` route handlers (``starlette.routing`` / ``fastapi.routing``)
- Sync dependencies (``fastapi.dependencies.utils``)
- Sync ``BackgroundTask`` (``starlette.background``)
- Sync-iterator ``StreamingResponse`` (``starlette.concurrency.iterate_in_threadpool``)
- ``UploadFile.read`` (``starlette.datastructures``)
"""

import pytest
from _client import build_multipart, fetch, get_json, read_json

# -- sync route handlers -----------------------------------------------------


@pytest.mark.asyncio
async def test_sync_handler_returns_json(fastapi_app):
"""A plain ``def`` route handler returns a JSON response."""
resp, data = await get_json(fastapi_app, "/sync/hello")
assert resp.status == 200
assert data["message"] == "sync hello"


@pytest.mark.asyncio
async def test_sync_post_handler(fastapi_app):
"""A sync POST handler receives the request and responds."""
resp = await fetch(fastapi_app, "/sync/echo", method="POST", body="hi")
assert resp.status == 200
data = await read_json(resp)
assert data["method"] == "POST"


# -- sync dependency ----------------------------------------------------------


@pytest.mark.asyncio
async def test_sync_dependency(fastapi_app):
"""An async handler that depends on a sync ``def`` dependency."""
resp, data = await get_json(fastapi_app, "/sync/dep")
assert resp.status == 200
assert data["greeting"] == "hello from sync dep"


# -- sync background task -----------------------------------------------------


@pytest.mark.asyncio
async def test_sync_background_task(fastapi_app):
"""A sync function passed to ``BackgroundTask`` runs to completion."""
resp, data = await get_json(fastapi_app, "/sync/background/run")
assert resp.status == 200
assert data["submitted"] is True

# The background task runs inline (patched), so by the time the response
# is fully sent the side-effect should already be visible.
resp2, data2 = await get_json(fastapi_app, "/sync/background/check")
assert resp2.status == 200
assert data2["bg_ran"] is True


# -- sync-iterator streaming response ----------------------------------------


@pytest.mark.asyncio
async def test_sync_streaming_response(fastapi_app):
"""StreamingResponse backed by a sync generator delivers all chunks."""
resp = await fetch(fastapi_app, "/sync/stream")
assert resp.status == 200
body = await resp.text()
for i in range(5):
assert f"chunk-{i}" in body


@pytest.mark.asyncio
async def test_sync_streaming_content_type(fastapi_app):
"""StreamingResponse preserves the declared media type."""
resp = await fetch(fastapi_app, "/sync/stream")
assert resp.status == 200
assert "text/plain" in resp.headers.get("content-type")


# -- file upload (UploadFile) -------------------------------------------------


@pytest.mark.asyncio
async def test_single_file_upload(fastapi_app):
"""A single file upload is received and its content is echoed back."""
body, content_type = build_multipart([("file", "greet.txt", "hello world")])
resp = await fetch(
fastapi_app,
"/upload/single",
method="POST",
headers={"Content-Type": content_type},
body=body,
)
assert resp.status == 200
data = await read_json(resp)
assert data["filename"] == "greet.txt"
assert data["size"] > 0
assert "hello world" in data["text"]


@pytest.mark.asyncio
async def test_multiple_file_upload(fastapi_app):
"""Multiple files are received and their metadata is echoed back."""
body, content_type = build_multipart(
[
("files", "one.txt", "first"),
("files", "two.txt", "second"),
]
)
resp = await fetch(
fastapi_app,
"/upload/multiple",
method="POST",
headers={"Content-Type": content_type},
body=body,
)
assert resp.status == 200
data = await read_json(resp)
assert len(data) == 2
assert [f["filename"] for f in data] == ["one.txt", "two.txt"]
assert all(f["size"] > 0 for f in data)
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
from pathlib import Path

import pytest
from fastapi import FastAPI, Request
from fastapi.responses import FileResponse
from fastapi import Depends, FastAPI, File, Request, UploadFile
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from fastapi.responses import Response as FastAPIResponse
from fastapi.staticfiles import StaticFiles
from pyodide.webloop import WebLoop
from starlette.background import BackgroundTask

import asgi
from workers import Response, WorkerEntrypoint
Expand All @@ -24,6 +25,16 @@ async def _noop(*args):

app = FastAPI()

# --------------------------------------------------------------------------- #
# Shared mutable state used to verify side-effects (e.g. background tasks).
# --------------------------------------------------------------------------- #
_side_effects: dict = {}


# --------------------------------------------------------------------------- #
# Routes exercising the anyio.to_thread.run_sync patch
# --------------------------------------------------------------------------- #


@app.get("/api/hello")
async def api_hello():
Expand All @@ -35,6 +46,87 @@ async def health():
return {"ok": True}


# -- sync route handler (dispatched via run_in_threadpool) -------------------
@app.get("/sync/hello")
def sync_hello():
"""A plain `def` (non-async) route handler."""
return {"message": "sync hello"}


@app.post("/sync/echo")
def sync_echo(request: Request):
"""Sync POST handler that reads the body."""
# request.body() is async; the sync handler can still return a dict.
return {"method": request.method}


# -- sync dependency ---------------------------------------------------------
def _get_greeting():
"""A plain `def` dependency (not async)."""
return "hello from sync dep"


@app.get("/sync/dep")
async def sync_dep_route(greeting: str = Depends(_get_greeting)):
"""Async handler with a sync dependency."""
return {"greeting": greeting}


# -- sync background task ----------------------------------------------------
def _bg_task():
"""Sync function executed as a BackgroundTask."""
_side_effects["bg_ran"] = True


@app.get("/sync/background/run")
async def sync_background_run():
"""Fire a sync background task and return immediately."""
_side_effects.pop("bg_ran", None)
return JSONResponse({"submitted": True}, background=BackgroundTask(_bg_task))


@app.get("/sync/background/check")
async def sync_background_check():
"""Return whether the background task has executed."""
return {"bg_ran": _side_effects.get("bg_ran", False)}


# -- sync iterator streaming response ---------------------------------------
def _sync_chunks():
"""A plain generator (not async) yielding text chunks."""
for i in range(5):
yield f"chunk-{i}\n"


@app.get("/sync/stream")
async def sync_stream():
"""StreamingResponse backed by a sync iterator."""
return StreamingResponse(_sync_chunks(), media_type="text/plain")


# -- file upload (UploadFile.read/write/seek go through run_in_threadpool) ---
@app.post("/upload/single")
async def upload_single(file: UploadFile = File(...)): # noqa: B008
"""Accept a single file upload and echo its metadata + content."""
content = await file.read()
return {
"filename": file.filename,
"content_type": file.content_type,
"size": len(content),
"text": content.decode(errors="replace"),
}


@app.post("/upload/multiple")
async def upload_multiple(files: list[UploadFile] = File(...)): # noqa: B008
"""Accept multiple file uploads and echo their metadata."""
result = []
for f in files:
data = await f.read()
result.append({"filename": f.filename, "size": len(data)})
return result


@app.get("/native-file")
async def native_file():
"""Serve a single bundled file with FastAPI's native FileResponse."""
Expand Down
Loading