Compare commits

..

1 Commits

Author SHA1 Message Date
Andreas Motl 5055fe7974 CI: Validate on Python 3.15 2026-03-26 01:50:31 +01:00
14 changed files with 44 additions and 109 deletions
+5 -1
View File
@@ -25,12 +25,16 @@ jobs:
"3.12", "3.12",
"3.13", "3.13",
"3.14", "3.14",
"3.14t", "3.15",
"3.15t",
"pypy3.11", "pypy3.11",
] ]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@v7 uses: astral-sh/setup-uv@v7
-36
View File
@@ -5,40 +5,6 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and
this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [v3.6.2] - 2026-04-12
### Fixed
- GraphQL error responses now correctly return 400 status instead of always 200
- OpenAPI docs UI now respects custom `openapi_route` instead of hardcoding `/schema.yml`
- `before_requests` default type mismatch that could crash routes called outside the router
- Blocking synchronous file I/O in `Response.stream_file()` — now uses async I/O via anyio
- Memory leak in rate limiter (empty bucket keys never cleaned up)
- Race condition in rate limiter `check()` — added thread-safe locking
- WSGI fallback catching all `TypeError`s instead of just call-signature mismatches
- Pydantic request/response model validation crashing on non-dict bodies
- Test assertions that could never fail (`or True`, `< 500` patterns)
- `CaseInsensitiveDict` missing `__delitem__`, `pop`, and `setdefault` overrides
- `assert` used for input validation in OpenAPI extension (stripped by `python -O`)
- Potential XSS in GraphiQL template endpoint injection
- Dead `or ""` in media format detection logic
### Changed
- `DELETE` requests now participate in Pydantic request body validation
- Simplified status code category check to use chained comparison
### Removed
- Unused `method` parameter from `load_target()`
- Unused Node.js setup step from CI test workflow
## [v3.6.1] - 2026-04-12
### Added
- Configurable GZip compression via `gzip` parameter on `API()` (defaults to `True`)
## [v3.6.0] - 2026-03-24 ## [v3.6.0] - 2026-03-24
### Added ### Added
@@ -525,8 +491,6 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
- Conception! - Conception!
[v3.6.2]: https://github.com/kennethreitz/responder/compare/v3.6.1..v3.6.2
[v3.6.1]: https://github.com/kennethreitz/responder/compare/v3.6.0..v3.6.1
[v3.6.0]: https://github.com/kennethreitz/responder/compare/v3.5.0..v3.6.0 [v3.6.0]: https://github.com/kennethreitz/responder/compare/v3.5.0..v3.6.0
[v3.5.0]: https://github.com/kennethreitz/responder/compare/v3.4.0..v3.5.0 [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.4.0]: https://github.com/kennethreitz/responder/compare/v3.3.0..v3.4.0
+1
View File
@@ -26,6 +26,7 @@ classifiers = [
"Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.14",
"Programming Language :: Python :: 3.15",
"Programming Language :: Python :: Free Threading", "Programming Language :: Python :: Free Threading",
"Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy", "Programming Language :: Python :: Implementation :: PyPy",
+1 -1
View File
@@ -1 +1 @@
__version__ = "3.6.2" __version__ = "3.6.0"
+1 -6
View File
@@ -31,7 +31,6 @@ class API:
:param templates_dir: The directory to use for templates. Will be created for you if it doesn't already exist. :param templates_dir: The directory to use for templates. Will be created for you if it doesn't already exist.
:param auto_escape: If ``True``, HTML and XML templates will automatically be escaped. :param auto_escape: If ``True``, HTML and XML templates will automatically be escaped.
:param enable_hsts: If ``True``, send all responses to HTTPS URLs. :param enable_hsts: If ``True``, send all responses to HTTPS URLs.
:param gzip: If ``True`` (the default), compress responses with GZip.
:param openapi_theme: OpenAPI documentation theme, must be one of ``elements``, ``rapidoc``, ``redoc``, ``swagger_ui`` :param openapi_theme: OpenAPI documentation theme, must be one of ``elements``, ``rapidoc``, ``redoc``, ``swagger_ui``
""" # noqa: E501 """ # noqa: E501
@@ -61,7 +60,6 @@ class API:
allowed_hosts=None, allowed_hosts=None,
openapi_theme=DEFAULT_OPENAPI_THEME, openapi_theme=DEFAULT_OPENAPI_THEME,
lifespan=None, lifespan=None,
gzip=True,
request_id=False, request_id=False,
enable_logging=False, enable_logging=False,
): ):
@@ -88,7 +86,6 @@ class API:
:param allowed_hosts: List of allowed hostnames (e.g. ``["example.com"]``). Defaults to ``["*"]``. :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 openapi_theme: Documentation UI theme: ``"swagger_ui"``, ``"redoc"``, ``"rapidoc"``, or ``"elements"``.
:param lifespan: An async context manager for startup/shutdown logic. :param lifespan: An async context manager for startup/shutdown logic.
:param gzip: If ``True`` (the default), compress responses with GZip.
:param request_id: If ``True``, add ``X-Request-ID`` headers to all responses. :param request_id: If ``True``, add ``X-Request-ID`` headers to all responses.
:param enable_logging: If ``True``, enable structured logging with per-request context (request ID, method, path, client IP). :param enable_logging: If ``True``, enable structured logging with per-request context (request ID, method, path, client IP).
""" # noqa: E501 """ # noqa: E501
@@ -125,9 +122,7 @@ class API:
self.default_endpoint = None self.default_endpoint = None
self.app = ExceptionMiddleware(self.router, debug=debug) self.app = ExceptionMiddleware(self.router, debug=debug)
self.add_middleware(GZipMiddleware)
if gzip:
self.add_middleware(GZipMiddleware)
if self.hsts_enabled: if self.hsts_enabled:
self.add_middleware(HTTPSRedirectMiddleware) self.add_middleware(HTTPSRedirectMiddleware)
+2 -1
View File
@@ -101,7 +101,8 @@ class GraphQLView:
response_data["data"] = result.data response_data["data"] = result.data
resp.media = response_data resp.media = response_data
resp.status_code = 200 if not result.errors else 400 status_code = 200 if not result.errors else 400
return (query, json.dumps(response_data), status_code)
async def on_request(self, req, resp): async def on_request(self, req, resp):
await self.graphql_response(req, resp) await self.graphql_response(req, resp)
+1 -1
View File
@@ -25,7 +25,7 @@ GRAPHIQL = """
<script crossorigin src="//cdn.jsdelivr.net/npm/react-dom@{{ REACT_VERSION }}/umd/react-dom.production.min.js"></script> <script crossorigin src="//cdn.jsdelivr.net/npm/react-dom@{{ REACT_VERSION }}/umd/react-dom.production.min.js"></script>
<script src="//cdn.jsdelivr.net/npm/graphiql@{{ GRAPHIQL_VERSION }}/graphiql.min.js"></script> <script src="//cdn.jsdelivr.net/npm/graphiql@{{ GRAPHIQL_VERSION }}/graphiql.min.js"></script>
<script> <script>
const fetcher = GraphiQL.createFetcher({ url: {{ endpoint | tojson }} }); const fetcher = GraphiQL.createFetcher({ url: '{{ endpoint }}' });
const root = ReactDOM.createRoot(document.getElementById('graphiql')); const root = ReactDOM.createRoot(document.getElementById('graphiql'));
root.render(React.createElement(GraphiQL, { fetcher: fetcher })); root.render(React.createElement(GraphiQL, { fetcher: fetcher }));
</script> </script>
+4 -5
View File
@@ -174,8 +174,8 @@ class OpenAPISchema:
def add_schema(self, name, schema, check_existing=True): def add_schema(self, name, schema, check_existing=True):
"""Adds a marshmallow or Pydantic schema to the API specification.""" """Adds a marshmallow or Pydantic schema to the API specification."""
if check_existing: if check_existing:
if name in self.schemas or name in self.pydantic_schemas: assert name not in self.schemas
raise ValueError(f"Schema '{name}' is already registered") assert name not in self.pydantic_schemas
if _is_pydantic_model(schema): if _is_pydantic_model(schema):
self.pydantic_schemas[name] = schema self.pydantic_schemas[name] = schema
@@ -216,13 +216,12 @@ class OpenAPISchema:
f"{self.docs_theme}.html", f"{self.docs_theme}.html",
title=self.title, title=self.title,
version=self.version, version=self.version,
schema_url=self.openapi_route, schema_url="/schema.yml",
) )
def static_url(self, asset): def static_url(self, asset):
"""Given a static asset, return its URL path.""" """Given a static asset, return its URL path."""
if self.static_route is None: assert self.static_route is not None
raise RuntimeError("Cannot generate static URL: static_route is disabled")
return f"{self.static_route}/{str(asset)}" return f"{self.static_route}/{str(asset)}"
def docs_response(self, req, resp): def docs_response(self, req, resp):
+8 -15
View File
@@ -1,6 +1,5 @@
"""Simple in-memory rate limiter for Responder.""" """Simple in-memory rate limiter for Responder."""
import threading
import time import time
from collections import defaultdict from collections import defaultdict
@@ -29,7 +28,6 @@ class RateLimiter:
self.max_requests = requests self.max_requests = requests
self.period = period self.period = period
self._buckets: dict[str, list[float]] = defaultdict(list) self._buckets: dict[str, list[float]] = defaultdict(list)
self._lock = threading.Lock()
def _client_key(self, req): def _client_key(self, req):
client = req.client client = req.client
@@ -41,25 +39,20 @@ class RateLimiter:
now = time.time() now = time.time()
cutoff = now - self.period 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]
if not self._buckets[key]:
del self._buckets[key]
def check(self, req, resp): def check(self, req, resp):
"""Check rate limit. Sets 429 status if exceeded.""" """Check rate limit. Sets 429 status if exceeded."""
key = self._client_key(req) key = self._client_key(req)
self._cleanup(key)
with self._lock: if len(self._buckets[key]) >= self.max_requests:
self._cleanup(key) resp.status_code = 429
resp.media = {"error": "rate limit exceeded"}
if len(self._buckets[key]) >= self.max_requests: resp.headers["Retry-After"] = str(self.period)
resp.status_code = 429 return False
resp.media = {"error": "rate limit exceeded"}
resp.headers["Retry-After"] = str(self.period)
return False
self._buckets[key].append(time.time())
remaining = self.max_requests - len(self._buckets[key])
self._buckets[key].append(time.time())
remaining = self.max_requests - len(self._buckets[key])
resp.headers["X-RateLimit-Limit"] = str(self.max_requests) resp.headers["X-RateLimit-Limit"] = str(self.max_requests)
resp.headers["X-RateLimit-Remaining"] = str(remaining) resp.headers["X-RateLimit-Remaining"] = str(remaining)
return True return True
+4 -15
View File
@@ -34,21 +34,12 @@ class CaseInsensitiveDict(dict):
def __getitem__(self, key): def __getitem__(self, key):
return super().__getitem__(key.lower()) return super().__getitem__(key.lower())
def __delitem__(self, key):
super().__delitem__(key.lower())
def __contains__(self, key): def __contains__(self, key):
return super().__contains__(key.lower()) return super().__contains__(key.lower())
def get(self, key, default=None): def get(self, key, default=None):
return super().get(key.lower(), default) 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): def update(self, other=None, **kwargs):
if other: if other:
for key, value in other.items(): for key, value in other.items():
@@ -308,8 +299,8 @@ class Request:
""" """
if format is None: if format is None:
format = "yaml" if "yaml" in self.mimetype else "json" # noqa: A001 format = "yaml" if "yaml" in self.mimetype or "" else "json" # noqa: A001
format = "form" if "form" in self.mimetype else format # noqa: A001 format = "form" if "form" in self.mimetype or "" else format # noqa: A001
formatter: Callable formatter: Callable
if isinstance(format, str): if isinstance(format, str):
@@ -473,11 +464,9 @@ class Response:
self.mimetype = guessed or "application/octet-stream" self.mimetype = guessed or "application/octet-stream"
async def file_generator(): async def file_generator():
import anyio with open(path, "rb") as f:
async with await anyio.open_file(path, "rb") as f:
while True: while True:
chunk = await f.read(chunk_size) chunk = f.read(chunk_size)
if not chunk: if not chunk:
break break
yield chunk yield chunk
+10 -21
View File
@@ -130,7 +130,7 @@ class Route(BaseRoute):
response = Response(req=request, formats=get_formats()) response = Response(req=request, formats=get_formats())
path_params = scope.get("path_params", {}) path_params = scope.get("path_params", {})
before_requests = scope.get("before_requests", {"http": [], "ws": []}) before_requests = scope.get("before_requests", [])
for before_request in before_requests.get("http", []): for before_request in before_requests.get("http", []):
if inspect.iscoroutinefunction(before_request): if inspect.iscoroutinefunction(before_request):
@@ -144,11 +144,9 @@ class Route(BaseRoute):
# Auto-validate request body with Pydantic model # Auto-validate request body with Pydantic model
req_model = getattr(self.endpoint, "_request_model", None) req_model = getattr(self.endpoint, "_request_model", None)
if req_model is not None and request.method in ("post", "put", "patch", "delete"): if req_model is not None and request.method in ("post", "put", "patch"):
try: try:
body = await request.media() body = await request.media()
if not isinstance(body, dict):
raise TypeError("Request body must be a JSON object")
req_model(**body) req_model(**body)
except Exception as exc: except Exception as exc:
response.status_code = 422 response.status_code = 422
@@ -190,7 +188,7 @@ class Route(BaseRoute):
# Auto-serialize response with Pydantic model # Auto-serialize response with Pydantic model
resp_model = getattr(self.endpoint, "_response_model", None) resp_model = getattr(self.endpoint, "_response_model", None)
if resp_model is not None and isinstance(response.media, dict): if resp_model is not None and response.media is not None:
try: try:
validated = resp_model(**response.media) validated = resp_model(**response.media)
response.media = validated.model_dump() response.media = validated.model_dump()
@@ -268,7 +266,7 @@ class WebSocketRoute(BaseRoute):
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
ws = WebSocket(scope, receive, send) ws = WebSocket(scope, receive, send)
before_requests = scope.get("before_requests", {"http": [], "ws": []}) before_requests = scope.get("before_requests", [])
for before_request in before_requests.get("ws", []): for before_request in before_requests.get("ws", []):
await before_request(ws) await before_request(ws)
@@ -461,22 +459,13 @@ class Router:
if path.startswith(path_prefix): if path.startswith(path_prefix):
scope["path"] = path[len(path_prefix) :] or "/" scope["path"] = path[len(path_prefix) :] or "/"
scope["root_path"] = root_path + path_prefix scope["root_path"] = root_path + path_prefix
try:
await app(scope, receive, send)
return
except TypeError:
from a2wsgi import WSGIMiddleware
if not (inspect.iscoroutinefunction(app) or hasattr(app, "__asgi_app__")): app = WSGIMiddleware(app)
# Check if it looks like a WSGI app (callable with fewer params)
try:
await app(scope, receive, send)
return
except TypeError as exc:
# Only fall back to WSGI if the error is about call signature
if "argument" not in str(exc) and "positional" not in str(exc):
raise
from a2wsgi import WSGIMiddleware
app = WSGIMiddleware(app)
await app(scope, receive, send)
return
else:
await app(scope, receive, send) await app(scope, receive, send)
return return
+1 -1
View File
@@ -85,7 +85,7 @@ for number in codes:
def _is_category(category, status_code): def _is_category(category, status_code):
return category <= status_code < category + 100 return all([(status_code >= category), (status_code < category + 100)])
def is_100(status_code): def is_100(status_code):
+3 -2
View File
@@ -12,7 +12,7 @@ __all__ = [
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def load_target(target: str, default_property: str = "api") -> t.Any: def load_target(target: str, default_property: str = "api", method: str = "run") -> t.Any:
""" """
Load Python code from a file path or module name. Load Python code from a file path or module name.
@@ -24,6 +24,7 @@ def load_target(target: str, default_property: str = "api") -> t.Any:
target: Module address (e.g., 'acme.app:foo'), file path (e.g., '/path/to/acme/app.py'), target: Module address (e.g., 'acme.app:foo'), file path (e.g., '/path/to/acme/app.py'),
or URL. or URL.
default_property: Name of the property to load if not specified in target (default: "api") default_property: Name of the property to load if not specified in target (default: "api")
method: Name of the method to invoke on the API instance (default: "run")
Returns: Returns:
The API instance, loaded from the given property. The API instance, loaded from the given property.
@@ -31,7 +32,7 @@ def load_target(target: str, default_property: str = "api") -> t.Any:
Raises: Raises:
ValueError: If target format is invalid ValueError: If target format is invalid
ImportError: If module cannot be imported ImportError: If module cannot be imported
AttributeError: If property is not found AttributeError: If property or method is not found
Example: Example:
>>> api = load_target("myapp.api:server") >>> api = load_target("myapp.api:server")
+3 -4
View File
@@ -85,7 +85,7 @@ def test_background_task_exception(capsys):
time.sleep(0.2) # let the done callback fire time.sleep(0.2) # let the done callback fire
captured = capsys.readouterr() captured = capsys.readouterr()
assert "ValueError" in captured.err or "ValueError" in captured.out assert "ValueError" in captured.err or True # traceback goes to stderr
def test_background_run(): def test_background_run():
@@ -112,8 +112,7 @@ def test_form_uploads_without_multipart(api):
content="name=hello&value=world", content="name=hello&value=world",
headers={"Content-Type": "application/x-www-form-urlencoded"}, headers={"Content-Type": "application/x-www-form-urlencoded"},
) )
assert r.status_code == 200 assert r.json() == {"name": "world", "value": "world"} or r.status_code < 500
assert r.json() == {"name": ["hello"], "value": ["world"]}
# --- models.py coverage --- # --- models.py coverage ---
@@ -470,7 +469,7 @@ def test_graphql_text_query(api):
content="{ hello }", content="{ hello }",
headers={"Content-Type": "text/plain"}, headers={"Content-Type": "text/plain"},
) )
assert r.status_code == 200 assert r.status_code < 500
def test_openapi_info_fields(): def test_openapi_info_fields():