Compare commits

...

14 Commits

Author SHA1 Message Date
kennethreitz ce3ab46d59 Bump version to 3.5.0 and update changelog
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:27:36 -04:00
kennethreitz 6f9c87d71c Fix broad exception handling and future.result() call
- Call future.result() instead of bare property access in test (#596)
- Catch (ValueError, TypeError) instead of broad Exception in
  response model serialization (#597)
- Catch WebSocketDisconnect instead of broad Exception in
  websocket chat example (#598)

Closes #596, closes #597, closes #598

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:25:12 -04:00
kennethreitz 29d0621d98 Replace deprecated asyncio.iscoroutinefunction with inspect.iscoroutinefunction
Closes #599

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:23:24 -04:00
kennethreitz 30fa2dfda7 Add uv.lock
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:22:06 -04:00
kennethreitz 43c803a426 Restore print statements in lifespan example
These serve as illustrative markers for users reading the example.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:21:21 -04:00
Andreas Motl ff6d530338 Chore: Code formatting (#594)
## About
A few cosmetic adjustments aka. code formatting.
Also validate the outcome on CI/GHA.
Feel free to improve now or later at your disposal.

## Details
The updates are based on using the most recent versions of pyproject-fmt
and ruff.
Specifically, spots marked with `noqa` might need further love, also at
your disposal.

---------

Co-authored-by: Kenneth Reitz <me@kennethreitz.org>
2026-03-24 15:21:04 -04:00
kennethreitz a375984310 Fix WSGI mount returning 400 at mount root (#600)
## Summary
- When a WSGI app (e.g. Flask) is mounted at `/prefix` and a request
hits exactly `/prefix`, the path prefix was stripped to `""` instead of
`"/"`, causing frameworks like Flask to return 400.
- One-character fix: default the stripped path to `"/"` when empty.

## Test plan
- [x] `tests/test_responder.py::test_mount_wsgi_app` now passes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:16:29 -04:00
Andreas Motl 46c6f440c5 Chore: Remove configuration for Read the Docs (#595)
https://responder.kennethreitz.org/ is here to stay.
This patch concludes the decision in GH-564.
2026-03-23 20:19:15 -04:00
Andreas Motl c87e8c876d CI: Validate on Python 3.14 vanilla, free-threaded, and PyPy (#593) 2026-03-23 18:28:27 -04:00
kennethreitz f86c7eed70 Fix RST title underline warning breaking docs CI
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 22:10:51 -04:00
kennethreitz 9d492a383c Add marimo notebook mounting docs and example
- Document mounting marimo ASGI apps in the feature tour
- Add examples/marimo_mount.py showing the integration
- Verified working: marimo.create_asgi_app() mounts cleanly via api.mount()

Fixes #580.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 22:09:01 -04:00
kennethreitz 77ae49aaef Improve GraphQL API interface, expand tests, drop 3.9 from CI
- Extract variables and operationName from query params and form data,
  not just JSON bodies. Fixes #571.
- Add docstrings to GraphQLView class and methods. Fixes #572.
- Add 10 new GraphQL tests: variables, operationName, mutations,
  context access, malformed queries, raw text, invalid variables
  param. Fixes #568.
- Remove Python 3.9 from CI matrix (dropped in 3.4.0).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 22:06:00 -04:00
kennethreitz 74c872ed57 Add type annotations to routes.py
Add comprehensive type hints to compile_path, BaseRoute, Route,
WebSocketRoute, and Router classes. Uses Starlette's Scope, Receive,
Send types and properly types the ASGI/WSGI union in Router.apps.
Fixes #566.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 21:47:21 -04:00
kennethreitz 724b769c9e Fix OpenAPI template packaging, add static file tests
- Include ext/openapi/docs/*.html in package_data so OpenAPI docs
  themes (swagger_ui, redoc, rapidoc, elements) ship with the wheel.
  Fixes #583.
- Add tests for static file serving and index.html fallback. Fixes #563.
- Bump version to 3.4.1.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 21:44:02 -04:00
21 changed files with 4003 additions and 202 deletions
+3 -1
View File
@@ -20,11 +20,13 @@ jobs:
fail-fast: false
matrix:
python-version: [
"3.9",
"3.10",
"3.11",
"3.12",
"3.13",
"3.14",
"3.14t",
"pypy3.11",
]
env:
UV_SYSTEM_PYTHON: true
-33
View File
@@ -1,33 +0,0 @@
# .readthedocs.yml
# Read the Docs configuration file
# Details
# - https://docs.readthedocs.io/en/stable/config-file/v2.html
# Required
version: 2
build:
os: "ubuntu-24.04"
tools:
python: "3.12"
python:
install:
- method: pip
path: .
extra_requirements:
- docs
sphinx:
configuration: docs/source/conf.py
# Use standard HTML builder.
builder: html
# Fail on all warnings to avoid broken references.
fail_on_warning: true
# Optionally build your docs in additional formats such as PDF
#formats:
# - pdf
+56 -1
View File
@@ -7,6 +7,57 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
## [Unreleased]
## [v3.5.0] - 2026-03-24
### Added
- CI validation for Python 3.14, 3.14 free-threaded, and PyPy 3.11
- Marimo notebook mounting docs and example
- Type annotations for `routes.py`
- `uv.lock` for reproducible installs
### Changed
- Replaced deprecated `asyncio.iscoroutinefunction` with `inspect.iscoroutinefunction` ahead of Python 3.16 removal
- Narrowed broad `except Exception` to specific exceptions in response model serialization and websocket chat example
- Improved GraphQL API interface with expanded test coverage
- Code formatting cleanup via pyproject-fmt and ruff
- Dropped Python 3.9 from CI
### Fixed
- WSGI mount returning 400 when requesting the exact mount root path
- Werkzeug 3.1.7 compatibility for trusted host validation in tests
- `future.result` bare property access in background task test (now properly calls `future.result()`)
- OpenAPI template packaging and static file serving
- RST title underline warning breaking docs CI
### Removed
- Read the Docs configuration (docs hosted on GitHub Pages)
## [v3.4.0] - 2026-03-22
### Changed
- Upgraded to Starlette 1.0
- Added comprehensive docstrings across the codebase
- Expanded API reference documentation
## [v3.3.0] - 2026-03-22
### Added
- Full documentation rewrite: tutorials for REST APIs, SQLAlchemy, Flask migration
- Auth, WebSocket, middleware, and configuration guides
- Testing docs with prose, examples, and tips
- GitHub Pages deployment for docs
### Changed
- Reworked homepage prose
- Rewrote CLI and API reference docs
## [v3.2.0] - 2026-03-22
### Added
@@ -411,7 +462,11 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
- Conception!
[unreleased]: https://github.com/kennethreitz/responder/compare/v3.0.0..HEAD
[unreleased]: https://github.com/kennethreitz/responder/compare/v3.5.0..HEAD
[v3.5.0]: https://github.com/kennethreitz/responder/compare/v3.4.0..v3.5.0
[v3.4.0]: https://github.com/kennethreitz/responder/compare/v3.3.0..v3.4.0
[v3.3.0]: https://github.com/kennethreitz/responder/compare/v3.2.0..v3.3.0
[v3.2.0]: https://github.com/kennethreitz/responder/compare/v3.0.0..v3.2.0
[v3.0.0]: https://github.com/kennethreitz/responder/compare/v2.0.5..v3.0.0
[v2.0.5]: https://github.com/kennethreitz/responder/compare/v2.0.4..v2.0.5
[v2.0.4]: https://github.com/kennethreitz/responder/compare/v2.0.3..v2.0.4
+16
View File
@@ -416,6 +416,22 @@ Requests to ``/flask/`` will be handled by Flask. Everything else goes
through Responder. Both WSGI and ASGI apps are supported — Responder
wraps WSGI apps in an ASGI adapter automatically.
You can also mount `marimo <https://marimo.io/>`_ notebooks as
interactive dashboards within your API::
import marimo
server = (
marimo.create_asgi_app()
.with_app(path="", root="./notebooks/dashboard.py")
.with_app(path="/analysis", root="./notebooks/analysis.py")
)
api.mount("/notebooks", server.build())
Notebooks are served at ``/notebooks/`` and ``/notebooks/analysis``,
with full interactivity — reactive cells, widgets, plots, and all.
Cookies
-------
+1 -1
View File
@@ -1,5 +1,5 @@
Writing Middleware
=================
==================
Middleware sits between the server and your route handlers, processing
every request and response that flows through your application. It's the
+31
View File
@@ -0,0 +1,31 @@
"""Mount marimo notebooks inside a Responder API.
Requirements:
pip install responder marimo
Usage:
python examples/marimo_mount.py
Then visit:
http://127.0.0.1:5042/hello → Responder JSON endpoint
http://127.0.0.1:5042/notebooks/ → Interactive marimo notebook
"""
import marimo
import responder
api = responder.API()
@api.route("/hello")
def hello(req, resp):
resp.media = {"message": "Hello from Responder!"}
# Mount marimo notebooks at /notebooks
server = marimo.create_asgi_app().with_app(path="", root="notebooks/hello.py")
api.mount("/notebooks", server.build())
if __name__ == "__main__":
api.run()
+9 -3
View File
@@ -1,8 +1,9 @@
# Complete REST API example with Pydantic validation.
# https://responder.kennethreitz.org/tutorial-rest.html
import responder
from pydantic import BaseModel
import responder
class BookIn(BaseModel):
title: str
@@ -35,8 +36,13 @@ def list_books(req, resp):
resp.media = list(books_db.values())
@api.route("/books", methods=["POST"], check_existing=False,
request_model=BookIn, response_model=BookOut)
@api.route(
"/books",
methods=["POST"],
check_existing=False,
request_model=BookIn,
response_model=BookOut,
)
async def create_book(req, resp):
global next_id
data = await req.media()
+4 -2
View File
@@ -1,5 +1,7 @@
# WebSocket chat room example.
# https://responder.kennethreitz.org/tutorial-websockets.html
from starlette.websockets import WebSocketDisconnect
import responder
api = responder.API()
@@ -35,7 +37,7 @@ def index(req, resp):
</script>
</body>
</html>
"""
""" # noqa: E501
@api.route("/chat", websocket=True)
@@ -47,7 +49,7 @@ async def chat(ws):
message = await ws.receive_text()
for client in connected:
await client.send_text(message)
except Exception:
except WebSocketDisconnect:
pass
finally:
connected.discard(ws)
+56 -72
View File
@@ -8,7 +8,7 @@ requires = [
name = "responder"
description = "A familiar HTTP Service Framework for Python."
readme = "README.md"
license = {text = "Apache 2.0"}
license = { text = "Apache 2.0" }
authors = [
{ name = "Kenneth Reitz", email = "me@kennethreitz.org" },
]
@@ -20,18 +20,21 @@ classifiers = [
"License :: OSI Approved :: Apache Software License",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: Free Threading",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
"Topic :: Internet :: WWW/HTTP",
]
dynamic = ["version"]
dynamic = [ "version" ]
dependencies = [
"a2wsgi",
"apispec>=1.0.0",
"apispec>=1",
"chardet",
"docopt-ng",
"graphene>=3",
@@ -41,17 +44,15 @@ dependencies = [
"pueblo[sfa-full]>=0.0.11",
"pydantic>=2",
"python-multipart",
"starlette[full]>=1.0",
"starlette[full]>=1",
"uvicorn[standard]",
]
[project.optional-dependencies]
develop = [
optional-dependencies.develop = [
"pyproject-fmt",
"ruff",
"validate-pyproject",
]
docs = [
optional-dependencies.docs = [
"alabaster<1.1",
"myst-parser",
"sphinx>=5,<9",
@@ -59,8 +60,8 @@ docs = [
"sphinx-copybutton",
"sphinx-design-elements",
]
release = ["build", "twine"]
test = [
optional-dependencies.release = [ "build", "twine" ]
optional-dependencies.test = [
"flask",
"mypy",
"pytest",
@@ -68,32 +69,22 @@ test = [
"pytest-mock",
"pytest-rerunfailures",
]
urls.Documentation = "https://responder.kennethreitz.org"
urls.Homepage = "https://github.com/kennethreitz/responder"
urls.Issues = "https://github.com/kennethreitz/responder/issues"
urls.Repository = "https://github.com/kennethreitz/responder"
scripts.responder = "responder.ext.cli:cli"
[project.scripts]
responder = "responder.ext.cli:cli"
[project.urls]
Homepage = "https://github.com/kennethreitz/responder"
Documentation = "https://responder.kennethreitz.org"
Repository = "https://github.com/kennethreitz/responder"
Issues = "https://github.com/kennethreitz/responder/issues"
[tool.setuptools.dynamic]
version = {attr = "responder.__version__.__version__"}
[tool.setuptools.package-data]
responder = ["py.typed"]
[tool.setuptools.packages.find]
exclude = ["tests"]
[tool.setuptools]
dynamic.version = { attr = "responder.__version__.__version__" }
package-data.responder = [ "py.typed", "ext/openapi/docs/*.html" ]
packages.find.exclude = [ "tests" ]
[tool.ruff]
line-length = 90
extend-exclude = [
"docs/source/conf.py",
]
lint.select = [
# Builtins
"A",
@@ -121,59 +112,20 @@ lint.select = [
# flake8-2020
"YTT",
]
lint.extend-ignore = [
"S101", # Allow use of `assert`.
"S101", # Allow use of `assert`.
]
lint.per-file-ignores."responder/util/cmd.py" = [ "A005" ] # Module shadows a Python standard-library module
lint.per-file-ignores."responder/util/cmd.py" = [ "A005" ] # Module shadows a Python standard-library module
lint.per-file-ignores."tests/*" = [
"ERA001", # Found commented-out code.
"S101", # Allow use of `assert`, and `print`.
]
[tool.pytest.ini_options]
addopts = """
-rfEXs -p pytester --strict-markers --verbosity=3
--cov --cov-report=term-missing --cov-report=xml
"""
filterwarnings = [
"error::UserWarning",
]
log_level = "DEBUG"
log_cli_level = "DEBUG"
log_format = "%(asctime)-15s [%(name)-36s] %(levelname)-8s: %(message)s"
minversion = "2.0"
testpaths = [
"responder",
"tests",
]
markers = [
]
xfail_strict = true
[tool.coverage.run]
branch = false
omit = [
"*.html",
"tests/*",
]
[tool.coverage.report]
fail_under = 0
show_missing = true
exclude_lines = [
"# pragma: no cover",
"raise NotImplemented",
]
[tool.mypy]
packages = [
"responder",
]
exclude = [
]
exclude = []
check_untyped_defs = true
explicit_package_bases = true
ignore_missing_imports = true
@@ -181,3 +133,35 @@ implicit_optional = true
install_types = true
namespace_packages = true
non_interactive = true
[tool.pytest]
ini_options.addopts = """
-rfEXs -p pytester --strict-markers --verbosity=3
--cov --cov-report=term-missing --cov-report=xml
"""
ini_options.filterwarnings = [
"error::UserWarning",
]
ini_options.log_level = "DEBUG"
ini_options.log_cli_level = "DEBUG"
ini_options.log_format = "%(asctime)-15s [%(name)-36s] %(levelname)-8s: %(message)s"
ini_options.minversion = "2.0"
ini_options.testpaths = [
"responder",
"tests",
]
ini_options.markers = []
ini_options.xfail_strict = true
[tool.coverage]
run.branch = false
run.omit = [
"*.html",
"tests/*",
]
report.exclude_lines = [
"# pragma: no cover",
"raise NotImplemented",
]
report.fail_under = 0
report.show_missing = true
+1 -1
View File
@@ -1 +1 @@
__version__ = "3.4.0"
__version__ = "3.5.0"
+4 -5
View File
@@ -1,4 +1,5 @@
import asyncio
import inspect
import os
from pathlib import Path
@@ -161,9 +162,7 @@ class API:
import uuid as _uuid
def _add_request_id(req, resp):
rid = req.headers.get(
"X-Request-ID", str(_uuid.uuid4())
)
rid = req.headers.get("X-Request-ID", str(_uuid.uuid4()))
resp.headers["X-Request-ID"] = rid
self.router.after_request(_add_request_id)
@@ -258,7 +257,7 @@ class API:
req = Request(request.scope, request.receive, formats=get_formats())
resp = Response(req=req, formats=get_formats())
if asyncio.iscoroutinefunction(func):
if inspect.iscoroutinefunction(func):
await func(req, resp, exc)
else:
func(req, resp, exc)
@@ -562,7 +561,7 @@ class API:
"""Run the application. Shorthand for :meth:`serve` that inherits the ``debug`` setting.
:param kwargs: Keyword arguments passed through to :meth:`serve`.
"""
""" # noqa: E501
if "debug" not in kwargs:
kwargs.update({"debug": self.debug})
self.serve(**kwargs)
+2 -1
View File
@@ -1,5 +1,6 @@
import asyncio
import concurrent.futures
import inspect
import multiprocessing
import traceback
@@ -76,6 +77,6 @@ class BackgroundQueue:
return do_task
async def __call__(self, func, *args, **kwargs) -> None:
if asyncio.iscoroutinefunction(func):
if inspect.iscoroutinefunction(func):
return await asyncio.create_task(func(*args, **kwargs))
return await run_in_threadpool(func, *args, **kwargs)
+47 -2
View File
@@ -4,12 +4,43 @@ from .templates import GRAPHIQL
class GraphQLView:
"""A class-based view that serves a GraphQL API.
Handles query resolution from multiple sources (JSON body, query
parameters, raw request text) and renders the GraphiQL IDE for
browser requests.
:param api: The Responder API instance.
:param schema: A Graphene schema instance.
"""
def __init__(self, *, api, schema):
self.api = api
self.schema = schema
@staticmethod
def _parse_variables(raw):
"""Parse variables from a string (query param) or return as-is (dict)."""
if raw is None:
return None
if isinstance(raw, str):
try:
return json.loads(raw)
except (json.JSONDecodeError, ValueError):
return None
return raw
@staticmethod
async def _resolve_graphql_query(req, resp):
"""Extract query, variables, and operationName from the request.
Supports multiple input sources, checked in order:
1. JSON body (``Content-Type: application/json``)
2. Form data (``Content-Type: application/x-www-form-urlencoded``)
3. Query parameters (``?query=...&variables=...&operationName=...``)
4. Raw request text
"""
if "json" in req.mimetype:
json_media = await req.media("json")
if "query" not in json_media:
@@ -22,9 +53,22 @@ class GraphQLView:
json_media.get("operationName"),
)
# Support query/q in params.
if "form" in req.mimetype:
form_data = await req.media("form")
if "query" in form_data:
return (
form_data["query"],
GraphQLView._parse_variables(form_data.get("variables")),
form_data.get("operationName"),
)
# Support query/variables/operationName in query params.
if "query" in req.params:
return req.params["query"], None, None
return (
req.params["query"],
GraphQLView._parse_variables(req.params.get("variables")),
req.params.get("operationName"),
)
if "q" in req.params:
return req.params["q"], None, None
@@ -32,6 +76,7 @@ class GraphQLView:
return await req.text, None, None
async def graphql_response(self, req, resp):
"""Process a GraphQL request and populate the response."""
show_graphiql = req.method == "get" and req.accepts("text/html")
if show_graphiql:
+3 -1
View File
@@ -129,7 +129,9 @@ class OpenAPISchema:
op["requestBody"] = {
"content": {
"application/json": {
"schema": {"$ref": f"#/components/schemas/{model_name}"}
"schema": {
"$ref": f"#/components/schemas/{model_name}"
}
}
}
}
+1 -3
View File
@@ -38,9 +38,7 @@ class RateLimiter:
def _cleanup(self, key):
now = time.time()
cutoff = now - self.period
self._buckets[key] = [
t for t in self._buckets[key] if t > cutoff
]
self._buckets[key] = [t for t in self._buckets[key] if t > cutoff]
def check(self, req, resp):
"""Check rate limit. Sets 429 status if exceeded."""
+81 -56
View File
@@ -1,14 +1,18 @@
from __future__ import annotations
import asyncio
import inspect
import re
import traceback
from collections import defaultdict
from collections.abc import Callable
from typing import Any, Union
__all__ = ["Route", "WebSocketRoute", "Router"]
from starlette.concurrency import run_in_threadpool
from starlette.exceptions import HTTPException
from starlette.types import ASGIApp
from starlette.types import ASGIApp, Receive, Scope, Send
from starlette.websockets import WebSocket, WebSocketClose
from . import status_codes
@@ -28,9 +32,9 @@ _CONVERTORS = {
PARAM_RE = re.compile("{([a-zA-Z_][a-zA-Z0-9_]*)(:[a-zA-Z_][a-zA-Z0-9_]*)?}")
def compile_path(path):
def compile_path(path: str) -> tuple[re.Pattern, dict[str, type]]:
path_re = "^"
param_convertors = {}
param_convertors: dict[str, type] = {}
idx = 0
for match in PARAM_RE.finditer(path):
@@ -54,10 +58,10 @@ def compile_path(path):
class BaseRoute:
def matches(self, scope):
def matches(self, scope: Scope) -> tuple[bool, dict]:
raise NotImplementedError()
async def __call__(self, scope, receive, send):
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
raise NotImplementedError()
@@ -68,32 +72,41 @@ class Route(BaseRoute):
``{pk:uuid}``, ``{value:float}``, ``{rest:path}``).
"""
def __init__(self, route, endpoint, *, before_request=False, methods=None):
def __init__(
self,
route: str,
endpoint: Callable,
*,
before_request: bool = False,
methods: list[str] | None = None,
) -> None:
assert route.startswith("/"), "Route path must start with '/'"
self.route = route
self.endpoint = endpoint
self.before_request = before_request
self.methods = {m.upper() for m in methods} if methods else None
self.methods: set[str] | None = {m.upper() for m in methods} if methods else None
self.path_re: re.Pattern
self.param_convertors: dict[str, type]
self.path_re, self.param_convertors = compile_path(route)
# Strip type annotations for URL generation (e.g. {id:int} -> {id})
self._url_template = PARAM_RE.sub(r"{\1}", route)
def __repr__(self):
def __repr__(self) -> str:
return f"<Route {self.route!r}={self.endpoint!r}>"
def url(self, **params):
def url(self, **params: Any) -> str:
return self._url_template.format(**params)
@property
def endpoint_name(self):
def endpoint_name(self) -> str:
return self.endpoint.__name__
@property
def description(self):
def description(self) -> str | None:
return self.endpoint.__doc__
def matches(self, scope):
def matches(self, scope: Scope) -> tuple[bool, dict]:
if scope["type"] != "http":
return False, {}
@@ -112,7 +125,7 @@ class Route(BaseRoute):
return True, {"path_params": {**matched_params}}
async def __call__(self, scope, receive, send):
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
request = Request(scope, receive, formats=get_formats())
response = Response(req=request, formats=get_formats())
@@ -120,7 +133,7 @@ class Route(BaseRoute):
before_requests = scope.get("before_requests", [])
for before_request in before_requests.get("http", []):
if asyncio.iscoroutinefunction(before_request):
if inspect.iscoroutinefunction(before_request):
await before_request(request, response)
else:
await run_in_threadpool(before_request, request, response)
@@ -166,7 +179,7 @@ class Route(BaseRoute):
for view in views:
# Check __call__ for class-based views (e.g. GraphQL)
if asyncio.iscoroutinefunction(view) or asyncio.iscoroutinefunction(
if inspect.iscoroutinefunction(view) or inspect.iscoroutinefunction(
view.__call__
):
await view(request, response, **path_params)
@@ -179,13 +192,13 @@ class Route(BaseRoute):
try:
validated = resp_model(**response.media)
response.media = validated.model_dump()
except Exception:
except (ValueError, TypeError):
pass # Don't break the response if serialization fails
# Run after-request hooks
after_requests = scope.get("after_requests", [])
for after_request in after_requests:
if asyncio.iscoroutinefunction(after_request):
if inspect.iscoroutinefunction(after_request):
await after_request(request, response)
else:
await run_in_threadpool(after_request, request, response)
@@ -195,40 +208,46 @@ class Route(BaseRoute):
await response(scope, receive, send)
def __eq__(self, other):
def __eq__(self, other: object) -> bool:
if not isinstance(other, Route):
return NotImplemented
return self.route == other.route and self.endpoint == other.endpoint
def __hash__(self):
def __hash__(self) -> int:
return hash(self.route) ^ hash(self.endpoint) ^ hash(self.before_request)
class WebSocketRoute(BaseRoute):
"""A WebSocket route that maps a URL pattern to a WebSocket handler."""
def __init__(self, route, endpoint, *, before_request=False):
def __init__(
self, route: str, endpoint: Callable, *, before_request: bool = False
) -> None:
assert route.startswith("/"), "Route path must start with '/'"
self.route = route
self.endpoint = endpoint
self.before_request = before_request
self.path_re: re.Pattern
self.param_convertors: dict[str, type]
self.path_re, self.param_convertors = compile_path(route)
self._url_template = PARAM_RE.sub(r"{\1}", route)
def __repr__(self):
def __repr__(self) -> str:
return f"<Route {self.route!r}={self.endpoint!r}>"
def url(self, **params):
def url(self, **params: Any) -> str:
return self._url_template.format(**params)
@property
def endpoint_name(self):
def endpoint_name(self) -> str:
return self.endpoint.__name__
@property
def description(self):
def description(self) -> str | None:
return self.endpoint.__doc__
def matches(self, scope):
def matches(self, scope: Scope) -> tuple[bool, dict]:
if scope["type"] != "websocket":
return False, {}
@@ -244,7 +263,7 @@ class WebSocketRoute(BaseRoute):
return True, {"path_params": {**matched_params}}
async def __call__(self, scope, receive, send):
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
ws = WebSocket(scope, receive, send)
before_requests = scope.get("before_requests", [])
@@ -253,10 +272,12 @@ class WebSocketRoute(BaseRoute):
await self.endpoint(ws)
def __eq__(self, other):
def __eq__(self, other: object) -> bool:
if not isinstance(other, WebSocketRoute):
return NotImplemented
return self.route == other.route and self.endpoint == other.endpoint
def __hash__(self):
def __hash__(self) -> int:
return hash(self.route) ^ hash(self.endpoint) ^ hash(self.before_request)
@@ -268,32 +289,36 @@ class Router:
"""
def __init__(
self, routes=None, default_response=None, before_requests=None, lifespan=None
):
self.routes = [] if routes is None else list(routes)
self,
routes: list[BaseRoute] | None = None,
default_response: Callable | None = None,
before_requests: dict[str, list[Callable]] | None = None,
lifespan: Callable | None = None,
) -> None:
self.routes: list[BaseRoute] = [] if routes is None else list(routes)
self.apps: dict[str, ASGIApp] = {}
self.default_endpoint = (
self.apps: dict[str, Union[ASGIApp, Any]] = {}
self.default_endpoint: Callable = (
self.default_response if default_response is None else default_response
)
self.before_requests = (
self.before_requests: dict[str, list[Callable]] = (
{"http": [], "ws": []} if before_requests is None else before_requests
)
self.after_requests: list = []
self.events = defaultdict(list)
self.after_requests: list[Callable] = []
self.events: defaultdict[str, list[Callable]] = defaultdict(list)
self._lifespan_handler = lifespan
def add_route(
self,
route=None,
endpoint=None,
route: str | None = None,
endpoint: Callable | None = None,
*,
default=False,
websocket=False,
before_request=False,
check_existing=False,
methods=None,
):
default: bool = False,
websocket: bool = False,
before_request: bool = False,
check_existing: bool = False,
methods: list[str] | None = None,
) -> None:
"""Adds a route to the router.
:param route: A string representation of the route
:param endpoint: The endpoint for the route -- can be callable, or class.
@@ -322,40 +347,40 @@ class Router:
self.routes.append(route)
def mount(self, route, app):
def mount(self, route: str, app: Any) -> None:
"""Mounts ASGI / WSGI applications at a given route"""
self.apps.update({route: app})
def add_event_handler(self, event_type, handler):
def add_event_handler(self, event_type: str, handler: Callable) -> None:
assert event_type in (
"startup",
"shutdown",
), f"Only 'startup' and 'shutdown' events are supported, not {event_type}."
self.events[event_type].append(handler)
async def trigger_event(self, event_type):
async def trigger_event(self, event_type: str) -> None:
for handler in self.events.get(event_type, []):
if asyncio.iscoroutinefunction(handler):
if inspect.iscoroutinefunction(handler):
await handler()
else:
handler()
def before_request(self, endpoint, websocket=False):
def before_request(self, endpoint: Callable, websocket: bool = False) -> None:
if websocket:
self.before_requests.setdefault("ws", []).append(endpoint)
else:
self.before_requests.setdefault("http", []).append(endpoint)
def after_request(self, endpoint):
def after_request(self, endpoint: Callable) -> None:
self.after_requests.append(endpoint)
def url_for(self, endpoint, **params):
def url_for(self, endpoint: Callable | str, **params: Any) -> str | None:
for route in self.routes:
if endpoint in (route.endpoint, route.endpoint.__name__):
return route.url(**params)
return None
async def default_response(self, scope, receive, send):
async def default_response(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] == "websocket":
websocket_close = WebSocketClose()
await websocket_close(scope, receive, send)
@@ -366,7 +391,7 @@ class Router:
raise HTTPException(status_code=status_codes.HTTP_404) # type: ignore[attr-defined]
def _resolve_route(self, scope):
def _resolve_route(self, scope: Scope) -> BaseRoute | None:
for route in self.routes:
matches, child_scope = route.matches(scope)
if matches:
@@ -374,7 +399,7 @@ class Router:
return route
return None
async def lifespan(self, scope, receive, send):
async def lifespan(self, scope: Scope, receive: Receive, send: Send) -> None:
message = await receive()
assert message["type"] == "lifespan.startup"
@@ -409,7 +434,7 @@ class Router:
await send({"type": "lifespan.shutdown.complete"})
async def __call__(self, scope, receive, send):
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
assert scope["type"] in ("http", "websocket", "lifespan")
if scope["type"] == "lifespan":
@@ -432,7 +457,7 @@ class Router:
# Call into a submounted app, if one exists.
for path_prefix, app in self.apps.items():
if path.startswith(path_prefix):
scope["path"] = path[len(path_prefix) :]
scope["path"] = path[len(path_prefix) :] or "/"
scope["root_path"] = root_path + path_prefix
try:
await app(scope, receive, send)
+63 -11
View File
@@ -4,14 +4,14 @@ import time
import pytest
from starlette.testclient import TestClient as StarletteTestClient
from starlette.websockets import WebSocketDisconnect
import responder
from responder.background import BackgroundQueue
from responder.models import CaseInsensitiveDict, QueryDict, Response
from responder.models import QueryDict
from responder.routes import Route, WebSocketRoute
from responder.templates import Templates
# --- api.py coverage ---
@@ -78,7 +78,10 @@ def test_background_task_exception(capsys):
raise ValueError("task failed")
future = failing_task()
future.result # wait for completion
try:
future.result() # wait for completion
except ValueError:
pass
time.sleep(0.2) # let the done callback fire
captured = capsys.readouterr()
@@ -302,7 +305,7 @@ def test_yaml_content_negotiation(api):
def test_websocket_404(api):
"""Lines 308-310: WebSocket to unknown route gets closed."""
client = StarletteTestClient(api)
with pytest.raises(Exception):
with pytest.raises(WebSocketDisconnect):
with client.websocket_connect("ws://;/nonexistent"):
pass
@@ -325,9 +328,7 @@ def test_websocket_route_params():
pass
route = WebSocketRoute("/ws/{room_id:int}", handler)
matches, scope = route.matches(
{"type": "websocket", "path": "/ws/42"}
)
matches, scope = route.matches({"type": "websocket", "path": "/ws/42"})
assert matches is True
assert scope["path_params"] == {"room_id": 42}
@@ -591,7 +592,10 @@ def test_pydantic_schema():
from pydantic import BaseModel
api = responder.API(
title="Test", version="1.0", openapi="3.0.2", allowed_hosts=[";"],
title="Test",
version="1.0",
openapi="3.0.2",
allowed_hosts=[";"],
)
@api.schema("Pet")
@@ -611,7 +615,10 @@ def test_pydantic_request_response_models():
from pydantic import BaseModel
api = responder.API(
title="Test", version="1.0", openapi="3.0.2", allowed_hosts=[";"],
title="Test",
version="1.0",
openapi="3.0.2",
allowed_hosts=[";"],
)
class ItemIn(BaseModel):
@@ -623,8 +630,7 @@ def test_pydantic_request_response_models():
name: str
price: float
@api.route("/items", methods=["POST"],
request_model=ItemIn, response_model=ItemOut)
@api.route("/items", methods=["POST"], request_model=ItemIn, response_model=ItemOut)
async def create(req, resp):
data = await req.media()
resp.media = {"id": 1, **data}
@@ -660,3 +666,49 @@ def test_templates_context(tmp_path):
result = templates.render("test.html")
assert "hello" in result
assert "world" in result
def test_static_file_serving(tmp_path):
"""Verify static files are served correctly from the static directory."""
static_dir = tmp_path / "static"
static_dir.mkdir()
(static_dir / "style.css").write_text("body { color: red; }")
(static_dir / "app.js").write_text("console.log('hello');")
api = responder.API(
static_dir=str(static_dir),
static_route="/static",
allowed_hosts=[";"],
)
# CSS file served with correct content
r = api.requests.get("http://;/static/style.css")
assert r.status_code == 200
assert "body { color: red; }" in r.text
assert "text/css" in r.headers.get("content-type", "")
# JS file served with correct content
r = api.requests.get("http://;/static/app.js")
assert r.status_code == 200
assert "console.log" in r.text
# Missing file returns 404
r = api.requests.get("http://;/static/missing.txt")
assert r.status_code == 404
def test_static_index_fallback(tmp_path):
"""Verify static index.html is served as default route."""
static_dir = tmp_path / "static"
static_dir.mkdir()
(static_dir / "index.html").write_text("<h1>SPA</h1>")
api = responder.API(
static_dir=str(static_dir),
allowed_hosts=[";"],
)
api.add_route("/", static=True)
r = api.requests.get("http://;/")
assert r.status_code == 200
assert "<h1>SPA</h1>" in r.text
+171
View File
@@ -1,4 +1,6 @@
# ruff: noqa: E402
import json
import pytest
graphene = pytest.importorskip("graphene")
@@ -17,6 +19,45 @@ def schema():
return graphene.Schema(query=Query)
@pytest.fixture
def mutation_schema():
class Query(graphene.ObjectType):
hello = graphene.String(name=graphene.String(default_value="stranger"))
def resolve_hello(self, info, name):
return f"Hello {name}"
class CreateUser(graphene.Mutation):
class Arguments:
name = graphene.String(required=True)
ok = graphene.Boolean()
name = graphene.String()
def mutate(self, info, name):
return CreateUser(ok=True, name=name)
class Mutation(graphene.ObjectType):
create_user = CreateUser.Field()
return graphene.Schema(query=Query, mutation=Mutation)
@pytest.fixture
def multi_op_schema():
class Query(graphene.ObjectType):
hello = graphene.String(name=graphene.String(default_value="stranger"))
goodbye = graphene.String(name=graphene.String(default_value="stranger"))
def resolve_hello(self, info, name):
return f"Hello {name}"
def resolve_goodbye(self, info, name):
return f"Goodbye {name}"
return graphene.Schema(query=Query)
def test_graphql_schema_query_querying(api, schema):
api.add_route("/", GraphQLView(schema=schema, api=api))
r = api.requests.get("http://;/?q={ hello }", headers={"Accept": "json"})
@@ -63,3 +104,133 @@ def test_graphql_error_response(api, schema):
api.add_route("/", GraphQLView(schema=schema, api=api))
r = api.requests.post("http://;/", json={"query": "{ nonexistent }"})
assert "errors" in r.json()
def test_graphql_variables_json(api, schema):
"""Variables passed via JSON body."""
api.add_route("/", GraphQLView(schema=schema, api=api))
r = api.requests.post(
"http://;/",
json={
"query": "query Hello($name: String!) { hello(name: $name) }",
"variables": {"name": "Alice"},
},
)
assert r.json() == {"data": {"hello": "Hello Alice"}}
def test_graphql_variables_query_param(api, schema):
"""Variables passed as JSON string in query parameter."""
api.add_route("/", GraphQLView(schema=schema, api=api))
variables = json.dumps({"name": "Bob"})
r = api.requests.get(
f"http://;/?query=query Hello($name: String!) "
f"{{ hello(name: $name) }}&variables={variables}",
headers={"Accept": "json"},
)
assert r.json() == {"data": {"hello": "Hello Bob"}}
def test_graphql_operation_name_json(api, multi_op_schema):
"""operationName selects which operation to run."""
api.add_route("/", GraphQLView(schema=multi_op_schema, api=api))
query = """
query SayHello { hello }
query SayGoodbye { goodbye }
"""
r = api.requests.post(
"http://;/",
json={
"query": query,
"operationName": "SayHello",
},
)
data = r.json()
assert data["data"]["hello"] == "Hello stranger"
def test_graphql_operation_name_query_param(api, multi_op_schema):
"""operationName via query parameter."""
api.add_route("/", GraphQLView(schema=multi_op_schema, api=api))
query = "query SayHello { hello } query SayGoodbye { goodbye }"
r = api.requests.get(
f"http://;/?query={query}&operationName=SayGoodbye",
headers={"Accept": "json"},
)
data = r.json()
assert data["data"]["goodbye"] == "Goodbye stranger"
def test_graphql_mutation(api, mutation_schema):
"""Mutations work via JSON body."""
api.add_route("/", GraphQLView(schema=mutation_schema, api=api))
r = api.requests.post(
"http://;/",
json={
"query": 'mutation { createUser(name: "Eve") { ok name } }',
},
)
data = r.json()
assert data["data"]["createUser"]["ok"] is True
assert data["data"]["createUser"]["name"] == "Eve"
def test_graphql_mutation_with_variables(api, mutation_schema):
"""Mutations with variables."""
api.add_route("/", GraphQLView(schema=mutation_schema, api=api))
r = api.requests.post(
"http://;/",
json={
"query": "mutation CreateUser($name: String!) "
"{ createUser(name: $name) { ok name } }",
"variables": {"name": "Frank"},
},
)
data = r.json()
assert data["data"]["createUser"]["ok"] is True
assert data["data"]["createUser"]["name"] == "Frank"
def test_graphql_context_access(api):
"""Resolvers can access request and response via info.context."""
class Query(graphene.ObjectType):
method = graphene.String()
def resolve_method(self, info):
return info.context["request"].method
schema = graphene.Schema(query=Query)
api.add_route("/", GraphQLView(schema=schema, api=api))
r = api.requests.post("http://;/", json={"query": "{ method }"})
assert r.json() == {"data": {"method": "post"}}
def test_graphql_malformed_query(api, schema):
"""Malformed GraphQL syntax returns errors."""
api.add_route("/", GraphQLView(schema=schema, api=api))
r = api.requests.post("http://;/", json={"query": "{ this is not valid"})
data = r.json()
assert "errors" in data
assert len(data["errors"]) > 0
def test_graphql_raw_text_query(api, schema):
"""Query sent as raw text body."""
api.add_route("/", GraphQLView(schema=schema, api=api))
r = api.requests.post(
"http://;/",
content=b"{ hello }",
headers={"Content-Type": "text/plain"},
)
assert r.json() == {"data": {"hello": "Hello stranger"}}
def test_graphql_invalid_variables_query_param(api, schema):
"""Invalid JSON in variables query param is treated as None."""
api.add_route("/", GraphQLView(schema=schema, api=api))
r = api.requests.get(
"http://;/?query={ hello }&variables=not-json",
headers={"Accept": "json"},
)
assert r.json() == {"data": {"hello": "Hello stranger"}}
+5 -7
View File
@@ -1,13 +1,10 @@
"""Tests for new features: validation, SSE, after_request, route groups, etc."""
import pytest
from pydantic import BaseModel
from starlette.testclient import TestClient as StarletteTestClient
import responder
from responder.ext.ratelimit import RateLimiter
# --- Pydantic auto-validation ---
@@ -42,7 +39,9 @@ def test_pydantic_request_validation():
assert "errors" in r.json()
# Invalid request — wrong type
r = api.requests.post("http://;/items", json={"name": "widget", "price": "not_a_number"})
r = api.requests.post(
"http://;/items", json={"name": "widget", "price": "not_a_number"}
)
assert r.status_code == 422
@@ -50,8 +49,7 @@ def test_pydantic_response_serialization():
"""Auto-serialize response through response_model."""
api = responder.API(allowed_hosts=[";"])
@api.route("/items", methods=["POST"],
request_model=ItemIn, response_model=ItemOut)
@api.route("/items", methods=["POST"], request_model=ItemIn, response_model=ItemOut)
async def create(req, resp):
data = await req.media()
# Include an extra field that should be stripped by the model
@@ -257,7 +255,7 @@ def test_rate_limiter():
def view(req, resp):
resp.text = "ok"
for i in range(3):
for _i in range(3):
r = api.requests.get("http://;/")
assert r.status_code == 200
assert "X-RateLimit-Remaining" in r.headers
+5 -2
View File
@@ -546,14 +546,17 @@ def test_documentation(needs_openapi):
assert "html" in r.text
def test_mount_wsgi_app(api, flask):
def test_mount_wsgi_app(flask):
# Use localhost so Werkzeug's trusted-host check accepts the request.
api = responder.API(allowed_hosts=["localhost"])
@api.route("/")
def hello(req, resp):
resp.text = "hello"
api.mount("/flask", flask)
r = api.requests.get("http://;/flask")
r = api.requests.get("http://localhost/flask")
assert r.status_code < 300
Generated
+3444
View File
File diff suppressed because it is too large Load Diff