From 4f02016ed6d08222cc66fad2dbbc38fcd2a97eef Mon Sep 17 00:00:00 2001 From: Kenneth Reitz Date: Sun, 22 Mar 2026 21:37:36 -0400 Subject: [PATCH] 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) --- README.md | 2 +- docs/source/api.rst | 39 ++++++++++++++++++ docs/source/index.rst | 2 +- pyproject.toml | 5 +-- responder/__version__.py | 2 +- responder/api.py | 62 ++++++++++++++++++++++++++++ responder/background.py | 39 ++++++++++++++++++ responder/models.py | 89 +++++++++++++++++++++++++++++++++++----- responder/routes.py | 14 +++++++ 9 files changed, 238 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index ce18678..9b577b7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/source/api.rst b/docs/source/api.rst index fc7851f..7bc0506 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -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 ------------------- diff --git a/docs/source/index.rst b/docs/source/index.rst index c11eba3..6b9ba39 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -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:: diff --git a/pyproject.toml b/pyproject.toml index 2601999..2445631 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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]", ] diff --git a/responder/__version__.py b/responder/__version__.py index 88c513e..903a158 100644 --- a/responder/__version__.py +++ b/responder/__version__.py @@ -1 +1 @@ -__version__ = "3.3.0" +__version__ = "3.4.0" diff --git a/responder/api.py b/responder/api.py index d7e5086..785b101 100644 --- a/responder/api.py +++ b/responder/api.py @@ -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) diff --git a/responder/background.py b/responder/background.py index b2b2304..5bb2438 100644 --- a/responder/background.py +++ b/responder/background.py @@ -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() diff --git a/responder/models.py b/responder/models.py index 991f9b3..d12ba80 100644 --- a/responder/models.py +++ b/responder/models.py @@ -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 diff --git a/responder/routes.py b/responder/routes.py index 9ae271f..8cc03ed 100644 --- a/responder/routes.py +++ b/responder/routes.py @@ -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 ):