mirror of
https://github.com/kennethreitz/responder.git
synced 2026-06-05 06:46:14 +00:00
Add comprehensive docstrings, expand API reference, upgrade to Starlette 1.0
- Add docstrings to all undocumented public methods across API, Request, Response, Router, Route, BackgroundQueue, and related classes - Expand api.rst with autodoc sections for RouteGroup, BackgroundQueue, QueryDict, and RateLimiter - Update starlette dependency to >=1.0 - Drop Python 3.9 support (required by Starlette 1.0), minimum is now 3.10 - Bump version to 3.4.0 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,7 +17,7 @@ if __name__ == "__main__":
|
||||
|
||||
$ pip install responder
|
||||
|
||||
That's it. Supports Python 3.9+.
|
||||
That's it. Supports Python 3.10+.
|
||||
|
||||
## The Basics
|
||||
|
||||
|
||||
@@ -43,6 +43,45 @@ status code, headers, and cookies.
|
||||
:inherited-members:
|
||||
|
||||
|
||||
Route Groups
|
||||
------------
|
||||
|
||||
Group related routes under a shared URL prefix — useful for API versioning
|
||||
and organizing large applications.
|
||||
|
||||
.. autoclass:: responder.api.RouteGroup
|
||||
:members:
|
||||
|
||||
|
||||
Background Queue
|
||||
----------------
|
||||
|
||||
Run tasks in background threads without blocking the response. Available
|
||||
as ``api.background``.
|
||||
|
||||
.. autoclass:: responder.background.BackgroundQueue
|
||||
:members:
|
||||
|
||||
|
||||
Query Dict
|
||||
----------
|
||||
|
||||
A dictionary subclass for query string parameters with multi-value support.
|
||||
|
||||
.. autoclass:: responder.models.QueryDict
|
||||
:members:
|
||||
|
||||
|
||||
Rate Limiter
|
||||
------------
|
||||
|
||||
In-memory token bucket rate limiter. Limits requests per client IP address
|
||||
and returns ``429 Too Many Requests`` when exceeded.
|
||||
|
||||
.. autoclass:: responder.ext.ratelimit.RateLimiter
|
||||
:members:
|
||||
|
||||
|
||||
Status Code Helpers
|
||||
-------------------
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ Installation
|
||||
|
||||
$ uv pip install responder
|
||||
|
||||
Python 3.9 and above. That's it.
|
||||
Python 3.10 and above. That's it.
|
||||
|
||||
|
||||
.. toctree::
|
||||
|
||||
+2
-3
@@ -12,7 +12,7 @@ license = {text = "Apache 2.0"}
|
||||
authors = [
|
||||
{ name = "Kenneth Reitz", email = "me@kennethreitz.org" },
|
||||
]
|
||||
requires-python = ">=3.9"
|
||||
requires-python = ">=3.10"
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Environment :: Web Environment",
|
||||
@@ -21,7 +21,6 @@ classifiers = [
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
@@ -42,7 +41,7 @@ dependencies = [
|
||||
"pueblo[sfa-full]>=0.0.11",
|
||||
"pydantic>=2",
|
||||
"python-multipart",
|
||||
"starlette[full]>=0.40",
|
||||
"starlette[full]>=1.0",
|
||||
"uvicorn[standard]",
|
||||
]
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "3.3.0"
|
||||
__version__ = "3.4.0"
|
||||
|
||||
@@ -61,6 +61,31 @@ class API:
|
||||
lifespan=None,
|
||||
request_id=False,
|
||||
):
|
||||
"""Create a new Responder API instance.
|
||||
|
||||
:param debug: If ``True``, enable debug mode with verbose error pages.
|
||||
:param title: The title of the API, used in OpenAPI documentation.
|
||||
:param version: The version string for the API (e.g. ``"1.0"``).
|
||||
:param description: A longer description of the API for OpenAPI docs.
|
||||
:param terms_of_service: URL to the API's terms of service.
|
||||
:param contact: Contact information dict for the API (``name``, ``url``, ``email``).
|
||||
:param license: License information dict (``name``, ``url``).
|
||||
:param openapi: The OpenAPI version string (e.g. ``"3.0.2"``). Enables OpenAPI schema generation.
|
||||
:param openapi_route: The URL path for the OpenAPI schema (default ``"/schema.yml"``).
|
||||
:param static_dir: Directory for static files. Set to ``None`` to disable. Created automatically if missing.
|
||||
:param static_route: URL prefix for serving static files (default ``"/static"``).
|
||||
:param templates_dir: Directory for Jinja2 templates (default ``"templates"``).
|
||||
:param auto_escape: If ``True``, auto-escape HTML/XML in templates.
|
||||
:param secret_key: Secret key for signing cookie-based sessions. **Always set this in production.**
|
||||
:param enable_hsts: If ``True``, redirect all HTTP requests to HTTPS.
|
||||
:param docs_route: URL path for interactive API docs (e.g. ``"/docs"``). Enables OpenAPI if not already set.
|
||||
:param cors: If ``True``, enable CORS middleware.
|
||||
:param cors_params: Dict of CORS configuration (``allow_origins``, ``allow_methods``, etc.).
|
||||
:param allowed_hosts: List of allowed hostnames (e.g. ``["example.com"]``). Defaults to ``["*"]``.
|
||||
:param openapi_theme: Documentation UI theme: ``"swagger_ui"``, ``"redoc"``, ``"rapidoc"``, or ``"elements"``.
|
||||
:param lifespan: An async context manager for startup/shutdown logic.
|
||||
:param request_id: If ``True``, add ``X-Request-ID`` headers to all responses.
|
||||
""" # noqa: E501
|
||||
self.background = BackgroundQueue()
|
||||
|
||||
self.secret_key = secret_key
|
||||
@@ -150,12 +175,30 @@ class API:
|
||||
|
||||
@property
|
||||
def static_app(self):
|
||||
"""The Starlette ``StaticFiles`` application for serving static assets."""
|
||||
if not hasattr(self, "_static_app"):
|
||||
assert self.static_dir is not None
|
||||
self._static_app = StaticFiles(directory=self.static_dir)
|
||||
return self._static_app
|
||||
|
||||
def before_request(self, websocket=False):
|
||||
"""Register a function to run before every request.
|
||||
|
||||
If the hook sets ``resp.status_code``, the route handler is skipped
|
||||
and the response is sent immediately (short-circuiting).
|
||||
|
||||
:param websocket: If ``True``, register as a WebSocket before-request hook instead of HTTP.
|
||||
|
||||
Usage::
|
||||
|
||||
@api.before_request()
|
||||
def check_auth(req, resp):
|
||||
if "Authorization" not in req.headers:
|
||||
resp.status_code = 401
|
||||
resp.media = {"error": "unauthorized"}
|
||||
|
||||
""" # noqa: E501
|
||||
|
||||
def decorator(f):
|
||||
self.router.before_request(f, websocket=websocket)
|
||||
return f
|
||||
@@ -180,6 +223,21 @@ class API:
|
||||
return decorator
|
||||
|
||||
def add_middleware(self, middleware_cls, **middleware_config):
|
||||
"""Add ASGI middleware to the application.
|
||||
|
||||
Middleware wraps the entire application and can inspect or modify
|
||||
every request and response. Middleware is applied in reverse order —
|
||||
the last middleware added runs first.
|
||||
|
||||
:param middleware_cls: A Starlette-compatible middleware class.
|
||||
:param middleware_config: Keyword arguments passed to the middleware constructor.
|
||||
|
||||
Usage::
|
||||
|
||||
from starlette.middleware.httpsredirect import HTTPSRedirectMiddleware
|
||||
api.add_middleware(HTTPSRedirectMiddleware)
|
||||
|
||||
"""
|
||||
self.app = middleware_cls(self.app, **middleware_config)
|
||||
|
||||
def exception_handler(self, exception_cls):
|
||||
@@ -501,6 +559,10 @@ class API:
|
||||
uvicorn.run(self, host=address, port=port, **options)
|
||||
|
||||
def run(self, **kwargs):
|
||||
"""Run the application. Shorthand for :meth:`serve` that inherits the ``debug`` setting.
|
||||
|
||||
:param kwargs: Keyword arguments passed through to :meth:`serve`.
|
||||
"""
|
||||
if "debug" not in kwargs:
|
||||
kwargs.update({"debug": self.debug})
|
||||
self.serve(**kwargs)
|
||||
|
||||
@@ -9,7 +9,33 @@ __all__ = ["BackgroundQueue"]
|
||||
|
||||
|
||||
class BackgroundQueue:
|
||||
"""A queue for running tasks in background threads.
|
||||
|
||||
Uses a ``ThreadPoolExecutor`` sized to the number of CPUs. Access it
|
||||
via ``api.background``.
|
||||
|
||||
Usage::
|
||||
|
||||
# As a decorator — fire and forget
|
||||
@api.background.task
|
||||
def send_email(to, subject):
|
||||
...
|
||||
|
||||
send_email("user@example.com", "Hello")
|
||||
|
||||
# Direct submission
|
||||
future = api.background.run(send_email, "user@example.com", "Hello")
|
||||
|
||||
# As a callable (supports async functions)
|
||||
await api.background(send_email, "user@example.com", "Hello")
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, n=None):
|
||||
"""Create a new background queue.
|
||||
|
||||
:param n: Number of worker threads. Defaults to CPU count.
|
||||
"""
|
||||
if n is None:
|
||||
n = multiprocessing.cpu_count()
|
||||
|
||||
@@ -18,11 +44,24 @@ class BackgroundQueue:
|
||||
self.results = []
|
||||
|
||||
def run(self, f, *args, **kwargs):
|
||||
"""Submit a function to run in a background thread.
|
||||
|
||||
:param f: The function to run.
|
||||
:returns: A ``concurrent.futures.Future`` for the result.
|
||||
"""
|
||||
f = self.pool.submit(f, *args, **kwargs)
|
||||
self.results.append(f)
|
||||
return f
|
||||
|
||||
def task(self, f):
|
||||
"""Decorator that wraps a function to run in the background thread pool.
|
||||
|
||||
The decorated function returns a ``Future`` instead of blocking.
|
||||
Exceptions are printed to stderr via traceback.
|
||||
|
||||
:param f: The function to wrap.
|
||||
"""
|
||||
|
||||
def on_future_done(fs):
|
||||
try:
|
||||
fs.result()
|
||||
|
||||
+79
-10
@@ -49,6 +49,12 @@ class CaseInsensitiveDict(dict):
|
||||
|
||||
|
||||
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))
|
||||
|
||||
@@ -117,6 +123,13 @@ class QueryDict(dict):
|
||||
|
||||
|
||||
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",
|
||||
@@ -153,6 +166,7 @@ class Request:
|
||||
|
||||
@property
|
||||
def mimetype(self):
|
||||
"""The MIME type of the request body, from the ``Content-Type`` header."""
|
||||
return self.headers.get("Content-Type", "")
|
||||
|
||||
@property
|
||||
@@ -270,6 +284,7 @@ class Request:
|
||||
|
||||
@property
|
||||
def is_secure(self):
|
||||
"""``True`` if the request was made over HTTPS."""
|
||||
return self.url.scheme == "https"
|
||||
|
||||
def accepts(self, content_type):
|
||||
@@ -315,6 +330,22 @@ def content_setter(mimetype):
|
||||
|
||||
|
||||
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.
|
||||
""" # noqa: E501
|
||||
|
||||
__slots__ = [
|
||||
"req",
|
||||
"status_code",
|
||||
@@ -334,23 +365,34 @@ class Response:
|
||||
|
||||
def __init__(self, req, *, formats):
|
||||
self.req = req
|
||||
#: The HTTP Status Code to use for the Response.
|
||||
self.status_code: int | None = None
|
||||
self.content = None #: A bytes representation of the response body.
|
||||
self.content = None
|
||||
self.mimetype = None
|
||||
self.encoding = DEFAULT_ENCODING
|
||||
self.media = None #: A Python object that will be content-negotiated and
|
||||
#: sent back to the client. Typically, in JSON formatting.
|
||||
self.media = None
|
||||
self._stream = None
|
||||
self.headers = {} #: A Python dictionary of ``{key: value}``,
|
||||
#: representing the headers of the response.
|
||||
self.headers = {}
|
||||
self.formats = formats
|
||||
self.cookies: SimpleCookie = SimpleCookie() #: The cookies set in the Response
|
||||
self.session = (
|
||||
req.session
|
||||
) #: The cookie-based session data, in dict form, to add to the Response.
|
||||
self.cookies: SimpleCookie = SimpleCookie()
|
||||
self.session = req.session
|
||||
|
||||
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)
|
||||
@@ -451,6 +493,12 @@ class Response:
|
||||
self.mimetype = guessed or "application/octet-stream"
|
||||
|
||||
def redirect(self, location, *, set_text=True, status_code=HTTP_301):
|
||||
"""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``).
|
||||
"""
|
||||
self.status_code = status_code
|
||||
if set_text:
|
||||
self.text = f"Redirecting to: {location}"
|
||||
@@ -496,6 +544,25 @@ class Response:
|
||||
secure=False,
|
||||
httponly=True,
|
||||
):
|
||||
"""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.
|
||||
|
||||
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:
|
||||
@@ -534,10 +601,12 @@ class Response:
|
||||
|
||||
@property
|
||||
def ok(self):
|
||||
"""``True`` if the status code is in the 2xx range (success)."""
|
||||
return 200 <= self.status_code_safe < 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
|
||||
|
||||
@@ -62,6 +62,12 @@ class BaseRoute:
|
||||
|
||||
|
||||
class Route(BaseRoute):
|
||||
"""An HTTP route that maps a URL pattern to an endpoint.
|
||||
|
||||
Supports path parameters with type convertors (``{id:int}``, ``{slug:str}``,
|
||||
``{pk:uuid}``, ``{value:float}``, ``{rest:path}``).
|
||||
"""
|
||||
|
||||
def __init__(self, route, endpoint, *, before_request=False, methods=None):
|
||||
assert route.startswith("/"), "Route path must start with '/'"
|
||||
self.route = route
|
||||
@@ -197,6 +203,8 @@ class Route(BaseRoute):
|
||||
|
||||
|
||||
class WebSocketRoute(BaseRoute):
|
||||
"""A WebSocket route that maps a URL pattern to a WebSocket handler."""
|
||||
|
||||
def __init__(self, route, endpoint, *, before_request=False):
|
||||
assert route.startswith("/"), "Route path must start with '/'"
|
||||
self.route = route
|
||||
@@ -253,6 +261,12 @@ class WebSocketRoute(BaseRoute):
|
||||
|
||||
|
||||
class Router:
|
||||
"""The core router that dispatches incoming requests to matching routes.
|
||||
|
||||
Handles route matching, before/after request hooks, lifespan events,
|
||||
and mounted sub-applications.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, routes=None, default_response=None, before_requests=None, lifespan=None
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user