mirror of
https://github.com/kennethreitz/responder.git
synced 2026-06-05 23:00:17 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b678542192 | |||
| 079e7ae3e5 | |||
| 9d4a9d5083 | |||
| 79c32fcef4 | |||
| 52896c6040 | |||
| def071fc71 | |||
| f194efecae | |||
| 868f84fc8b | |||
| ea3b6a6e4a | |||
| 0b16558b04 | |||
| 2440d4caed | |||
| e653a9f1fd | |||
| c1fe6e11bd | |||
| 06b7bae7c0 | |||
| 2494034111 | |||
| 07cfa66e5c | |||
| a5d38cf9c3 | |||
| 2f5e46e233 |
@@ -31,9 +31,6 @@ jobs:
|
|||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -5,6 +5,40 @@ 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
|
||||||
@@ -491,6 +525,8 @@ 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 +1 @@
|
|||||||
__version__ = "3.6.0"
|
__version__ = "3.6.2"
|
||||||
|
|||||||
+6
-1
@@ -31,6 +31,7 @@ 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
|
||||||
|
|
||||||
@@ -60,6 +61,7 @@ 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,
|
||||||
):
|
):
|
||||||
@@ -86,6 +88,7 @@ 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
|
||||||
@@ -122,7 +125,9 @@ 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)
|
||||||
|
|||||||
@@ -101,8 +101,7 @@ class GraphQLView:
|
|||||||
response_data["data"] = result.data
|
response_data["data"] = result.data
|
||||||
|
|
||||||
resp.media = response_data
|
resp.media = response_data
|
||||||
status_code = 200 if not result.errors else 400
|
resp.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)
|
||||||
|
|||||||
@@ -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 }}' });
|
const fetcher = GraphiQL.createFetcher({ url: {{ endpoint | tojson }} });
|
||||||
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>
|
||||||
|
|||||||
@@ -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:
|
||||||
assert name not in self.schemas
|
if name in self.schemas or name in self.pydantic_schemas:
|
||||||
assert name not in self.pydantic_schemas
|
raise ValueError(f"Schema '{name}' is already registered")
|
||||||
|
|
||||||
if _is_pydantic_model(schema):
|
if _is_pydantic_model(schema):
|
||||||
self.pydantic_schemas[name] = schema
|
self.pydantic_schemas[name] = schema
|
||||||
@@ -216,12 +216,13 @@ 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="/schema.yml",
|
schema_url=self.openapi_route,
|
||||||
)
|
)
|
||||||
|
|
||||||
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."""
|
||||||
assert self.static_route is not None
|
if self.static_route is 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):
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""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
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ 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
|
||||||
@@ -39,20 +41,25 @@ 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)
|
|
||||||
|
|
||||||
if len(self._buckets[key]) >= self.max_requests:
|
with self._lock:
|
||||||
resp.status_code = 429
|
self._cleanup(key)
|
||||||
resp.media = {"error": "rate limit exceeded"}
|
|
||||||
resp.headers["Retry-After"] = str(self.period)
|
if len(self._buckets[key]) >= self.max_requests:
|
||||||
return False
|
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])
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
+15
-4
@@ -34,12 +34,21 @@ 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():
|
||||||
@@ -299,8 +308,8 @@ class Request:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
if format is None:
|
if format is None:
|
||||||
format = "yaml" if "yaml" in self.mimetype or "" else "json" # noqa: A001
|
format = "yaml" if "yaml" in self.mimetype else "json" # noqa: A001
|
||||||
format = "form" if "form" in self.mimetype or "" else format # noqa: A001
|
format = "form" if "form" in self.mimetype else format # noqa: A001
|
||||||
|
|
||||||
formatter: Callable
|
formatter: Callable
|
||||||
if isinstance(format, str):
|
if isinstance(format, str):
|
||||||
@@ -464,9 +473,11 @@ class Response:
|
|||||||
self.mimetype = guessed or "application/octet-stream"
|
self.mimetype = guessed or "application/octet-stream"
|
||||||
|
|
||||||
async def file_generator():
|
async def file_generator():
|
||||||
with open(path, "rb") as f:
|
import anyio
|
||||||
|
|
||||||
|
async with await anyio.open_file(path, "rb") as f:
|
||||||
while True:
|
while True:
|
||||||
chunk = f.read(chunk_size)
|
chunk = await f.read(chunk_size)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
break
|
break
|
||||||
yield chunk
|
yield chunk
|
||||||
|
|||||||
+21
-10
@@ -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", [])
|
before_requests = scope.get("before_requests", {"http": [], "ws": []})
|
||||||
|
|
||||||
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,9 +144,11 @@ 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"):
|
if req_model is not None and request.method in ("post", "put", "patch", "delete"):
|
||||||
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
|
||||||
@@ -188,7 +190,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 response.media is not None:
|
if resp_model is not None and isinstance(response.media, dict):
|
||||||
try:
|
try:
|
||||||
validated = resp_model(**response.media)
|
validated = resp_model(**response.media)
|
||||||
response.media = validated.model_dump()
|
response.media = validated.model_dump()
|
||||||
@@ -266,7 +268,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", [])
|
before_requests = scope.get("before_requests", {"http": [], "ws": []})
|
||||||
for before_request in before_requests.get("ws", []):
|
for before_request in before_requests.get("ws", []):
|
||||||
await before_request(ws)
|
await before_request(ws)
|
||||||
|
|
||||||
@@ -459,13 +461,22 @@ 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
|
|
||||||
|
|
||||||
app = WSGIMiddleware(app)
|
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:
|
||||||
await app(scope, receive, send)
|
await app(scope, receive, send)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ for number in codes:
|
|||||||
|
|
||||||
|
|
||||||
def _is_category(category, status_code):
|
def _is_category(category, status_code):
|
||||||
return all([(status_code >= category), (status_code < category + 100)])
|
return category <= status_code < category + 100
|
||||||
|
|
||||||
|
|
||||||
def is_100(status_code):
|
def is_100(status_code):
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ __all__ = [
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def load_target(target: str, default_property: str = "api", method: str = "run") -> t.Any:
|
def load_target(target: str, default_property: str = "api") -> t.Any:
|
||||||
"""
|
"""
|
||||||
Load Python code from a file path or module name.
|
Load Python code from a file path or module name.
|
||||||
|
|
||||||
@@ -24,7 +24,6 @@ def load_target(target: str, default_property: str = "api", method: str = "run")
|
|||||||
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.
|
||||||
@@ -32,7 +31,7 @@ def load_target(target: str, default_property: str = "api", method: str = "run")
|
|||||||
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 or method is not found
|
AttributeError: If property is not found
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
>>> api = load_target("myapp.api:server")
|
>>> api = load_target("myapp.api:server")
|
||||||
|
|||||||
@@ -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 True # traceback goes to stderr
|
assert "ValueError" in captured.err or "ValueError" in captured.out
|
||||||
|
|
||||||
|
|
||||||
def test_background_run():
|
def test_background_run():
|
||||||
@@ -112,7 +112,8 @@ 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.json() == {"name": "world", "value": "world"} or r.status_code < 500
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"name": ["hello"], "value": ["world"]}
|
||||||
|
|
||||||
|
|
||||||
# --- models.py coverage ---
|
# --- models.py coverage ---
|
||||||
@@ -469,7 +470,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 < 500
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
def test_openapi_info_fields():
|
def test_openapi_info_fields():
|
||||||
|
|||||||
Reference in New Issue
Block a user