Files
responder/responder/models.py
T
kennethreitz efb05827b7 Fix WSGI/ASGI mount detection and lifespan app; offload large auto-etag hash
- Mounted sub-app dispatch inferred WSGI-vs-ASGI from the *text* of a
  TypeError raised while calling the app ("argument"/"positional"), so a
  genuine TypeError inside a mounted ASGI app was misclassified as WSGI and
  its real traceback destroyed. Decide from the call signature up front
  (_is_wsgi_app): coroutine / async __call__ / __asgi_app__ => ASGI, a
  two-positional (environ, start_response) signature => WSGI.

- The lifespan handler was called with scope.get("app"), but Responder never
  set scope["app"], so a standard `async def lifespan(app)` always received
  None. Set scope["app"] = self.api before invoking it.

- auto_etag hashed the response body with MD5 on the event loop; for a large
  auto-etagged body that blocks the loop. Offload the hash to a thread above
  64 KiB; small bodies stay inline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 15:27:20 -04:00

1676 lines
59 KiB
Python

from __future__ import annotations
import asyncio
import functools
import hashlib
import inspect
import json
from collections.abc import Callable
from datetime import UTC, datetime
from email.utils import format_datetime, parsedate_to_datetime
from http.cookies import SimpleCookie
from typing import Any
from urllib.parse import parse_qs, urlparse
__all__ = ["Request", "Response", "QueryDict", "UploadFile"]
try:
import chardet
except ImportError:
chardet = None # type: ignore[assignment]
from starlette.background import BackgroundTasks
from starlette.concurrency import run_in_threadpool
from starlette.datastructures import UploadFile
from starlette.exceptions import HTTPException
from starlette.requests import Request as StarletteRequest
from starlette.requests import State
from starlette.responses import (
Response as StarletteResponse,
)
from starlette.responses import (
StreamingResponse as StarletteStreamingResponse,
)
from .errors import PROBLEM_JSON, problem_payload_for
from .statics import DEFAULT_ENCODING
from .status_codes import HTTP_301
async def _upload_file_save(
self, path, *, chunk_size=1024 * 1024, seek_start=True, create_parents=False
):
"""Persist an uploaded file to disk without buffering it all in memory."""
from pathlib import Path
import anyio
target = Path(path)
if create_parents:
target.parent.mkdir(parents=True, exist_ok=True)
if seek_start:
await self.seek(0)
async with await anyio.open_file(target, "wb") as f:
while True:
chunk = await self.read(chunk_size)
if not chunk:
break
await f.write(chunk)
return target
if not hasattr(UploadFile, "save"):
UploadFile.save = _upload_file_save # type: ignore[attr-defined]
class CaseInsensitiveDict(dict):
"""A case-insensitive dict for HTTP headers."""
def __setitem__(self, key, value):
super().__setitem__(key.lower(), value)
def __getitem__(self, key):
return super().__getitem__(key.lower())
def __delitem__(self, key):
super().__delitem__(key.lower())
def __contains__(self, key):
return super().__contains__(key.lower())
def get(self, key, default=None):
return super().get(key.lower(), default)
def pop(self, key, *args):
return super().pop(key.lower(), *args)
def setdefault(self, key, default=None):
return super().setdefault(key.lower(), default)
def update(self, other=None, /, **kwargs):
if other:
for key, value in other.items():
self[key] = value
for key, value in kwargs.items():
self[key] = value
class QueryDict(dict):
"""A dictionary for query string parameters that handles multi-value keys.
Single-value access returns the last value for a key. Use :meth:`get_list`
to retrieve all values for a multi-value parameter.
"""
def __init__(self, query_string):
self.update(parse_qs(query_string, keep_blank_values=True))
def __getitem__(self, key):
"""
Return the last data value for this key, or [] if it's an empty list;
raise KeyError if not found.
"""
list_ = super().__getitem__(key)
try:
return list_[-1]
except IndexError:
return []
def get(self, key, default=None):
"""
Return the last data value for the passed key. If key doesn't exist
or value is an empty list, return `default`.
"""
try:
val = self[key]
except KeyError:
return default
if val == []:
return default
return val
def _get_list(self, key, default=None, force_list=False):
"""
Return a list of values for the key.
Used internally to manipulate values list. If force_list is True,
return a new copy of values.
"""
try:
values = super().__getitem__(key)
except KeyError:
if default is None:
return []
return default
else:
if force_list:
values = list(values) if values is not None else None
return values
def get_list(self, key, default=None):
"""
Return the list of values for the key. If key doesn't exist, return a
default value.
"""
return self._get_list(key, default, force_list=True)
def items(self):
"""
Yield (key, value) pairs, where value is the last item in the list
associated with the key.
"""
for key in self:
yield key, self[key]
def items_list(self):
"""
Yield (key, value) pairs, where value is the the list.
"""
yield from super().items()
def _parse_accept(header: str) -> list[tuple[str, str, float]]:
"""Parse an ``Accept`` header into ``(type, subtype, q)`` media ranges."""
ranges: list[tuple[str, str, float]] = []
for part in header.split(","):
media, _, params = part.strip().partition(";")
media = media.strip().lower()
if not media:
continue
type_, _, subtype = media.partition("/")
quality = 1.0
for param in params.split(";"):
key, _, value = param.partition("=")
if key.strip().lower() == "q":
try:
quality = float(value.strip())
except ValueError:
quality = 1.0
# Keep an absent subtype empty (a bare "yaml" token) rather than
# widening it to "*", so it doesn't match unrelated media types.
ranges.append((type_.strip() or "*", subtype.strip(), quality))
return ranges
class Request:
"""An HTTP request, passed to each view as the first argument.
Provides access to headers, cookies, query parameters, the request body,
session data, and more. Most properties are synchronous; reading the body
(via :attr:`content`, :attr:`text`, or :meth:`media`) requires ``await``.
"""
__slots__ = [
"_starlette",
"formats",
"_headers",
"_encoding",
"api",
"_content",
"_cookies",
"_url",
"_params",
"_max_size",
]
def __init__(self, scope, receive, api=None, formats=None):
self._starlette = StarletteRequest(scope, receive)
self.formats = formats
self._encoding = None
self.api = api
self._content = None
self._url = None
self._params = None
self._headers = None
self._cookies = None
self._max_size = scope.get("max_request_size")
@property
def session(self):
"""The session data, in dict form, from the Request.
Requires sessions enabled (a ``secret_key`` or ``session_backend``,
with ``sessions != False``); raises ``RuntimeError`` otherwise.
"""
if "session" not in self._starlette.scope:
raise RuntimeError(
"req.session is unavailable: sessions are disabled. Enable them "
"with API(secret_key=...) or API(session_backend=...) (sessions "
"are on by default unless you passed sessions=False)."
)
return self._starlette.session
@property
def headers(self) -> CaseInsensitiveDict:
"""A case-insensitive dictionary, containing all headers sent in the Request."""
if self._headers is None:
headers: CaseInsensitiveDict = CaseInsensitiveDict()
for key, value in self._starlette.headers.items():
headers[key] = value
self._headers = headers
return self._headers
@property
def mimetype(self) -> str:
"""The MIME type of the request body, from the ``Content-Type`` header."""
return self.headers.get("Content-Type", "")
@property
def is_json(self) -> bool:
"""Returns ``True`` if the request content type is JSON."""
# Media types are case-insensitive (RFC 7231 §3.1.1.1).
return "json" in self.mimetype.lower()
@property
def last_event_id(self) -> str | None:
"""The SSE ``Last-Event-ID`` header sent by a reconnecting client.
Use it to resume an event stream from where the client left off.
"""
return self.headers.get("Last-Event-ID")
@property
def method(self) -> str:
"""The HTTP method, UPPER-cased (``"GET"``, ``"POST"``, …).
Comparisons are case-sensitive; compare against uppercase literals
(``req.method == "GET"``). Use ``.lower()`` for the lowercase string.
"""
return self._starlette.method.upper()
@property
def full_url(self) -> str:
"""The full URL of the Request, query parameters and all."""
return str(self._starlette.url)
@property
def url(self):
"""The parsed URL of the Request."""
if self._url is None:
self._url = urlparse(self.full_url)
return self._url
@property
def cookies(self) -> dict:
"""The cookies sent in the Request, as a dictionary."""
if self._cookies is None:
cookies = {}
cookie_header = self.headers.get("Cookie", "")
bc: SimpleCookie = SimpleCookie(cookie_header)
for key, morsel in bc.items():
cookies[key] = morsel.value
self._cookies = cookies
return self._cookies
@property
def params(self) -> QueryDict:
"""A dictionary of the parsed query parameters used for the Request."""
if self._params is None:
# Read the raw query string straight from the scope instead of
# reconstructing and re-parsing the whole URL. UTF-8 matches the
# prior behavior (Starlette's URL decodes query_string as UTF-8);
# errors="replace" keeps malformed bytes from raising.
qs = self._starlette.scope.get("query_string", b"")
if isinstance(qs, (bytes, bytearray)):
qs = qs.decode("utf-8", errors="replace")
self._params = QueryDict(qs)
return self._params
@property
def path_params(self) -> dict:
"""The path parameters extracted from the URL route."""
return self._starlette.path_params
@property
def client(self):
"""The client's address as a (host, port) named tuple, or None."""
return self._starlette.client
@property
def state(self) -> State:
"""
Use the state to store additional information.
This can be a very helpful feature, if you want to hand over
information from a middelware or a route decorator to the
actual route handler.
Usage: ``request.state.time_started = time.time()``
"""
return self._starlette.state
@property
async def encoding(self):
"""The encoding of the Request's body. Can be set, manually. Must be awaited."""
# Use the user-set encoding first.
if self._encoding:
return self._encoding
return await self.apparent_encoding
@encoding.setter
def encoding(self, value):
self._encoding = value
def _check_size(self, size):
if self._max_size is not None and size > self._max_size:
raise HTTPException(status_code=413, detail="Request body too large")
@property
async def content(self):
"""The Request body, as bytes. Must be awaited."""
if self._content is None:
declared = self.headers.get("Content-Length")
if declared and declared.isdigit():
self._check_size(int(declared))
# Enforce the size cap while reading, so an oversized chunked
# (or lying-Content-Length) body is rejected before it is fully
# resident in memory — not buffered first and checked after.
chunks: list[bytes] = []
received = 0
async for chunk in self._starlette.stream():
received += len(chunk)
self._check_size(received)
chunks.append(chunk)
self._content = b"".join(chunks)
return self._content
async def stream(self):
"""Iterate over the raw request body in chunks, without buffering.
Useful for large uploads. Once streamed, the body cannot be read
again via :attr:`content`, :attr:`text`, or :meth:`media`.
Usage::
@api.route("/upload", methods=["POST"])
async def upload(req, resp):
async with await anyio.open_file(path, "wb") as f:
async for chunk in req.stream():
await f.write(chunk)
"""
if self._content is not None:
yield self._content
return
received = 0
async for chunk in self._starlette.stream():
if chunk:
received += len(chunk)
self._check_size(received)
yield chunk
async def _parsed_form(self):
"""Parse the form/multipart body via Starlette.
Buffers the body through Responder's :attr:`content` (which enforces
``max_request_size``) and hands Starlette the cached bytes, so the body
stays replayable for any later read (``req.content``, ``media('form')``,
…) and the size cap is honored on multipart uploads. The result is
cached by Starlette, so repeated calls are cheap.
"""
starlette_req = self._starlette
if not hasattr(starlette_req, "_body"):
starlette_req._body = await self.content
return await starlette_req.form()
@property
async def text(self):
"""The Request body, as unicode. Must be awaited."""
return (await self.content).decode(await self.encoding)
@property
async def declared_encoding(self):
# An explicit (non-standard) "Encoding" header wins, for back-compat.
if "Encoding" in self.headers:
return self.headers["Encoding"]
# Otherwise honor the standard charset= parameter of Content-Type,
# rather than guessing with chardet.
content_type = self.headers.get("Content-Type", "")
for param in content_type.split(";")[1:]:
key, _, value = param.partition("=")
if key.strip().lower() == "charset":
charset = value.strip().strip('"')
if charset:
return charset
return None
@property
async def apparent_encoding(self):
"""The apparent encoding, detected automatically. Must be awaited.
Uses chardet for detection if installed, otherwise falls back to UTF-8.
"""
declared_encoding = await self.declared_encoding
if declared_encoding:
return declared_encoding
if chardet is not None:
return chardet.detect(await self.content)["encoding"] or DEFAULT_ENCODING
return DEFAULT_ENCODING
@property
def is_secure(self) -> bool:
"""``True`` if the request was made over HTTPS."""
return self.url.scheme == "https"
def accepts(self, content_type: str) -> bool:
"""Whether the client's ``Accept`` header allows ``content_type``.
Honors media ranges (``*/*``, ``type/*``) and q-values (a range with
``q=0`` is treated as not acceptable). An absent ``Accept`` header
accepts anything. ``content_type`` may be a full media type
(``application/json``) or a bare subtype token (``json``).
"""
accept = self.headers.get("Accept")
if not accept:
return True
wanted = content_type.lower()
for type_, subtype, quality in _parse_accept(accept):
if quality <= 0:
continue
if "/" in wanted:
ctype, _, csubtype = wanted.partition("/")
if type_ in ("*", ctype) and subtype in ("*", csubtype):
return True
# Bare token (e.g. "json"): match a wildcard range or a subtype/type
# that contains the token (keeps the historical substring behavior).
elif subtype == "*" or wanted in subtype or wanted in type_:
return True
return False
async def media(self, format: str | Callable | None = None) -> Any: # noqa: A002
"""Renders incoming json/yaml/form data as Python objects. Must be awaited.
:param format: The name of the format being used.
Alternatively, accepts a custom callable for the format type.
"""
if format is None:
# Media types are case-insensitive (RFC 7231 §3.1.1.1).
mimetype = self.mimetype.lower()
if "msgpack" in mimetype:
format = "msgpack" # noqa: A001
elif "yaml" in mimetype:
format = "yaml" # noqa: A001
elif "form" in mimetype:
format = "form" # noqa: A001
else:
format = "json" # noqa: A001
formatter: Callable
if isinstance(format, str):
try:
formatter = self.formats[format]
except KeyError as ex:
raise ValueError(f"Unable to process data in '{format}' format") from ex
elif callable(format):
formatter = format
else:
raise TypeError(f"Invalid 'format' argument: {format}")
return await formatter(self)
def media_sync(self, format: str | Callable | None = None) -> Any: # noqa: A002
"""Synchronous :meth:`media`, for use from **sync** handlers.
Responder runs sync handlers in a worker thread, so this safely bridges
to the event loop to read and parse the body. Calling it from an
``async`` handler raises — use ``await req.media()`` there instead.
Usage::
@api.route("/", methods=["POST"])
def create(req, resp):
data = req.media_sync()
"""
import anyio
return anyio.from_thread.run(self.media, format)
@property
def text_sync(self):
"""Synchronous :attr:`text`, for use from **sync** handlers (see
:meth:`media_sync`)."""
import anyio
async def _get():
return await self.text
return anyio.from_thread.run(_get)
class RangeNotSatisfiable(Exception):
"""The request's ``Range`` header cannot be satisfied (→ 416)."""
_MAX_BYTE_RANGES = 16
def _parse_byte_range(header, size):
"""Parse a ``Range`` header against a resource of ``size``.
Returns a list of ``(start, end)`` inclusive ranges, ``None`` when the
header is absent or malformed (serve the full resource), or raises
:class:`RangeNotSatisfiable` (→ 416).
"""
if not header or not header.startswith("bytes=") or size == 0:
return None
range_specs = header[len("bytes=") :]
if range_specs.count(",") >= _MAX_BYTE_RANGES:
return None
specs = [part.strip() for part in range_specs.split(",")]
if not specs or any(not spec for spec in specs):
return None
ranges = []
for spec in specs:
start_s, sep, end_s = spec.partition("-")
if not sep:
return None
try:
if not start_s: # Suffix range: bytes=-N (the last N bytes).
suffix = int(end_s)
if suffix <= 0:
raise RangeNotSatisfiable()
start, end = max(0, size - suffix), size - 1
else:
start = int(start_s)
end = min(int(end_s), size - 1) if end_s else size - 1
except ValueError:
return None # Malformed numbers: ignore the header.
if start >= size or start > end:
raise RangeNotSatisfiable()
ranges.append((start, end))
ranges.sort()
merged: list[tuple[int, int]] = []
for start, end in ranges:
if not merged or start > merged[-1][1] + 1:
merged.append((start, end))
else:
merged[-1] = (merged[-1][0], max(merged[-1][1], end))
return merged
def _multipart_range_header(boundary, content_type, start, end, size):
return (
f"--{boundary}\r\n"
f"Content-Type: {content_type}\r\n"
f"Content-Range: bytes {start}-{end}/{size}\r\n"
"\r\n"
).encode("ascii")
def _md5_hex(raw: bytes) -> str:
"""Hex MD5 digest (not security-sensitive — an ETag)."""
return hashlib.md5(raw, usedforsecurity=False).hexdigest()
def _strong_etag_core(tag):
"""The quoted core of a strong entity-tag, or ``None`` for weak/invalid."""
if not tag:
return None
tag = tag.strip()
if tag.startswith("W/"):
return None
if len(tag) >= 2 and tag.startswith('"') and tag.endswith('"'):
return tag
return None
def _resolve_within(path, root):
"""Resolve ``path`` under ``root``, refusing any escape (→ 404).
``path`` is treated as relative to ``root`` (a leading ``/`` is ignored),
and symlinks are followed before the containment check, so neither ``..``
nor a symlink can reach outside ``root``.
"""
from pathlib import Path
base = Path(root).resolve()
target = (base / str(path).lstrip("/")).resolve()
try:
target.relative_to(base)
except ValueError:
raise HTTPException(status_code=404, detail="Not found") from None
return target
def _is_external_url(location):
"""Whether ``location`` points off-site (absolute, or protocol-relative).
Browsers normalize backslashes to forward slashes and ignore leading
control/whitespace when resolving a ``Location``, so ``/\\evil.com`` and
friends are treated as protocol-relative — normalize before classifying.
"""
norm = location.replace("\\", "/").lstrip(" \t\r\n\x00")
if norm.startswith("//"):
return True
parsed = urlparse(norm)
return bool(parsed.scheme or parsed.netloc)
def _merge_header_tokens(*values):
"""Merge comma-separated header tokens, de-duped case-insensitively, in order."""
seen: dict[str, str] = {}
for value in values:
if not value:
continue
for token in value.split(","):
token = token.strip()
if token and token.lower() not in seen:
seen[token.lower()] = token
return ", ".join(seen.values())
def _sse_single_line(value: object) -> str:
"""Scrub an SSE single-line field (``event``/``id``/``retry``).
These fields are terminated by a line break, so a CR or LF in the value
would end the field early and let caller-supplied content inject extra SSE
lines or whole frames. Strip line breaks (and NUL, which the spec forbids
in ``id``).
"""
return str(value).replace("\r", "").replace("\n", "").replace("\x00", "")
def _sse_frame(data=None, *, event=None, id=None, retry=None): # noqa: A002
"""Encode one Server-Sent Events frame. dict/list data is JSON-encoded."""
lines = []
if event is not None:
lines.append(f"event: {_sse_single_line(event)}")
if id is not None:
lines.append(f"id: {_sse_single_line(id)}")
if retry is not None:
lines.append(f"retry: {_sse_single_line(retry)}")
if data is not None:
if isinstance(data, (dict, list)):
data = json.dumps(data)
# Split on any SSE line terminator (\r\n, \r, or \n) so multi-line data
# becomes multiple `data:` lines and a lone \r can't inject a new field.
normalized = str(data).replace("\r\n", "\n").replace("\r", "\n")
for line in normalized.split("\n"):
lines.append(f"data: {line}")
lines.append("")
lines.append("")
return "\n".join(lines).encode("utf-8")
def _format_sse_event(event: Any) -> bytes:
"""Turn a yielded SSE event (str / bytes / dict) into wire bytes."""
if isinstance(event, (bytes, bytearray)):
return bytes(event)
if isinstance(event, dict):
if set(event) == {"comment"}:
return f": {_sse_single_line(event['comment'])}\n\n".encode()
return _sse_frame(
data=event.get("data"),
event=event.get("event"),
id=event.get("id"),
retry=event.get("retry"),
)
return _sse_frame(data=event)
async def _sse_with_heartbeat(source, interval, maxsize=1024):
"""Yield from ``source``, injecting a heartbeat comment after ``interval``
seconds of silence — without cancelling the producer mid-item (a background
task feeds a queue; only the idle ``queue.get`` is timed out).
The queue is bounded (``maxsize``): if the client can't keep up, the
producer's ``queue.put`` blocks rather than buffering events without limit,
restoring backpressure for slow/stalled consumers."""
queue: asyncio.Queue = asyncio.Queue(maxsize=maxsize)
done = object()
async def pump():
try:
async for item in source:
await queue.put((False, item))
except Exception as exc: # surface a producer error to the consumer
await queue.put((True, exc))
return
await queue.put((False, done))
task = asyncio.ensure_future(pump())
try:
while True:
try:
is_exc, item = await asyncio.wait_for(queue.get(), interval)
except asyncio.TimeoutError:
yield {"comment": "keepalive"}
continue
if is_exc:
raise item
if item is done:
return
yield item
finally:
task.cancel()
try:
await task
except BaseException: # noqa: BLE001, S110 - cleanup of cancelled pump
pass
async def _empty_async_body():
chunks: tuple[bytes, ...] = ()
for chunk in chunks:
yield chunk
def content_setter(mimetype):
def getter(instance):
return instance.content
def setter(instance, value):
instance.content = value
instance.mimetype = mimetype
return property(fget=getter, fset=setter)
class Response:
"""An HTTP response, passed to each view as the second argument.
Mutate this object to control what gets sent back to the client. Set
:attr:`text`, :attr:`html`, :attr:`media`, or :attr:`content` to define
the body. Use :attr:`headers` and :meth:`set_cookie` to control metadata.
:var text: Set the response body as plain text (sets ``Content-Type: text/plain``).
:var html: Set the response body as HTML (sets ``Content-Type: text/html``).
:var media: Set a Python object (dict, list) to be serialized as JSON (or negotiated format).
:var content: Set the raw response body as bytes.
:var status_code: The HTTP status code (e.g. ``200``, ``404``). Defaults to ``200`` if not set.
:var headers: A dict of response headers.
:var cookies: A ``SimpleCookie`` holding cookies to set on the response.
:var session: A dict of session data. Changes are persisted in a signed cookie.
:var etag: Entity tag for the response. When the request's ``If-None-Match`` matches, an automatic ``304 Not Modified`` is sent instead of the body.
:var last_modified: A ``datetime`` (or HTTP-date string) for ``Last-Modified``. Honors ``If-Modified-Since`` with automatic ``304`` responses.
""" # noqa: E501
__slots__ = [
"req",
"status_code",
"content",
"encoding",
"media",
"headers",
"formats",
"cookies",
"mimetype",
"etag",
"last_modified",
"_stream",
"_auto_etag",
"_auto_vary",
"_background",
"_deferred_content",
"_multipart_range_boundary",
"_multipart_range_content_type",
]
text = content_setter("text/plain")
html = content_setter("text/html")
def __init__(self, req, *, formats, auto_etag=False, auto_vary=False):
self.req = req
self.status_code: int | None = None
self.content = None
self.mimetype = None
self.encoding = DEFAULT_ENCODING
self.media = None
self._stream = None
self.etag = None
self.last_modified = None
self._auto_etag = auto_etag
self._auto_vary = auto_vary
self._background = None
self._deferred_content = None
self._multipart_range_boundary = None
self._multipart_range_content_type = None
self.headers = {}
self.formats = formats
self.cookies: SimpleCookie = SimpleCookie()
@property
def session(self):
"""The session dict (delegates to the request; requires sessions on)."""
return self.req.session
@session.setter
def session(self, value):
scope = self.req._starlette.scope
if "session" not in scope:
raise RuntimeError("Cannot assign resp.session: sessions are disabled.")
# Mutate in place — rebinding would replace Starlette's Session subclass
# (which tracks accessed/modified) with a plain dict and crash the
# cookie middleware. clear()+update() works for the server backend too.
session = scope["session"]
session.clear()
session.update(value)
def stream(self, func, *args, **kwargs):
"""Set up a streaming response from an async generator function.
The generator yields chunks of bytes that are sent to the client
as they are produced, without buffering the full response in memory.
Usage::
@api.route("/stream")
async def stream_data(req, resp):
@resp.stream
async def body():
for i in range(10):
yield f"chunk {i}\\n".encode()
:param func: An async generator function that yields response chunks.
"""
assert inspect.isasyncgenfunction(func)
self._stream = functools.partial(func, *args, **kwargs)
return func
def sse(self, func=None, *args, heartbeat=None, **kwargs):
"""Set up a Server-Sent Events response from an async generator.
Each yielded value becomes one event:
- a ``dict`` with any of ``data``, ``event``, ``id``, ``retry``
(``data`` that is a ``dict``/``list`` is JSON-encoded);
- ``{"comment": "..."}`` for a comment/keepalive line;
- a ``str`` (or anything else) is sent as the ``data`` field;
- ``bytes`` are written verbatim (pre-formatted frame).
Usage::
@api.route("/events")
async def events(req, resp):
@resp.sse
async def stream():
for i in range(10):
yield {"data": {"n": i}, "id": i}
Pass ``heartbeat=`` (seconds) to emit a keepalive comment during idle
periods, so proxies don't drop the connection::
@resp.sse(heartbeat=15)
async def stream(): ...
On reconnect the client sends the last id it saw; read it with
:attr:`Request.last_event_id`.
:param func: The async generator function (or omit when using the
``@resp.sse(heartbeat=...)`` form).
:param heartbeat: Idle keepalive interval in seconds (``None`` = off).
"""
if func is None: # called as @resp.sse(heartbeat=...) -> decorator
def decorator(f):
return self.sse(f, *args, heartbeat=heartbeat, **kwargs)
return decorator
assert inspect.isasyncgenfunction(func)
async def sse_generator():
source = func(*args, **kwargs)
if heartbeat:
source = _sse_with_heartbeat(source, heartbeat)
async for event in source:
yield _format_sse_event(event)
self._stream = sse_generator
self.mimetype = "text/event-stream"
self.headers["Cache-Control"] = "no-cache"
self.headers["Connection"] = "keep-alive"
# Tell nginx & friends not to buffer the stream, so events flush live.
self.headers["X-Accel-Buffering"] = "no"
return func
def _set_file_mimetype(self, path, content_type):
if content_type:
self.mimetype = content_type
else:
import mimetypes
guessed = mimetypes.guess_type(str(path))[0]
self.mimetype = guessed or "application/octet-stream"
def _requested_range(self, size):
"""The (start, end) byte range to serve, or None for the full file.
Sets ``Accept-Ranges``, and on satisfiable ranges, the ``206`` status
plus either ``Content-Range`` or a multipart byte-range body.
Unsatisfiable ranges raise
:class:`RangeNotSatisfiable` after marking the response ``416``.
"""
self.headers["Accept-Ranges"] = "bytes"
if self.req.method not in ("GET", "HEAD"):
return None
if not self._if_range_matches():
return None
try:
byte_ranges = _parse_byte_range(self.req.headers.get("Range"), size)
except RangeNotSatisfiable:
self.status_code = 416
self.headers["Content-Range"] = f"bytes */{size}"
self.content = b""
raise
if byte_ranges is None:
return None
self.status_code = 206
if len(byte_ranges) == 1:
start, end = byte_ranges[0]
self.headers["Content-Range"] = f"bytes {start}-{end}/{size}"
return byte_ranges
boundary = hashlib.md5( # noqa: S324 - boundary, not security-sensitive
self.req.headers.get("Range", "").encode("utf-8"),
usedforsecurity=False,
).hexdigest()
self._multipart_range_boundary = f"responder-{boundary}"
self._multipart_range_content_type = self.mimetype or "application/octet-stream"
self.mimetype = f"multipart/byteranges; boundary={self._multipart_range_boundary}"
self.headers.pop("Content-Range", None)
return byte_ranges
def _if_range_matches(self):
"""Whether a ``Range`` request is allowed to stay partial.
``If-Range`` turns a stale range request back into a full-body response:
the range is honored only when the provided validator still matches the
current representation. If it doesn't, the ``Range`` header is ignored.
"""
if_range = self.req.headers.get("If-Range")
if not if_range or not self.req.headers.get("Range"):
return True
request_tag = _strong_etag_core(if_range)
if request_tag is not None:
current_tag = _strong_etag_core(self._normalized_etag) if self.etag else None
return current_tag is not None and current_tag == request_tag
if self.last_modified is None:
return False
try:
current = parsedate_to_datetime(self._last_modified_header)
candidate = parsedate_to_datetime(if_range)
if current is None or candidate is None:
return False
if current.tzinfo is None:
current = current.replace(tzinfo=UTC)
if candidate.tzinfo is None:
candidate = candidate.replace(tzinfo=UTC)
return current <= candidate
except (TypeError, ValueError):
return False
def _apply_file_conditionals(self, stat_result):
"""Set a stat-based weak ETag and Last-Modified (unless already set), so
the existing 304 machinery answers ``If-None-Match`` / ``If-Modified-Since``
for served files without reading their contents."""
if self.etag is None:
mtime_ns = getattr(
stat_result, "st_mtime_ns", int(stat_result.st_mtime * 1_000_000_000)
)
self.etag = f'W/"{stat_result.st_size:x}-{mtime_ns:x}"'
if self.last_modified is None:
self.last_modified = datetime.fromtimestamp(
stat_result.st_mtime, tz=UTC
)
def stream_file(
self, path, *, content_type=None, chunk_size=8192, root=None, conditional=True
):
"""Stream a file without loading it entirely into memory.
Supports HTTP range requests (``Range: bytes=...``) with ``206``
partial responses, enabling video seeking and resumable downloads.
:param path: Path to the file.
:param content_type: Optional MIME type override.
:param chunk_size: Size of chunks to read (default 8192 bytes).
:param root: If given, ``path`` is resolved under this directory and any
attempt to escape it (via ``..`` or a symlink) yields a
``404`` — use this whenever ``path`` is built from user input.
:param conditional: If ``True`` (default), set a stat-based ETag and
``Last-Modified`` so conditional requests get a ``304``.
"""
from pathlib import Path as PathType
path = PathType(path) if root is None else _resolve_within(path, root)
self._set_file_mimetype(path, content_type)
stat_result = path.stat()
size = stat_result.st_size
if conditional:
self._apply_file_conditionals(stat_result)
# Evaluate the validator before Range: a matching conditional must
# win (304), not be masked by a 206 partial response.
if self._is_not_modified():
return
try:
byte_range = self._requested_range(size)
except RangeNotSatisfiable:
return
byte_ranges = byte_range if byte_range else [(0, size - 1)]
async def file_generator():
import anyio
async with await anyio.open_file(path, "rb") as f:
if len(byte_ranges) > 1:
boundary = self._multipart_range_boundary
assert boundary is not None
content_type = (
self._multipart_range_content_type
or "application/octet-stream"
)
for start, end in byte_ranges:
yield _multipart_range_header(
boundary, content_type, start, end, size
)
await f.seek(start)
remaining = end - start + 1
while remaining > 0:
chunk = await f.read(min(chunk_size, remaining))
if not chunk:
break
remaining -= len(chunk)
yield chunk
yield b"\r\n"
yield f"--{boundary}--\r\n".encode("ascii")
return
start, end = byte_ranges[0]
remaining = end - start + 1 if size else 0
if start:
await f.seek(start)
while remaining > 0:
chunk = await f.read(min(chunk_size, remaining))
if not chunk:
break
remaining -= len(chunk)
yield chunk
self._stream = file_generator
def file(self, path, *, content_type=None, root=None, conditional=True):
"""Serve a file from disk as the response.
Supports HTTP range requests (``Range: bytes=...``) with ``206``
partial responses. The file's bytes are read in a worker thread when
the response is sent, so calling this from an ``async`` handler never
blocks the event loop.
:param path: Path to the file to serve.
:param content_type: Optional MIME type override.
:param root: If given, ``path`` is resolved under this directory and any
attempt to escape it (via ``..`` or a symlink) yields a
``404`` — use this whenever ``path`` is built from user input.
:param conditional: If ``True`` (default), set a stat-based ETag and
``Last-Modified`` so conditional requests get a ``304``
(and the file's bytes aren't read to compute it).
"""
from pathlib import Path
path = Path(path) if root is None else _resolve_within(path, root)
self._set_file_mimetype(path, content_type)
stat_result = path.stat()
size = stat_result.st_size
if conditional:
self._apply_file_conditionals(stat_result)
# Evaluate the validator before Range: a matching conditional must
# win (304), not be masked by a 206 partial response.
if self._is_not_modified():
return
try:
byte_range = self._requested_range(size)
except RangeNotSatisfiable:
return
byte_ranges = byte_range if byte_range else [(0, size - 1)]
def _read() -> bytes:
if not size:
return b""
with path.open("rb") as f:
if len(byte_ranges) > 1:
boundary = self._multipart_range_boundary
assert boundary is not None
content_type = (
self._multipart_range_content_type
or "application/octet-stream"
)
parts = []
for start, end in byte_ranges:
parts.append(
_multipart_range_header(
boundary, content_type, start, end, size
)
)
f.seek(start)
parts.append(f.read(end - start + 1))
parts.append(b"\r\n")
parts.append(f"--{boundary}--\r\n".encode("ascii"))
return b"".join(parts)
start, end = byte_ranges[0]
if start:
f.seek(start)
return f.read(end - start + 1)
async def _deferred() -> bytes:
return await run_in_threadpool(_read)
self._deferred_content = _deferred
def download(
self, path, *, filename=None, content_type=None, root=None, conditional=True
):
"""Serve a file as an attachment, prompting the browser to download.
Streams the file (with range support, so downloads are resumable)
and sets ``Content-Disposition``.
:param path: Path to the file to serve.
:param filename: Download name presented to the client.
Defaults to the file's own name.
:param content_type: Optional MIME type override.
:param root: If given, ``path`` is resolved under this directory and any
escape attempt yields a ``404`` (see :meth:`file`).
"""
from pathlib import Path
from urllib.parse import quote
path = Path(path) if root is None else _resolve_within(path, root)
name = filename or path.name
self.stream_file(path, content_type=content_type, conditional=conditional)
try:
name.encode("ascii")
disposition = f'attachment; filename="{name}"'
except UnicodeEncodeError:
disposition = f"attachment; filename*=UTF-8''{quote(name)}"
self.headers["Content-Disposition"] = disposition
def render(self, template, *args, **kwargs):
r"""Render a Jinja2 template as the HTML response body.
Shorthand for ``resp.html = api.template(...)``, using the owning
API's ``templates_dir``.
:param template: The template filename.
:param \*args: Data to pass into the template.
:param \*\*kwargs: Data to pass into the template.
Usage::
@api.route("/")
def home(req, resp):
resp.render("home.html", user="kenneth")
"""
if self.req.api is None:
raise RuntimeError(
"resp.render() requires the Response to be bound to an API"
)
self.html = self.req.api.template(template, *args, **kwargs)
def background(self, func, *args, **kwargs):
"""Schedule a task to run after the response has been sent.
Unlike ``api.background`` (which runs immediately in a thread pool),
tasks scheduled here are deferred until the client has the response,
so they never delay it. Sync and async functions both work. Multiple
tasks run in the order scheduled.
Usage::
@api.route("/signup", methods=["POST"])
async def signup(req, resp):
resp.media = {"ok": True}
resp.background(send_welcome_email, "user@example.com")
"""
if self._background is None:
self._background = BackgroundTasks()
self._background.add_task(func, *args, **kwargs)
return func
def reset_for_error(self):
"""Discard a previously prepared success response before framework errors."""
self.content = None
self.media = None
self.mimetype = None
self._stream = None
self._deferred_content = None
self._background = None
self._multipart_range_boundary = None
self._multipart_range_content_type = None
self.etag = None
self.last_modified = None
self.headers.clear()
self.cookies.clear()
def _reset_body(self) -> None:
self.content = None
self.media = None
self.mimetype = None
self._stream = None
self._deferred_content = None
self._multipart_range_boundary = None
self._multipart_range_content_type = None
def created(self, media=None, *, location=None, headers=None):
"""Mark the response as ``201 Created``.
Optionally set a JSON-serializable body and a ``Location`` header::
resp.created({"id": item.id}, location=f"/items/{item.id}")
"""
self.status_code = 201
if media is not None:
self._reset_body()
self.media = media
if location is not None:
self.headers["Location"] = str(location)
if headers:
self.headers.update(headers)
def no_content(self, *, headers=None):
"""Mark the response as ``204 No Content`` and clear any response body."""
self.status_code = 204
self._reset_body()
self.content = b""
self.headers.pop("Content-Type", None)
self.headers.pop("content-type", None)
if headers:
self.headers.update(headers)
def problem(
self,
status_code,
detail=None,
*,
title=None,
type=None, # noqa: A002
instance=None,
errors=None,
headers=None,
**extensions,
):
"""Send an RFC 9457 ``application/problem+json`` response.
API-level ``problem_handler`` enrichment and request IDs are applied,
then explicit helper arguments and extension members are layered on top.
"""
self.status_code = int(status_code)
payload = problem_payload_for(
self.req._starlette.scope,
self.status_code,
detail,
title=title,
errors=errors,
request=self.req,
)
if type is not None:
payload["type"] = type
if instance is not None:
payload["instance"] = instance
payload.update(extensions)
self._reset_body()
self.content = json.dumps(payload).encode("utf-8")
self.mimetype = PROBLEM_JSON
self.headers["Content-Type"] = PROBLEM_JSON
if headers:
self.headers.update(headers)
def cache_control(self, **directives):
"""Set the ``Cache-Control`` header from keyword directives.
Underscores become hyphens; ``True`` renders a bare directive,
other values render ``name=value``::
resp.cache_control(public=True, max_age=3600)
# Cache-Control: public, max-age=3600
resp.cache_control(no_store=True)
# Cache-Control: no-store
"""
parts = []
for key, value in directives.items():
if value is False or value is None:
continue
name = key.replace("_", "-")
parts.append(name if value is True else f"{name}={value}")
self.headers["Cache-Control"] = ", ".join(parts)
def redirect(
self, location, *, set_text=True, status_code=HTTP_301, allow_external=True
):
"""Redirect the client to a different URL.
:param location: The URL to redirect to.
:param set_text: If ``True``, set a default redirect message as the body.
:param status_code: The HTTP status code (default ``301``).
:param allow_external: If ``False``, refuse (with a ``400``) to redirect
to an absolute or protocol-relative URL — pass
this whenever ``location`` comes from user input,
to prevent open redirects.
"""
if not allow_external and _is_external_url(location):
raise HTTPException(
status_code=400, detail="Refusing to redirect to an external URL"
)
self.status_code = status_code
if set_text:
self.text = f"Redirecting to: {location}"
self.headers.update({"Location": location})
@property
async def body(self):
# A file scheduled via resp.file() is read here (off the event loop).
if self._deferred_content is not None and self.content is None:
self.content = await self._deferred_content()
if self._stream is not None:
headers = {}
if self.mimetype is not None:
headers["Content-Type"] = self.mimetype
return (self._stream(), headers)
if self.content is not None:
headers = {}
content = self.content
if self.mimetype is not None:
headers["Content-Type"] = self.mimetype
if self.mimetype == "text/plain" and self.encoding is not None:
headers["Encoding"] = self.encoding
if isinstance(content, str):
content = content.encode(self.encoding)
return (content, headers)
for format_ in self.formats:
if self.req.accepts(format_):
encoded = await self.formats[format_](self, encode=True)
# Formats that can't encode (e.g. form, files) return None.
if encoded is not None:
return encoded, ({"Vary": "Accept"} if self._auto_vary else {})
# Default to JSON anyway.
headers = {"Content-Type": "application/json"}
if self._auto_vary:
headers["Vary"] = "Accept"
return (await self.formats["json"](self, encode=True), headers)
def set_cookie(
self,
key,
value="",
expires=None,
path="/",
domain=None,
max_age=None,
secure=False,
httponly=True,
samesite="lax",
):
"""Set a cookie on the response with full control over directives.
:param key: The cookie name.
:param value: The cookie value.
:param expires: Expiration date string (e.g. ``"Thu, 01 Jan 2026 00:00:00 GMT"``).
:param path: URL path the cookie applies to (default ``"/"``).
:param domain: Domain the cookie is valid for.
:param max_age: Maximum age in seconds before the cookie expires.
:param secure: If ``True``, cookie is only sent over HTTPS.
:param httponly: If ``True`` (default), cookie is inaccessible to JavaScript.
:param samesite: Cross-site behavior: ``"lax"`` (default), ``"strict"``,
``"none"``, or ``None`` to omit the directive.
Usage::
resp.set_cookie(
"token", value="abc123",
max_age=3600, secure=True, httponly=True,
)
"""
self.cookies[key] = value
morsel = self.cookies[key]
if expires is not None:
morsel["expires"] = expires
if path is not None:
morsel["path"] = path
if domain is not None:
morsel["domain"] = domain
if max_age is not None:
morsel["max-age"] = max_age
morsel["secure"] = secure
morsel["httponly"] = httponly
if samesite is not None:
morsel["samesite"] = samesite
def delete_cookie(
self,
key,
*,
path="/",
domain=None,
secure=False,
httponly=True,
samesite="lax",
):
"""Expire a cookie on the client (empty value, ``Max-Age=0``).
Pass the same ``path``/``domain`` the cookie was set with so the browser
matches and drops it.
Usage::
resp.delete_cookie("token")
"""
self.set_cookie(
key,
value="",
max_age=0,
expires="Thu, 01 Jan 1970 00:00:00 GMT",
path=path,
domain=domain,
secure=secure,
httponly=httponly,
samesite=samesite,
)
def vary(self, *values):
"""Add one or more field names to the response ``Vary`` header.
Tokens are merged with any existing ``Vary`` value and de-duplicated
case-insensitively::
resp.vary("Accept", "Accept-Language")
"""
merged = _merge_header_tokens(self.headers.get("Vary"), *values)
if merged:
self.headers["Vary"] = merged
def _prepare_cookies(self, starlette_response):
cookie_header = (
(b"set-cookie", morsel.output(header="").lstrip().encode("latin-1"))
for morsel in self.cookies.values()
)
starlette_response.raw_headers.extend(cookie_header)
@property
def _normalized_etag(self):
etag = str(self.etag)
if etag.startswith(('"', "W/")):
return etag
return f'"{etag}"'
@property
def _last_modified_header(self):
if isinstance(self.last_modified, datetime):
value = self.last_modified
if value.tzinfo is None:
value = value.replace(tzinfo=UTC)
return format_datetime(value, usegmt=True)
return str(self.last_modified)
def _is_not_modified(self):
"""Whether the request's conditional headers match this response."""
if self.req.method not in ("GET", "HEAD"):
return False
if self.status_code not in (None, 200):
return False
# If-None-Match takes precedence over If-Modified-Since (RFC 7232).
if_none_match = self.req.headers.get("If-None-Match")
if if_none_match and self.etag is not None:
if if_none_match.strip() == "*":
return True
def core(tag):
return tag[2:] if tag.startswith("W/") else tag
tags = [core(t.strip()) for t in if_none_match.split(",")]
return core(self._normalized_etag) in tags
if_modified_since = self.req.headers.get("If-Modified-Since")
if if_modified_since and self.last_modified is not None:
try:
since = parsedate_to_datetime(if_modified_since)
current = parsedate_to_datetime(self._last_modified_header)
# A "-0000" zone parses to a naive datetime; normalize both to
# UTC so comparing them can't raise (naive vs aware -> TypeError).
if since.tzinfo is None:
since = since.replace(tzinfo=UTC)
if current.tzinfo is None:
current = current.replace(tzinfo=UTC)
return current <= since
except (TypeError, ValueError):
return False
return False
async def __call__(self, scope, receive, send):
body = None
headers: dict = {}
built = False
if (
self._auto_etag
and self.etag is None
and self._stream is None
and self.req.method in ("GET", "HEAD")
and self.status_code in (None, 200)
):
body, headers = await self.body
built = True
raw = (
body
if isinstance(body, bytes)
else str(body).encode(self.encoding or DEFAULT_ENCODING)
)
# Hash off the event loop for large bodies so a big auto-etagged
# response doesn't block the loop; small bodies stay inline.
if len(raw) > 65536:
self.etag = await run_in_threadpool(_md5_hex, raw)
else:
self.etag = _md5_hex(raw)
if self.etag is not None or self.last_modified is not None:
if self.etag is not None:
self.headers["ETag"] = self._normalized_etag
if self.last_modified is not None:
self.headers["Last-Modified"] = self._last_modified_header
if self._is_not_modified():
# Carry the negotiated Vary onto the 304 too, else a shared
# cache keys the not-modified response without it.
if built and headers.get("Vary"):
self.vary(headers["Vary"])
elif (
self._auto_vary
and self._stream is None
and self.content is None
and self._deferred_content is None
):
self.vary("Accept")
not_modified = StarletteResponse(
status_code=304, headers=self.headers, background=self._background
)
self._prepare_cookies(not_modified)
await not_modified(scope, receive, send)
return
if not built:
body, headers = await self.body
if self.headers:
# Merge Vary from both sources so an explicit resp.vary(...) and an
# auto-added "Accept" combine rather than clobber each other.
vary = _merge_header_tokens(headers.get("Vary"), self.headers.get("Vary"))
headers.update(self.headers)
if vary:
headers["Vary"] = vary
response_cls: type[StarletteResponse] | type[StarletteStreamingResponse]
if self._stream is not None:
response_cls = StarletteStreamingResponse
else:
response_cls = StarletteResponse
if self.req.method == "HEAD" and self._stream is not None:
body = _empty_async_body()
response = response_cls(
body,
status_code=self.status_code_safe,
headers=headers,
background=self._background,
)
if self.req.method == "HEAD" and self._stream is None:
# Preserve the headers Starlette computed from the GET body, but do
# not send a response body for HEAD.
response.body = b""
self._prepare_cookies(response)
await response(scope, receive, send)
@property
def ok(self):
"""``True`` if the status code is in the 2xx range (success).
Reads as ``200`` until a status code has been set, so it never raises.
"""
code = self.status_code if self.status_code is not None else 200
return 200 <= code < 300
@property
def status_code_safe(self) -> int:
"""Return the status code, raising ``RuntimeError`` if it hasn't been set."""
if self.status_code is None:
raise RuntimeError("HTTP status code has not been defined")
return self.status_code