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.13",
"3.14",
"3.14t",
"3.15",
"3.15t",
"pypy3.11",
]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install uv
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
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
### Added
@@ -525,8 +491,6 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
- 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.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
+1
View File
@@ -26,6 +26,7 @@ classifiers = [
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: 3.15",
"Programming Language :: Python :: Free Threading",
"Programming Language :: Python :: Implementation :: CPython",
"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 auto_escape: If ``True``, HTML and XML templates will automatically be escaped.
: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``
""" # noqa: E501
@@ -61,7 +60,6 @@ class API:
allowed_hosts=None,
openapi_theme=DEFAULT_OPENAPI_THEME,
lifespan=None,
gzip=True,
request_id=False,
enable_logging=False,
):
@@ -88,7 +86,6 @@ class API:
: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 gzip: If ``True`` (the default), compress responses with GZip.
: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).
""" # noqa: E501
@@ -125,9 +122,7 @@ class API:
self.default_endpoint = None
self.app = ExceptionMiddleware(self.router, debug=debug)
if gzip:
self.add_middleware(GZipMiddleware)
self.add_middleware(GZipMiddleware)
if self.hsts_enabled:
self.add_middleware(HTTPSRedirectMiddleware)
+2 -1
View File
@@ -101,7 +101,8 @@ class GraphQLView:
response_data["data"] = result.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):
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 src="//cdn.jsdelivr.net/npm/graphiql@{{ GRAPHIQL_VERSION }}/graphiql.min.js"></script>
<script>
const fetcher = GraphiQL.createFetcher({ url: {{ endpoint | tojson }} });
const fetcher = GraphiQL.createFetcher({ url: '{{ endpoint }}' });
const root = ReactDOM.createRoot(document.getElementById('graphiql'));
root.render(React.createElement(GraphiQL, { fetcher: fetcher }));
</script>
+4 -5
View File
@@ -174,8 +174,8 @@ class OpenAPISchema:
def add_schema(self, name, schema, check_existing=True):
"""Adds a marshmallow or Pydantic schema to the API specification."""
if check_existing:
if name in self.schemas or name in self.pydantic_schemas:
raise ValueError(f"Schema '{name}' is already registered")
assert name not in self.schemas
assert name not in self.pydantic_schemas
if _is_pydantic_model(schema):
self.pydantic_schemas[name] = schema
@@ -216,13 +216,12 @@ class OpenAPISchema:
f"{self.docs_theme}.html",
title=self.title,
version=self.version,
schema_url=self.openapi_route,
schema_url="/schema.yml",
)
def static_url(self, asset):
"""Given a static asset, return its URL path."""
if self.static_route is None:
raise RuntimeError("Cannot generate static URL: static_route is disabled")
assert self.static_route is not None
return f"{self.static_route}/{str(asset)}"
def docs_response(self, req, resp):
+8 -15
View File
@@ -1,6 +1,5 @@
"""Simple in-memory rate limiter for Responder."""
import threading
import time
from collections import defaultdict
@@ -29,7 +28,6 @@ class RateLimiter:
self.max_requests = requests
self.period = period
self._buckets: dict[str, list[float]] = defaultdict(list)
self._lock = threading.Lock()
def _client_key(self, req):
client = req.client
@@ -41,25 +39,20 @@ class RateLimiter:
now = time.time()
cutoff = now - self.period
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):
"""Check rate limit. Sets 429 status if exceeded."""
key = self._client_key(req)
self._cleanup(key)
with self._lock:
self._cleanup(key)
if len(self._buckets[key]) >= self.max_requests:
resp.status_code = 429
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])
if len(self._buckets[key]) >= self.max_requests:
resp.status_code = 429
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])
resp.headers["X-RateLimit-Limit"] = str(self.max_requests)
resp.headers["X-RateLimit-Remaining"] = str(remaining)
return True
+4 -15
View File
@@ -34,21 +34,12 @@ class CaseInsensitiveDict(dict):
def __getitem__(self, key):
return super().__getitem__(key.lower())
def __delitem__(self, key):
super().__delitem__(key.lower())
def __contains__(self, key):
return super().__contains__(key.lower())
def get(self, key, default=None):
return super().get(key.lower(), default)
def pop(self, key, *args):
return super().pop(key.lower(), *args)
def setdefault(self, key, default=None):
return super().setdefault(key.lower(), default)
def update(self, other=None, **kwargs):
if other:
for key, value in other.items():
@@ -308,8 +299,8 @@ class Request:
"""
if format is None:
format = "yaml" if "yaml" in self.mimetype else "json" # noqa: A001
format = "form" if "form" in self.mimetype else format # noqa: A001
format = "yaml" if "yaml" in self.mimetype or "" else "json" # noqa: A001
format = "form" if "form" in self.mimetype or "" else format # noqa: A001
formatter: Callable
if isinstance(format, str):
@@ -473,11 +464,9 @@ class Response:
self.mimetype = guessed or "application/octet-stream"
async def file_generator():
import anyio
async with await anyio.open_file(path, "rb") as f:
with open(path, "rb") as f:
while True:
chunk = await f.read(chunk_size)
chunk = f.read(chunk_size)
if not chunk:
break
yield chunk
+10 -21
View File
@@ -130,7 +130,7 @@ class Route(BaseRoute):
response = Response(req=request, formats=get_formats())
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", []):
if inspect.iscoroutinefunction(before_request):
@@ -144,11 +144,9 @@ class Route(BaseRoute):
# Auto-validate request body with Pydantic model
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:
body = await request.media()
if not isinstance(body, dict):
raise TypeError("Request body must be a JSON object")
req_model(**body)
except Exception as exc:
response.status_code = 422
@@ -190,7 +188,7 @@ class Route(BaseRoute):
# Auto-serialize response with Pydantic model
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:
validated = resp_model(**response.media)
response.media = validated.model_dump()
@@ -268,7 +266,7 @@ class WebSocketRoute(BaseRoute):
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
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", []):
await before_request(ws)
@@ -461,22 +459,13 @@ class Router:
if path.startswith(path_prefix):
scope["path"] = path[len(path_prefix) :] or "/"
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__")):
# 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:
app = WSGIMiddleware(app)
await app(scope, receive, send)
return
+1 -1
View File
@@ -85,7 +85,7 @@ for number in codes:
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):
+3 -2
View File
@@ -12,7 +12,7 @@ __all__ = [
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.
@@ -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'),
or URL.
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:
The API instance, loaded from the given property.
@@ -31,7 +32,7 @@ def load_target(target: str, default_property: str = "api") -> t.Any:
Raises:
ValueError: If target format is invalid
ImportError: If module cannot be imported
AttributeError: If property is not found
AttributeError: If property or method is not found
Example:
>>> 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
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():
@@ -112,8 +112,7 @@ def test_form_uploads_without_multipart(api):
content="name=hello&value=world",
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
assert r.status_code == 200
assert r.json() == {"name": ["hello"], "value": ["world"]}
assert r.json() == {"name": "world", "value": "world"} or r.status_code < 500
# --- models.py coverage ---
@@ -470,7 +469,7 @@ def test_graphql_text_query(api):
content="{ hello }",
headers={"Content-Type": "text/plain"},
)
assert r.status_code == 200
assert r.status_code < 500
def test_openapi_info_fields():