From 310194ec3039a249f3773333dc82993d8d832161 Mon Sep 17 00:00:00 2001 From: laggardkernel Date: Sun, 13 Jun 2021 23:54:34 +0800 Subject: [PATCH 01/37] Cleanup param "workers" in WSGIMiddleware (#1146) Param "workers" in WSGIMiddleware.__init__ has not been used since 0.6.3, which is changed in GH-164, commit 96c51c. Co-authored-by: Jamie Hewland --- starlette/middleware/wsgi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/starlette/middleware/wsgi.py b/starlette/middleware/wsgi.py index 6b30610bc6..515cf3e765 100644 --- a/starlette/middleware/wsgi.py +++ b/starlette/middleware/wsgi.py @@ -54,7 +54,7 @@ def build_environ(scope: Scope, body: bytes) -> dict: class WSGIMiddleware: - def __init__(self, app: typing.Callable, workers: int = 10) -> None: + def __init__(self, app: typing.Callable) -> None: self.app = app async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: From 15761fb48e4c56be09167cb8f9b761114593b651 Mon Sep 17 00:00:00 2001 From: laggardkernel Date: Sun, 13 Jun 2021 23:59:17 +0800 Subject: [PATCH 02/37] Deduplicate failure text in CORS preflight response (#1199) Co-authored-by: Jamie Hewland --- starlette/middleware/cors.py | 1 + tests/middleware/test_cors.py | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/starlette/middleware/cors.py b/starlette/middleware/cors.py index 0b3f505e71..c850579c80 100644 --- a/starlette/middleware/cors.py +++ b/starlette/middleware/cors.py @@ -129,6 +129,7 @@ def preflight_response(self, request_headers: Headers) -> Response: for header in [h.lower() for h in requested_headers.split(",")]: if header.strip() not in self.allow_headers: failures.append("headers") + break # We don't strictly need to use 400 responses here, since its up to # the browser to enforce the CORS policy, but its more informative diff --git a/tests/middleware/test_cors.py b/tests/middleware/test_cors.py index 7a250a2416..266ebca5b4 100644 --- a/tests/middleware/test_cors.py +++ b/tests/middleware/test_cors.py @@ -179,6 +179,16 @@ def homepage(request): assert response.text == "Disallowed CORS origin, method, headers" assert "access-control-allow-origin" not in response.headers + # Bug specific test, https://github.com/encode/starlette/pull/1199 + # Test preflight response text with multiple disallowed headers + headers = { + "Origin": "https://example.org", + "Access-Control-Request-Method": "GET", + "Access-Control-Request-Headers": "X-Nope-1, X-Nope-2", + } + response = client.options("/", headers=headers) + assert response.text == "Disallowed CORS headers" + def test_preflight_allows_request_origin_if_origins_wildcard_and_credentials_allowed(): app = Starlette() From 42592d68e5d7fd044b79d7846eabe7f33961527c Mon Sep 17 00:00:00 2001 From: Jordan Speicher Date: Fri, 18 Jun 2021 09:48:43 -0500 Subject: [PATCH 03/37] anyio integration (#1157) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * First whack at anyio integration * Fix formatting * Remove debug messages * mypy fixes * Update README.md Co-authored-by: Marcelo Trylesinski * Fix install_requires typo * move_on_after blocks if deadline is too small * Linter fixes * Improve WSGI structured concurrency * Tests use anyio * Checkin progress on testclient * Prep for anyio 3 * Remove debug backend option * Use anyio 3.0.0rc1 * Remove old style executor from GraphQLApp * Fix extra import * Don't cancel task scope early * Wait for wsgi sender to finish before exiting * Use memory object streams in websocket tests * Test on asyncio, asyncio+uvloop, and trio * Formatting fixes * run_until_first_complete doesn't need a return * Fix middleware app call * Simplify middleware exceptions * Use anyio for websocket test * Set STARLETTE_TESTCLIENT_ASYNC_BACKEND in tests * Pass async backend to portal * Formatting fixes * Bump anyio * Cleanup portals and add TestClient.async_backend * Use anyio.run_async_from_thread to send from worker thread * Use websocket_connect as context manager * Document changes in TestClient * Formatting fix * Fix websocket raises coverage * Update to anyio 3.0.0rc3 and replace aiofiles * Apply suggestions from code review Co-authored-by: Alex Grönholm * Bump to require anyio 3.0.0 final * Remove mention of aiofiles in README.md * Pin jinja2 to releases before 3 due to DeprecationWarnings * Add task_group as application attribute * Remove run_until_first_complete * Undo jinja pin * Refactor anyio.sleep into an event * Use one less task in test_websocket_concurrency_pattern * Apply review suggestions * Rename argument * fix start_task_soon type * fix BaseHTTPMiddleware when used without Starlette * Testclient receive() is a non-trapping function if the response is already complete This allows for a zero deadline when waiting for a disconnect message * Use variable annotation for async_backend * Update docs regarding dependency on anyio * Use CancelScope instead of move_on_after in request.is_disconnected * Cancel task group after returning middleware response Add test for https://github.com/encode/starlette/issues/1022 * Add link to anyio backend options in testclient docs * Add types-dataclasses * Re-implement starlette.concurrency.run_until_first_complete and add a test * Fix type on handler callable * Apply review comments to clarify run_until_first_complete scope Co-authored-by: Marcelo Trylesinski Co-authored-by: Alex Grönholm Co-authored-by: Thomas Grainger --- README.md | 9 +-- docs/index.md | 6 +- docs/testclient.md | 18 +++++ requirements.txt | 3 +- setup.py | 2 +- starlette/concurrency.py | 27 +++---- starlette/graphql.py | 21 ++--- starlette/middleware/base.py | 69 ++++++++-------- starlette/middleware/wsgi.py | 61 ++++++-------- starlette/requests.py | 13 +-- starlette/responses.py | 32 ++++---- starlette/staticfiles.py | 6 +- starlette/testclient.py | 138 ++++++++++++++++++++------------ tests/conftest.py | 24 ++++++ tests/middleware/test_base.py | 15 ++++ tests/middleware/test_errors.py | 3 +- tests/test_authentication.py | 16 ++-- tests/test_concurrency.py | 22 +++++ tests/test_database.py | 3 + tests/test_datastructures.py | 2 +- tests/test_exceptions.py | 3 +- tests/test_graphql.py | 22 +---- tests/test_requests.py | 6 +- tests/test_responses.py | 6 +- tests/test_routing.py | 6 +- tests/test_staticfiles.py | 5 +- tests/test_testclient.py | 18 ++--- tests/test_websockets.py | 37 ++++----- 28 files changed, 335 insertions(+), 258 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_concurrency.py diff --git a/README.md b/README.md index 184eb480a9..8eedea9523 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ # Starlette Starlette is a lightweight [ASGI](https://asgi.readthedocs.io/en/latest/) framework/toolkit, -which is ideal for building high performance asyncio services. +which is ideal for building high performance async services. It is production-ready, and gives you the following: @@ -36,7 +36,8 @@ It is production-ready, and gives you the following: * Session and Cookie support. * 100% test coverage. * 100% type annotated codebase. -* Zero hard dependencies. +* Few hard dependencies. +* Compatible with `asyncio` and `trio` backends. ## Requirements @@ -84,10 +85,9 @@ For a more complete example, see [encode/starlette-example](https://github.com/e ## Dependencies -Starlette does not have any hard dependencies, but the following are optional: +Starlette only requires `anyio`, and the following are optional: * [`requests`][requests] - Required if you want to use the `TestClient`. -* [`aiofiles`][aiofiles] - Required if you want to use `FileResponse` or `StaticFiles`. * [`jinja2`][jinja2] - Required if you want to use `Jinja2Templates`. * [`python-multipart`][python-multipart] - Required if you want to support form parsing, with `request.form()`. * [`itsdangerous`][itsdangerous] - Required for `SessionMiddleware` support. @@ -167,7 +167,6 @@ gunicorn -k uvicorn.workers.UvicornH11Worker ...

Starlette is BSD licensed code. Designed & built in Brighton, England.

[requests]: http://docs.python-requests.org/en/master/ -[aiofiles]: https://github.com/Tinche/aiofiles [jinja2]: http://jinja.pocoo.org/ [python-multipart]: https://andrew-d.github.io/python-multipart/ [graphene]: https://graphene-python.org/ diff --git a/docs/index.md b/docs/index.md index 4ae77f0e60..b9692a1fbc 100644 --- a/docs/index.md +++ b/docs/index.md @@ -32,7 +32,7 @@ It is production-ready, and gives you the following: * Session and Cookie support. * 100% test coverage. * 100% type annotated codebase. -* Zero hard dependencies. +* Few hard dependencies. ## Requirements @@ -79,10 +79,9 @@ For a more complete example, [see here](https://github.com/encode/starlette-exam ## Dependencies -Starlette does not have any hard dependencies, but the following are optional: +Starlette only requires `anyio`, and the following dependencies are optional: * [`requests`][requests] - Required if you want to use the `TestClient`. -* [`aiofiles`][aiofiles] - Required if you want to use `FileResponse` or `StaticFiles`. * [`jinja2`][jinja2] - Required if you want to use `Jinja2Templates`. * [`python-multipart`][python-multipart] - Required if you want to support form parsing, with `request.form()`. * [`itsdangerous`][itsdangerous] - Required for `SessionMiddleware` support. @@ -161,7 +160,6 @@ gunicorn -k uvicorn.workers.UvicornH11Worker ...

Starlette is BSD licensed code. Designed & built in Brighton, England.

[requests]: http://docs.python-requests.org/en/master/ -[aiofiles]: https://github.com/Tinche/aiofiles [jinja2]: http://jinja.pocoo.org/ [python-multipart]: https://andrew-d.github.io/python-multipart/ [graphene]: https://graphene-python.org/ diff --git a/docs/testclient.md b/docs/testclient.md index 61f7201c62..f378584018 100644 --- a/docs/testclient.md +++ b/docs/testclient.md @@ -31,6 +31,22 @@ application. Occasionally you might want to test the content of 500 error responses, rather than allowing client to raise the server exception. In this case you should use `client = TestClient(app, raise_server_exceptions=False)`. +### Selecting the Async backend + +`TestClient.async_backend` is a dictionary which allows you to set the options +for the backend used to run tests. These options are passed to +`anyio.start_blocking_portal()`. See the [anyio documentation](https://anyio.readthedocs.io/en/stable/basics.html#backend-options) +for more information about backend options. By default, `asyncio` is used. + +To run `Trio`, set `async_backend["backend"] = "trio"`, for example: + +```python +def test_app() + client = TestClient(app) + client.async_backend["backend"] = "trio" + ... +``` + ### Testing WebSocket sessions You can also test websocket sessions with the test client. @@ -72,6 +88,8 @@ always raised by the test client. May raise `starlette.websockets.WebSocketDisconnect` if the application does not accept the websocket connection. +`websocket_connect()` must be used as a context manager (in a `with` block). + #### Sending data * `.send_text(data)` - Send the given text to the application. diff --git a/requirements.txt b/requirements.txt index 6ec5bf09ee..ae3d91f263 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,9 +18,10 @@ types-requests types-contextvars types-aiofiles types-PyYAML +types-dataclasses pytest pytest-cov -pytest-asyncio +trio # Documentation mkdocs diff --git a/setup.py b/setup.py index c48356370c..a687ad861d 100644 --- a/setup.py +++ b/setup.py @@ -37,9 +37,9 @@ def get_long_description(): packages=find_packages(exclude=["tests*"]), package_data={"starlette": ["py.typed"]}, include_package_data=True, + install_requires=["anyio>=3.0.0,<4"], extras_require={ "full": [ - "aiofiles", "graphene", "itsdangerous", "jinja2", diff --git a/starlette/concurrency.py b/starlette/concurrency.py index c8c5d57acb..e89d1e0471 100644 --- a/starlette/concurrency.py +++ b/starlette/concurrency.py @@ -1,33 +1,32 @@ -import asyncio import functools -import sys import typing from typing import Any, AsyncGenerator, Iterator +import anyio + try: import contextvars # Python 3.7+ only or via contextvars backport. except ImportError: # pragma: no cover contextvars = None # type: ignore -if sys.version_info >= (3, 7): # pragma: no cover - from asyncio import create_task -else: # pragma: no cover - from asyncio import ensure_future as create_task T = typing.TypeVar("T") async def run_until_first_complete(*args: typing.Tuple[typing.Callable, dict]) -> None: - tasks = [create_task(handler(**kwargs)) for handler, kwargs in args] - (done, pending) = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) - [task.cancel() for task in pending] - [task.result() for task in done] + async with anyio.create_task_group() as task_group: + + async def run(func: typing.Callable[[], typing.Coroutine]) -> None: + await func() + task_group.cancel_scope.cancel() + + for func, kwargs in args: + task_group.start_soon(run, functools.partial(func, **kwargs)) async def run_in_threadpool( func: typing.Callable[..., T], *args: typing.Any, **kwargs: typing.Any ) -> T: - loop = asyncio.get_event_loop() if contextvars is not None: # pragma: no cover # Ensure we run in the same context child = functools.partial(func, *args, **kwargs) @@ -35,9 +34,9 @@ async def run_in_threadpool( func = context.run args = (child,) elif kwargs: # pragma: no cover - # loop.run_in_executor doesn't accept 'kwargs', so bind them in here + # run_sync doesn't accept 'kwargs', so bind them in here func = functools.partial(func, **kwargs) - return await loop.run_in_executor(None, func, *args) + return await anyio.to_thread.run_sync(func, *args) class _StopIteration(Exception): @@ -57,6 +56,6 @@ def _next(iterator: Iterator) -> Any: async def iterate_in_threadpool(iterator: Iterator) -> AsyncGenerator: while True: try: - yield await run_in_threadpool(_next, iterator) + yield await anyio.to_thread.run_sync(_next, iterator) except _StopIteration: break diff --git a/starlette/graphql.py b/starlette/graphql.py index ed2274f896..6e5d6ec6af 100644 --- a/starlette/graphql.py +++ b/starlette/graphql.py @@ -31,29 +31,18 @@ class GraphQLApp: def __init__( self, schema: "graphene.Schema", - executor: typing.Any = None, executor_class: type = None, graphiql: bool = True, ) -> None: self.schema = schema self.graphiql = graphiql - if executor is None: - # New style in 0.10.0. Use 'executor_class'. - # See issue https://github.com/encode/starlette/issues/242 - self.executor = executor - self.executor_class = executor_class - self.is_async = executor_class is not None and issubclass( - executor_class, AsyncioExecutor - ) - else: - # Old style. Use 'executor'. - # We should remove this in the next median/major version bump. - self.executor = executor - self.executor_class = None - self.is_async = isinstance(executor, AsyncioExecutor) + self.executor_class = executor_class + self.is_async = executor_class is not None and issubclass( + executor_class, AsyncioExecutor + ) async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: - if self.executor is None and self.executor_class is not None: + if self.executor_class is not None: self.executor = self.executor_class() request = Request(scope, receive=receive) diff --git a/starlette/middleware/base.py b/starlette/middleware/base.py index b347a6a2dd..77ba669251 100644 --- a/starlette/middleware/base.py +++ b/starlette/middleware/base.py @@ -1,9 +1,10 @@ -import asyncio import typing +import anyio + from starlette.requests import Request from starlette.responses import Response, StreamingResponse -from starlette.types import ASGIApp, Message, Receive, Scope, Send +from starlette.types import ASGIApp, Receive, Scope, Send RequestResponseEndpoint = typing.Callable[[Request], typing.Awaitable[Response]] DispatchFunction = typing.Callable[ @@ -21,45 +22,39 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: await self.app(scope, receive, send) return - request = Request(scope, receive=receive) - response = await self.dispatch_func(request, self.call_next) - await response(scope, receive, send) + async def call_next(request: Request) -> Response: + send_stream, recv_stream = anyio.create_memory_object_stream() - async def call_next(self, request: Request) -> Response: - loop = asyncio.get_event_loop() - queue: "asyncio.Queue[typing.Optional[Message]]" = asyncio.Queue() + async def coro() -> None: + async with send_stream: + await self.app(scope, request.receive, send_stream.send) - scope = request.scope - receive = request.receive - send = queue.put + task_group.start_soon(coro) - async def coro() -> None: try: - await self.app(scope, receive, send) - finally: - await queue.put(None) - - task = loop.create_task(coro()) - message = await queue.get() - if message is None: - task.result() - raise RuntimeError("No response returned.") - assert message["type"] == "http.response.start" - - async def body_stream() -> typing.AsyncGenerator[bytes, None]: - while True: - message = await queue.get() - if message is None: - break - assert message["type"] == "http.response.body" - yield message.get("body", b"") - task.result() - - response = StreamingResponse( - status_code=message["status"], content=body_stream() - ) - response.raw_headers = message["headers"] - return response + message = await recv_stream.receive() + except anyio.EndOfStream: + raise RuntimeError("No response returned.") + + assert message["type"] == "http.response.start" + + async def body_stream() -> typing.AsyncGenerator[bytes, None]: + async with recv_stream: + async for message in recv_stream: + assert message["type"] == "http.response.body" + yield message.get("body", b"") + + response = StreamingResponse( + status_code=message["status"], content=body_stream() + ) + response.raw_headers = message["headers"] + return response + + async with anyio.create_task_group() as task_group: + request = Request(scope, receive=receive) + response = await self.dispatch_func(request, call_next) + await response(scope, receive, send) + task_group.cancel_scope.cancel() async def dispatch( self, request: Request, call_next: RequestResponseEndpoint diff --git a/starlette/middleware/wsgi.py b/starlette/middleware/wsgi.py index 515cf3e765..7e69e1a6b2 100644 --- a/starlette/middleware/wsgi.py +++ b/starlette/middleware/wsgi.py @@ -1,10 +1,11 @@ -import asyncio import io +import math import sys import typing -from starlette.concurrency import run_in_threadpool -from starlette.types import Message, Receive, Scope, Send +import anyio + +from starlette.types import Receive, Scope, Send def build_environ(scope: Scope, body: bytes) -> dict: @@ -69,9 +70,9 @@ def __init__(self, app: typing.Callable, scope: Scope) -> None: self.scope = scope self.status = None self.response_headers = None - self.send_event = asyncio.Event() - self.send_queue: typing.List[typing.Optional[Message]] = [] - self.loop = asyncio.get_event_loop() + self.stream_send, self.stream_receive = anyio.create_memory_object_stream( + math.inf + ) self.response_started = False self.exc_info: typing.Any = None @@ -83,31 +84,18 @@ async def __call__(self, receive: Receive, send: Send) -> None: body += message.get("body", b"") more_body = message.get("more_body", False) environ = build_environ(self.scope, body) - sender = None - try: - sender = self.loop.create_task(self.sender(send)) - await run_in_threadpool(self.wsgi, environ, self.start_response) - self.send_queue.append(None) - self.send_event.set() - await asyncio.wait_for(sender, None) - if self.exc_info is not None: - raise self.exc_info[0].with_traceback( - self.exc_info[1], self.exc_info[2] - ) - finally: - if sender and not sender.done(): - sender.cancel() # pragma: no cover + + async with anyio.create_task_group() as task_group: + task_group.start_soon(self.sender, send) + async with self.stream_send: + await anyio.to_thread.run_sync(self.wsgi, environ, self.start_response) + if self.exc_info is not None: + raise self.exc_info[0].with_traceback(self.exc_info[1], self.exc_info[2]) async def sender(self, send: Send) -> None: - while True: - if self.send_queue: - message = self.send_queue.pop(0) - if message is None: - return + async with self.stream_receive: + async for message in self.stream_receive: await send(message) - else: - await self.send_event.wait() - self.send_event.clear() def start_response( self, @@ -124,21 +112,22 @@ def start_response( (name.strip().encode("ascii").lower(), value.strip().encode("ascii")) for name, value in response_headers ] - self.send_queue.append( + anyio.from_thread.run( + self.stream_send.send, { "type": "http.response.start", "status": status_code, "headers": headers, - } + }, ) - self.loop.call_soon_threadsafe(self.send_event.set) def wsgi(self, environ: dict, start_response: typing.Callable) -> None: for chunk in self.app(environ, start_response): - self.send_queue.append( - {"type": "http.response.body", "body": chunk, "more_body": True} + anyio.from_thread.run( + self.stream_send.send, + {"type": "http.response.body", "body": chunk, "more_body": True}, ) - self.loop.call_soon_threadsafe(self.send_event.set) - self.send_queue.append({"type": "http.response.body", "body": b""}) - self.loop.call_soon_threadsafe(self.send_event.set) + anyio.from_thread.run( + self.stream_send.send, {"type": "http.response.body", "body": b""} + ) diff --git a/starlette/requests.py b/starlette/requests.py index ab6f51424b..54ed8611e3 100644 --- a/starlette/requests.py +++ b/starlette/requests.py @@ -1,9 +1,10 @@ -import asyncio import json import typing from collections.abc import Mapping from http import cookies as http_cookies +import anyio + from starlette.datastructures import URL, Address, FormData, Headers, QueryParams, State from starlette.formparsers import FormParser, MultiPartParser from starlette.types import Message, Receive, Scope, Send @@ -251,10 +252,12 @@ async def close(self) -> None: async def is_disconnected(self) -> bool: if not self._is_disconnected: - try: - message = await asyncio.wait_for(self._receive(), timeout=0.0000001) - except asyncio.TimeoutError: - message = {} + message: Message = {} + + # If message isn't immediately available, move on + with anyio.CancelScope() as cs: + cs.cancel() + message = await self._receive() if message.get("type") == "http.disconnect": self._is_disconnected = True diff --git a/starlette/responses.py b/starlette/responses.py index 00f6be4dbc..d03df23294 100644 --- a/starlette/responses.py +++ b/starlette/responses.py @@ -6,24 +6,20 @@ import sys import typing from email.utils import formatdate +from functools import partial from mimetypes import guess_type as mimetypes_guess_type from urllib.parse import quote +import anyio + from starlette.background import BackgroundTask -from starlette.concurrency import iterate_in_threadpool, run_until_first_complete +from starlette.concurrency import iterate_in_threadpool from starlette.datastructures import URL, MutableHeaders from starlette.types import Receive, Scope, Send # Workaround for adding samesite support to pre 3.8 python http.cookies.Morsel._reserved["samesite"] = "SameSite" # type: ignore -try: - import aiofiles - from aiofiles.os import stat as aio_stat -except ImportError: # pragma: nocover - aiofiles = None # type: ignore - aio_stat = None # type: ignore - # Compatibility wrapper for `mimetypes.guess_type` to support `os.PathLike` on None: await send({"type": "http.response.body", "body": b"", "more_body": False}) async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: - await run_until_first_complete( - (self.stream_response, {"send": send}), - (self.listen_for_disconnect, {"receive": receive}), - ) + async with anyio.create_task_group() as task_group: + + async def wrap(func: typing.Callable[[], typing.Coroutine]) -> None: + await func() + task_group.cancel_scope.cancel() + + task_group.start_soon(wrap, partial(self.stream_response, send)) + await wrap(partial(self.listen_for_disconnect, receive)) if self.background is not None: await self.background() @@ -244,7 +244,6 @@ def __init__( stat_result: os.stat_result = None, method: str = None, ) -> None: - assert aiofiles is not None, "'aiofiles' must be installed to use FileResponse" self.path = path self.status_code = status_code self.filename = filename @@ -280,7 +279,7 @@ def set_stat_headers(self, stat_result: os.stat_result) -> None: async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if self.stat_result is None: try: - stat_result = await aio_stat(self.path) + stat_result = await anyio.to_thread.run_sync(os.stat, self.path) self.set_stat_headers(stat_result) except FileNotFoundError: raise RuntimeError(f"File at path {self.path} does not exist.") @@ -298,10 +297,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if self.send_header_only: await send({"type": "http.response.body", "body": b"", "more_body": False}) else: - # Tentatively ignoring type checking failure to work around the wrong type - # definitions for aiofile that come with typeshed. See - # https://github.com/python/typeshed/pull/4650 - async with aiofiles.open(self.path, mode="rb") as file: # type: ignore + async with await anyio.open_file(self.path, mode="rb") as file: more_body = True while more_body: chunk = await file.read(self.chunk_size) diff --git a/starlette/staticfiles.py b/starlette/staticfiles.py index 15a67fe35d..33ea0b0337 100644 --- a/starlette/staticfiles.py +++ b/starlette/staticfiles.py @@ -4,7 +4,7 @@ import typing from email.utils import parsedate -from aiofiles.os import stat as aio_stat +import anyio from starlette.datastructures import URL, Headers from starlette.responses import ( @@ -154,7 +154,7 @@ async def lookup_path( # directory. continue try: - stat_result = await aio_stat(full_path) + stat_result = await anyio.to_thread.run_sync(os.stat, full_path) return full_path, stat_result except FileNotFoundError: pass @@ -187,7 +187,7 @@ async def check_config(self) -> None: return try: - stat_result = await aio_stat(self.directory) + stat_result = await anyio.to_thread.run_sync(os.stat, self.directory) except FileNotFoundError: raise RuntimeError( f"StaticFiles directory '{self.directory}' does not exist." diff --git a/starlette/testclient.py b/starlette/testclient.py index 77c038b17f..c1c0fe1652 100644 --- a/starlette/testclient.py +++ b/starlette/testclient.py @@ -1,15 +1,19 @@ import asyncio +import contextlib import http import inspect import io import json +import math import queue -import threading import types import typing +from concurrent.futures import Future from urllib.parse import unquote, urljoin, urlsplit +import anyio import requests +from anyio.streams.stapled import StapledObjectStream from starlette.types import Message, Receive, Scope, Send from starlette.websockets import WebSocketDisconnect @@ -89,11 +93,16 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: class _ASGIAdapter(requests.adapters.HTTPAdapter): def __init__( - self, app: ASGI3App, raise_server_exceptions: bool = True, root_path: str = "" + self, + app: ASGI3App, + async_backend: typing.Dict[str, typing.Any], + raise_server_exceptions: bool = True, + root_path: str = "", ) -> None: self.app = app self.raise_server_exceptions = raise_server_exceptions self.root_path = root_path + self.async_backend = async_backend def send( self, request: requests.PreparedRequest, *args: typing.Any, **kwargs: typing.Any @@ -142,7 +151,7 @@ def send( "server": [host, port], "subprotocols": subprotocols, } - session = WebSocketTestSession(self.app, scope) + session = WebSocketTestSession(self.app, scope, self.async_backend) raise _Upgrade(session) scope = { @@ -161,17 +170,17 @@ def send( request_complete = False response_started = False - response_complete = False + response_complete: anyio.Event raw_kwargs: typing.Dict[str, typing.Any] = {"body": io.BytesIO()} template = None context = None async def receive() -> Message: - nonlocal request_complete, response_complete + nonlocal request_complete if request_complete: - while not response_complete: - await asyncio.sleep(0.0001) + if not response_complete.is_set(): + await response_complete.wait() return {"type": "http.disconnect"} body = request.body @@ -195,7 +204,7 @@ async def receive() -> Message: return {"type": "http.request", "body": body_bytes} async def send(message: Message) -> None: - nonlocal raw_kwargs, response_started, response_complete, template, context + nonlocal raw_kwargs, response_started, template, context if message["type"] == "http.response.start": assert ( @@ -217,7 +226,7 @@ async def send(message: Message) -> None: response_started ), 'Received "http.response.body" without "http.response.start".' assert ( - not response_complete + not response_complete.is_set() ), 'Received "http.response.body" after response completed.' body = message.get("body", b"") more_body = message.get("more_body", False) @@ -225,19 +234,15 @@ async def send(message: Message) -> None: raw_kwargs["body"].write(body) if not more_body: raw_kwargs["body"].seek(0) - response_complete = True + response_complete.set() elif message["type"] == "http.response.template": template = message["template"] context = message["context"] try: - loop = asyncio.get_event_loop() - except RuntimeError: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - try: - loop.run_until_complete(self.app(scope, receive, send)) + with anyio.start_blocking_portal(**self.async_backend) as portal: + response_complete = portal.call(anyio.Event) + portal.call(self.app, scope, receive, send) except BaseException as exc: if self.raise_server_exceptions: raise exc @@ -264,48 +269,59 @@ async def send(message: Message) -> None: class WebSocketTestSession: - def __init__(self, app: ASGI3App, scope: Scope) -> None: + def __init__( + self, app: ASGI3App, scope: Scope, async_backend: typing.Dict[str, typing.Any] + ) -> None: self.app = app self.scope = scope self.accepted_subprotocol = None + self.async_backend = async_backend self._receive_queue: "queue.Queue[typing.Any]" = queue.Queue() self._send_queue: "queue.Queue[typing.Any]" = queue.Queue() - self._thread = threading.Thread(target=self._run) - self.send({"type": "websocket.connect"}) - self._thread.start() - message = self.receive() - self._raise_on_close(message) - self.accepted_subprotocol = message.get("subprotocol", None) def __enter__(self) -> "WebSocketTestSession": + self.exit_stack = contextlib.ExitStack() + self.portal = self.exit_stack.enter_context( + anyio.start_blocking_portal(**self.async_backend) + ) + + try: + _: "Future[None]" = self.portal.start_task_soon(self._run) + self.send({"type": "websocket.connect"}) + message = self.receive() + self._raise_on_close(message) + except Exception: + self.exit_stack.close() + raise + self.accepted_subprotocol = message.get("subprotocol", None) return self def __exit__(self, *args: typing.Any) -> None: - self.close(1000) - self._thread.join() + try: + self.close(1000) + finally: + self.exit_stack.close() while not self._send_queue.empty(): message = self._send_queue.get() if isinstance(message, BaseException): raise message - def _run(self) -> None: + async def _run(self) -> None: """ The sub-thread in which the websocket session runs. """ - loop = asyncio.new_event_loop() scope = self.scope receive = self._asgi_receive send = self._asgi_send try: - loop.run_until_complete(self.app(scope, receive, send)) + await self.app(scope, receive, send) except BaseException as exc: self._send_queue.put(exc) - finally: - loop.close() + raise async def _asgi_receive(self) -> Message: while self._receive_queue.empty(): - await asyncio.sleep(0) + await anyio.sleep(0) return self._receive_queue.get() async def _asgi_send(self, message: Message) -> None: @@ -365,6 +381,14 @@ def receive_json(self, mode: str = "text") -> typing.Any: class TestClient(requests.Session): __test__ = False # For pytest to not discover this up. + #: These options are passed to `anyio.start_blocking_portal()` + async_backend: typing.Dict[str, typing.Any] = { + "backend": "asyncio", + "backend_options": {}, + } + + task: "Future[None]" + def __init__( self, app: typing.Union[ASGI2App, ASGI3App], @@ -381,6 +405,7 @@ def __init__( asgi_app = _WrapASGI2(app) #  type: ignore adapter = _ASGIAdapter( asgi_app, + self.async_backend, raise_server_exceptions=raise_server_exceptions, root_path=root_path, ) @@ -452,27 +477,40 @@ def websocket_connect( return session def __enter__(self) -> "TestClient": - loop = asyncio.get_event_loop() - self.send_queue: "asyncio.Queue[typing.Any]" = asyncio.Queue() - self.receive_queue: "asyncio.Queue[typing.Any]" = asyncio.Queue() - self.task = loop.create_task(self.lifespan()) - loop.run_until_complete(self.wait_startup()) + self.exit_stack = contextlib.ExitStack() + self.portal = self.exit_stack.enter_context( + anyio.start_blocking_portal(**self.async_backend) + ) + self.stream_send = StapledObjectStream( + *anyio.create_memory_object_stream(math.inf) + ) + self.stream_receive = StapledObjectStream( + *anyio.create_memory_object_stream(math.inf) + ) + try: + self.task = self.portal.start_task_soon(self.lifespan) + self.portal.call(self.wait_startup) + except Exception: + self.exit_stack.close() + raise return self def __exit__(self, *args: typing.Any) -> None: - loop = asyncio.get_event_loop() - loop.run_until_complete(self.wait_shutdown()) + try: + self.portal.call(self.wait_shutdown) + finally: + self.exit_stack.close() async def lifespan(self) -> None: scope = {"type": "lifespan"} try: - await self.app(scope, self.receive_queue.get, self.send_queue.put) + await self.app(scope, self.stream_receive.receive, self.stream_send.send) finally: - await self.send_queue.put(None) + await self.stream_send.send(None) async def wait_startup(self) -> None: - await self.receive_queue.put({"type": "lifespan.startup"}) - message = await self.send_queue.get() + await self.stream_receive.send({"type": "lifespan.startup"}) + message = await self.stream_send.receive() if message is None: self.task.result() assert message["type"] in ( @@ -480,14 +518,14 @@ async def wait_startup(self) -> None: "lifespan.startup.failed", ) if message["type"] == "lifespan.startup.failed": - message = await self.send_queue.get() + message = await self.stream_send.receive() if message is None: self.task.result() async def wait_shutdown(self) -> None: - await self.receive_queue.put({"type": "lifespan.shutdown"}) - message = await self.send_queue.get() - if message is None: - self.task.result() - assert message["type"] == "lifespan.shutdown.complete" - await self.task + async with self.stream_send: + await self.stream_receive.send({"type": "lifespan.shutdown"}) + message = await self.stream_send.receive() + if message is None: + self.task.result() + assert message["type"] == "lifespan.shutdown.complete" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000000..d1f3ba8e48 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,24 @@ +import pytest + +from starlette.testclient import TestClient + + +@pytest.fixture( + params=[ + pytest.param( + {"backend": "asyncio", "backend_options": {"use_uvloop": False}}, + id="asyncio", + ), + pytest.param({"backend": "trio", "backend_options": {}}, id="trio"), + ], + autouse=True, +) +def anyio_backend(request, monkeypatch): + monkeypatch.setattr(TestClient, "async_backend", request.param) + return request.param["backend"] + + +@pytest.fixture +def no_trio_support(request): + if request.keywords.get("trio"): + pytest.skip("Trio not supported (yet!)") diff --git a/tests/middleware/test_base.py b/tests/middleware/test_base.py index 048dd9ffb9..df8901934a 100644 --- a/tests/middleware/test_base.py +++ b/tests/middleware/test_base.py @@ -143,3 +143,18 @@ def homepage(request): def test_middleware_repr(): middleware = Middleware(CustomMiddleware) assert repr(middleware) == "Middleware(CustomMiddleware)" + + +def test_fully_evaluated_response(): + # Test for https://github.com/encode/starlette/issues/1022 + class CustomMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request, call_next): + await call_next(request) + return PlainTextResponse("Custom") + + app = Starlette() + app.add_middleware(CustomMiddleware) + + client = TestClient(app) + response = client.get("/does_not_exist") + assert response.text == "Custom" diff --git a/tests/middleware/test_errors.py b/tests/middleware/test_errors.py index c178ef9da2..28b2a7ba32 100644 --- a/tests/middleware/test_errors.py +++ b/tests/middleware/test_errors.py @@ -67,4 +67,5 @@ async def app(scope, receive, send): with pytest.raises(RuntimeError): client = TestClient(app) - client.websocket_connect("/") + with client.websocket_connect("/"): + pass # pragma: nocover diff --git a/tests/test_authentication.py b/tests/test_authentication.py index 3373f67c50..8ee87932a1 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -261,10 +261,14 @@ def test_authentication_required(): def test_websocket_authentication_required(): with TestClient(app) as client: with pytest.raises(WebSocketDisconnect): - client.websocket_connect("/ws") + with client.websocket_connect("/ws"): + pass # pragma: nocover with pytest.raises(WebSocketDisconnect): - client.websocket_connect("/ws", headers={"Authorization": "basic foobar"}) + with client.websocket_connect( + "/ws", headers={"Authorization": "basic foobar"} + ): + pass # pragma: nocover with client.websocket_connect( "/ws", auth=("tomchristie", "example") @@ -273,12 +277,14 @@ def test_websocket_authentication_required(): assert data == {"authenticated": True, "user": "tomchristie"} with pytest.raises(WebSocketDisconnect): - client.websocket_connect("/ws/decorated") + with client.websocket_connect("/ws/decorated"): + pass # pragma: nocover with pytest.raises(WebSocketDisconnect): - client.websocket_connect( + with client.websocket_connect( "/ws/decorated", headers={"Authorization": "basic foobar"} - ) + ): + pass # pragma: nocover with client.websocket_connect( "/ws/decorated", auth=("tomchristie", "example") diff --git a/tests/test_concurrency.py b/tests/test_concurrency.py new file mode 100644 index 0000000000..cc5eba974f --- /dev/null +++ b/tests/test_concurrency.py @@ -0,0 +1,22 @@ +import anyio +import pytest + +from starlette.concurrency import run_until_first_complete + + +@pytest.mark.anyio +async def test_run_until_first_complete(): + task1_finished = anyio.Event() + task2_finished = anyio.Event() + + async def task1(): + task1_finished.set() + + async def task2(): + await task1_finished.wait() + await anyio.sleep(0) # pragma: nocover + task2_finished.set() # pragma: nocover + + await run_until_first_complete((task1, {}), (task2, {})) + assert task1_finished.is_set() + assert not task2_finished.is_set() diff --git a/tests/test_database.py b/tests/test_database.py index 258a71ec51..f7280c2c71 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -19,6 +19,9 @@ ) +pytestmark = pytest.mark.usefixtures("no_trio_support") + + @pytest.fixture(autouse=True, scope="module") def create_test_database(): engine = sqlalchemy.create_engine(DATABASE_URL) diff --git a/tests/test_datastructures.py b/tests/test_datastructures.py index b0e6baf985..bb71ba870c 100644 --- a/tests/test_datastructures.py +++ b/tests/test_datastructures.py @@ -217,7 +217,7 @@ class BigUploadFile(UploadFile): spool_max_size = 1024 -@pytest.mark.asyncio +@pytest.mark.anyio async def test_upload_file(): big_file = BigUploadFile("big-file") await big_file.write(b"big-data" * 512) diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 841c9a5cf6..bab6961b52 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -54,7 +54,8 @@ def test_not_modified(): def test_websockets_should_raise(): with pytest.raises(RuntimeError): - client.websocket_connect("/runtime_error") + with client.websocket_connect("/runtime_error"): + pass # pragma: nocover def test_handled_exc_after_response(): diff --git a/tests/test_graphql.py b/tests/test_graphql.py index 67f3072318..b945a5cfe1 100644 --- a/tests/test_graphql.py +++ b/tests/test_graphql.py @@ -1,5 +1,4 @@ import graphene -import pytest from graphql.execution.executors.asyncio import AsyncioExecutor from starlette.applications import Starlette @@ -142,27 +141,8 @@ async def resolve_hello(self, info, name): async_app = GraphQLApp(schema=async_schema, executor_class=AsyncioExecutor) -def test_graphql_async(): +def test_graphql_async(no_trio_support): client = TestClient(async_app) response = client.get("/?query={ hello }") assert response.status_code == 200 assert response.json() == {"data": {"hello": "Hello stranger"}} - - -async_schema = graphene.Schema(query=ASyncQuery) - - -@pytest.fixture -def old_style_async_app(event_loop) -> GraphQLApp: - old_style_async_app = GraphQLApp( - schema=async_schema, executor=AsyncioExecutor(loop=event_loop) - ) - return old_style_async_app - - -def test_graphql_async_old_style_executor(old_style_async_app: GraphQLApp): - # See https://github.com/encode/starlette/issues/242 - client = TestClient(old_style_async_app) - response = client.get("/?query={ hello }") - assert response.status_code == 200 - assert response.json() == {"data": {"hello": "Hello stranger"}} diff --git a/tests/test_requests.py b/tests/test_requests.py index a83a2c480b..fee059ab2e 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -1,5 +1,4 @@ -import asyncio - +import anyio import pytest from starlette.requests import ClientDisconnect, Request, State @@ -212,9 +211,8 @@ async def receiver(): return {"type": "http.disconnect"} scope = {"type": "http", "method": "POST", "path": "/"} - loop = asyncio.get_event_loop() with pytest.raises(ClientDisconnect): - loop.run_until_complete(app(scope, receiver, None)) + anyio.run(app, scope, receiver, None) def test_request_is_disconnected(): diff --git a/tests/test_responses.py b/tests/test_responses.py index fd2ba0e424..496e64c86a 100644 --- a/tests/test_responses.py +++ b/tests/test_responses.py @@ -1,6 +1,6 @@ -import asyncio import os +import anyio import pytest from starlette import status @@ -83,7 +83,7 @@ async def numbers(minimum, maximum): yield str(i) if i != maximum: yield ", " - await asyncio.sleep(0) + await anyio.sleep(0) async def numbers_for_cleanup(start=1, stop=5): nonlocal filled_by_bg_task @@ -197,7 +197,7 @@ async def numbers(minimum, maximum): yield str(i) if i != maximum: yield ", " - await asyncio.sleep(0) + await anyio.sleep(0) async def numbers_for_cleanup(start=1, stop=5): nonlocal filled_by_bg_task diff --git a/tests/test_routing.py b/tests/test_routing.py index fff3332dbd..1d8eb8d957 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -286,7 +286,8 @@ def test_protocol_switch(): assert session.receive_json() == {"URL": "ws://testserver/"} with pytest.raises(WebSocketDisconnect): - client.websocket_connect("/404") + with client.websocket_connect("/404"): + pass # pragma: nocover ok = PlainTextResponse("OK") @@ -492,7 +493,8 @@ def test_standalone_ws_route_does_not_match(): app = WebSocketRoute("/", ws_helloworld) client = TestClient(app) with pytest.raises(WebSocketDisconnect): - client.websocket_connect("/invalid") + with client.websocket_connect("/invalid"): + pass # pragma: nocover def test_lifespan_async(): diff --git a/tests/test_staticfiles.py b/tests/test_staticfiles.py index 6b325071fa..3c8ff240e5 100644 --- a/tests/test_staticfiles.py +++ b/tests/test_staticfiles.py @@ -1,8 +1,8 @@ -import asyncio import os import pathlib import time +import anyio import pytest from starlette.applications import Starlette @@ -153,8 +153,7 @@ def test_staticfiles_prevents_breaking_out_of_directory(tmpdir): # We can't test this with 'requests', so we test the app directly here. path = app.get_path({"path": "/../example.txt"}) scope = {"method": "GET"} - loop = asyncio.get_event_loop() - response = loop.run_until_complete(app.get_response(path, scope)) + response = anyio.run(app.get_response, path, scope) assert response.status_code == 404 assert response.body == b"Not Found" diff --git a/tests/test_testclient.py b/tests/test_testclient.py index 00f4e0125b..86f36e172a 100644 --- a/tests/test_testclient.py +++ b/tests/test_testclient.py @@ -1,5 +1,4 @@ -import asyncio - +import anyio import pytest from starlette.applications import Starlette @@ -118,13 +117,14 @@ async def respond(websocket): async def asgi(receive, send): websocket = WebSocket(scope, receive=receive, send=send) await websocket.accept() - asyncio.ensure_future(respond(websocket)) - try: - # this will block as the client does not send us data - # it should not prevent `respond` from executing though - await websocket.receive_json() - except WebSocketDisconnect: - pass + async with anyio.create_task_group() as task_group: + task_group.start_soon(respond, websocket) + try: + # this will block as the client does not send us data + # it should not prevent `respond` from executing though + await websocket.receive_json() + except WebSocketDisconnect: + pass return asgi diff --git a/tests/test_websockets.py b/tests/test_websockets.py index ffb1a44a8c..63ecd050ac 100644 --- a/tests/test_websockets.py +++ b/tests/test_websockets.py @@ -1,9 +1,7 @@ -import asyncio - +import anyio import pytest from starlette import status -from starlette.concurrency import run_until_first_complete from starlette.testclient import TestClient from starlette.websockets import WebSocket, WebSocketDisconnect @@ -208,23 +206,24 @@ async def asgi(receive, send): def test_websocket_concurrency_pattern(): def app(scope): - async def reader(websocket, queue): - async for data in websocket.iter_json(): - await queue.put(data) + stream_send, stream_receive = anyio.create_memory_object_stream() - async def writer(websocket, queue): - while True: - message = await queue.get() - await websocket.send_json(message) + async def reader(websocket): + async with stream_send: + async for data in websocket.iter_json(): + await stream_send.send(data) + + async def writer(websocket): + async with stream_receive: + async for message in stream_receive: + await websocket.send_json(message) async def asgi(receive, send): websocket = WebSocket(scope, receive=receive, send=send) - queue = asyncio.Queue() await websocket.accept() - await run_until_first_complete( - (reader, {"websocket": websocket, "queue": queue}), - (writer, {"websocket": websocket, "queue": queue}), - ) + async with anyio.create_task_group() as task_group: + task_group.start_soon(reader, websocket) + await writer(websocket) await websocket.close() return asgi @@ -283,7 +282,8 @@ async def asgi(receive, send): client = TestClient(app) with pytest.raises(WebSocketDisconnect) as exc: - client.websocket_connect("/") + with client.websocket_connect("/"): + pass # pragma: nocover assert exc.value.code == status.WS_1001_GOING_AWAY @@ -311,7 +311,8 @@ async def asgi(receive, send): client = TestClient(app) with pytest.raises(AssertionError): - client.websocket_connect("/123?a=abc") + with client.websocket_connect("/123?a=abc"): + pass # pragma: nocover def test_duplicate_close(): @@ -327,7 +328,7 @@ async def asgi(receive, send): client = TestClient(app) with pytest.raises(RuntimeError): with client.websocket_connect("/"): - pass + pass # pragma: nocover def test_duplicate_disconnect(): From d917501af55afd9d3117b54413cd22de2e80afb9 Mon Sep 17 00:00:00 2001 From: Jamie Hewland Date: Fri, 18 Jun 2021 16:40:09 +0100 Subject: [PATCH 04/37] Clean up last bit of aiofiles after #1157 (#1203) --- requirements.txt | 2 -- setup.cfg | 2 -- 2 files changed, 4 deletions(-) diff --git a/requirements.txt b/requirements.txt index ae3d91f263..8394c4a130 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,4 @@ # Optionals -aiofiles graphene itsdangerous jinja2 @@ -16,7 +15,6 @@ isort==5.* mypy types-requests types-contextvars -types-aiofiles types-PyYAML types-dataclasses pytest diff --git a/setup.cfg b/setup.cfg index 51f2aad322..ef52a63369 100644 --- a/setup.cfg +++ b/setup.cfg @@ -27,8 +27,6 @@ xfail_strict=True filterwarnings= # Turn warnings that aren't filtered into exceptions error - # https://github.com/Tinche/aiofiles/issues/81 - ignore: "@coroutine" decorator is deprecated.*:DeprecationWarning # Deprecated GraphQL (including https://github.com/graphql-python/graphene/issues/1055) ignore: GraphQLApp is deprecated and will be removed in a future release\..*:DeprecationWarning ignore: Using or importing the ABCs from 'collections' instead of from 'collections\.abc' is deprecated.*:DeprecationWarning From a839d9220dee74c60b2255ec12bed171d1d15a9b Mon Sep 17 00:00:00 2001 From: Jamie Hewland Date: Sat, 19 Jun 2021 12:42:56 +0100 Subject: [PATCH 05/37] Use coverage directly instead of pytest-cov (#1204) * Use coverage directly instead of pytest-cov * Use coverage's source_pkgs --- requirements.txt | 2 +- scripts/test | 2 +- setup.cfg | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements.txt b/requirements.txt index 8394c4a130..5d0fd280d5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,6 +9,7 @@ requests # Testing autoflake black==20.8b1 +coverage>=5.3 databases[sqlite] flake8 isort==5.* @@ -18,7 +19,6 @@ types-contextvars types-PyYAML types-dataclasses pytest -pytest-cov trio # Documentation diff --git a/scripts/test b/scripts/test index f9c9917233..720a66392d 100755 --- a/scripts/test +++ b/scripts/test @@ -11,7 +11,7 @@ if [ -z $GITHUB_ACTIONS ]; then scripts/check fi -${PREFIX}pytest $@ +${PREFIX}coverage run -m pytest $@ if [ -z $GITHUB_ACTIONS ]; then scripts/coverage diff --git a/setup.cfg b/setup.cfg index ef52a63369..1f2db52f0f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -17,9 +17,6 @@ combine_as_imports = True [tool:pytest] addopts = - --cov-report=term-missing:skip-covered - --cov=starlette - --cov=tests -rxXs --strict-config --strict-markers @@ -32,3 +29,6 @@ filterwarnings= ignore: Using or importing the ABCs from 'collections' instead of from 'collections\.abc' is deprecated.*:DeprecationWarning ignore: The 'context' alias has been deprecated. Please use 'context_value' instead\.:DeprecationWarning ignore: The 'variables' alias has been deprecated. Please use 'variable_values' instead\.:DeprecationWarning + +[coverage:run] +source_pkgs = starlette, tests From ab0fff9dd3377e4ddd2035994d9e1736fed43c62 Mon Sep 17 00:00:00 2001 From: Jamie Hewland Date: Sat, 19 Jun 2021 18:02:53 +0100 Subject: [PATCH 06/37] Test on Python 3.10 (#1201) --- .github/workflows/test-suite.yml | 2 +- docs/graphql.md | 7 ++++--- requirements.txt | 2 +- setup.cfg | 5 +++++ setup.py | 3 ++- tests/conftest.py | 4 ++++ 6 files changed, 17 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index eed46850a9..751c5193be 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -14,7 +14,7 @@ jobs: strategy: matrix: - python-version: ["3.6", "3.7", "3.8", "3.9"] + python-version: ["3.6", "3.7", "3.8", "3.9", "3.10.0-beta.3"] steps: - uses: "actions/checkout@v2" diff --git a/docs/graphql.md b/docs/graphql.md index 72b6478f81..281bdea850 100644 --- a/docs/graphql.md +++ b/docs/graphql.md @@ -2,9 +2,10 @@ !!! Warning GraphQL support in Starlette is **deprecated** as of version 0.15 and will - be removed in a future release. Please consider using a third-party library - to provide GraphQL support. This is usually done by mounting a GraphQL ASGI - application. See [#619](https://github.com/encode/starlette/issues/619). + be removed in a future release. It is also incompatible with Python 3.10+. + Please consider using a third-party library to provide GraphQL support. This + is usually done by mounting a GraphQL ASGI application. + See [#619](https://github.com/encode/starlette/issues/619). Some example libraries are: * [Ariadne](https://ariadnegraphql.org/docs/asgi) diff --git a/requirements.txt b/requirements.txt index 5d0fd280d5..abc7a3b0a5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ # Optionals -graphene +graphene; python_version<'3.10' itsdangerous jinja2 python-multipart diff --git a/setup.cfg b/setup.cfg index 1f2db52f0f..f59a720292 100644 --- a/setup.cfg +++ b/setup.cfg @@ -32,3 +32,8 @@ filterwarnings= [coverage:run] source_pkgs = starlette, tests +# GraphQLApp incompatible with and untested on Python 3.10. It's deprecated, let's just ignore +# coverage for it until it's gone. +omit = + starlette/graphql.py + tests/test_graphql.py diff --git a/setup.py b/setup.py index a687ad861d..978a606c76 100644 --- a/setup.py +++ b/setup.py @@ -40,7 +40,7 @@ def get_long_description(): install_requires=["anyio>=3.0.0,<4"], extras_require={ "full": [ - "graphene", + "graphene; python_version<'3.10'", "itsdangerous", "jinja2", "python-multipart", @@ -60,6 +60,7 @@ def get_long_description(): "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", ], zip_safe=False, ) diff --git a/tests/conftest.py b/tests/conftest.py index d1f3ba8e48..9ed420305d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,11 @@ +import sys + import pytest from starlette.testclient import TestClient +collect_ignore = ["test_graphql.py"] if sys.version_info >= (3, 10) else [] + @pytest.fixture( params=[ From 7ed2890146705accb6c3924afb7f5e55010c0a1f Mon Sep 17 00:00:00 2001 From: Aber Date: Mon, 21 Jun 2021 16:09:19 +0800 Subject: [PATCH 07/37] Fixed TestClient error when response headers missing (#1200) * Fixed https://github.com/abersheeran/asgi-ratelimit/issues/14 * lint it * Black it Co-authored-by: euri10 --- starlette/testclient.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/starlette/testclient.py b/starlette/testclient.py index c1c0fe1652..6675f49714 100644 --- a/starlette/testclient.py +++ b/starlette/testclient.py @@ -214,7 +214,8 @@ async def send(message: Message) -> None: raw_kwargs["status"] = message["status"] raw_kwargs["reason"] = _get_reason_phrase(message["status"]) raw_kwargs["headers"] = [ - (key.decode(), value.decode()) for key, value in message["headers"] + (key.decode(), value.decode()) + for key, value in message.get("headers", []) ] raw_kwargs["preload_content"] = False raw_kwargs["original_response"] = _MockOriginalResponse( From 119c427474a45bd1297f5adcde864b2874dd8c4f Mon Sep 17 00:00:00 2001 From: Jamie Hewland Date: Wed, 23 Jun 2021 09:31:02 +0100 Subject: [PATCH 08/37] Prepare version 0.15.0 (#1202) * Prepare version 0.15.0 * Remember to add a note about websocket_connect * Add date and blurb to release notes * Bump version to 0.15.0 * Add note about fixing #1012 --- docs/release-notes.md | 42 +++++++++++++++++++++++++++++++++++++++--- starlette/__init__.py | 2 +- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/docs/release-notes.md b/docs/release-notes.md index 08f1e2895b..b6db7b9b60 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,11 +1,47 @@ ## 0.15.0 -Unreleased +June 23, 2021 -### Deprecated +This release includes major changes to the low-level asynchronous parts of Starlette. As a result, +**Starlette now depends on [AnyIO](https://anyio.readthedocs.io/en/stable/)** and some minor API +changes have occurred. Another significant change with this release is the +**deprecation of built-in GraphQL support**. + +### Added +* Starlette now supports [Trio](https://trio.readthedocs.io/en/stable/) as an async runtime via + AnyIO - [#1157](https://github.com/encode/starlette/pull/1157). +* `TestClient.websocket_connect()` now must be used as a context manager. +* Initial support for Python 3.10 - [#1201](https://github.com/encode/starlette/pull/1201). +* The compression level used in `GZipMiddleware` is now adjustable - + [#1128](https://github.com/encode/starlette/pull/1128). + +### Fixed +* Several fixes to `CORSMiddleware`. See [#1111](https://github.com/encode/starlette/pull/1111), + [#1112](https://github.com/encode/starlette/pull/1112), + [#1113](https://github.com/encode/starlette/pull/1113), + [#1199](https://github.com/encode/starlette/pull/1199). +* Improved exception messages in the case of duplicated path parameter names - + [#1177](https://github.com/encode/starlette/pull/1177). +* `RedirectResponse` now uses `quote` instead of `quote_plus` encoding for the `Location` header + to better match the behaviour in other frameworks such as Django - + [#1164](https://github.com/encode/starlette/pull/1164). +* Exception causes are now preserved in more cases - + [#1158](https://github.com/encode/starlette/pull/1158). +* Session cookies now use the ASGI root path in the case of mounted applications - + [#1147](https://github.com/encode/starlette/pull/1147). +* Fixed a cache invalidation bug when static files were deleted in certain circumstances - + [#1023](https://github.com/encode/starlette/pull/1023). +* Improved memory usage of `BaseHTTPMiddleware` when handling large responses - + [#1012](https://github.com/encode/starlette/issues/1012) fixed via #1157 + +### Deprecated/removed * Built-in GraphQL support via the `GraphQLApp` class has been deprecated and will be removed in a - future release. Please see [#619](https://github.com/encode/starlette/issues/619). + future release. Please see [#619](https://github.com/encode/starlette/issues/619). GraphQL is not + supported on Python 3.10. +* The `executor` parameter to `GraphQLApp` was removed. Use `executor_class` instead. +* The `workers` parameter to `WSGIMiddleware` was removed. This hasn't had any effect since + Starlette v0.6.3. ## 0.14.2 diff --git a/starlette/__init__.py b/starlette/__init__.py index 745162e731..9da2f8fcca 100644 --- a/starlette/__init__.py +++ b/starlette/__init__.py @@ -1 +1 @@ -__version__ = "0.14.2" +__version__ = "0.15.0" From 66266369bba14613295f5320e4ab01b1524d4b83 Mon Sep 17 00:00:00 2001 From: Jamie Hewland Date: Fri, 25 Jun 2021 08:46:38 +0100 Subject: [PATCH 09/37] mkdocs: Set site_url (#1215) --- mkdocs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/mkdocs.yml b/mkdocs.yml index a20f1ea7eb..b1237aefb4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,5 +1,6 @@ site_name: Starlette site_description: The little ASGI library that shines. +site_url: https://www.starlette.io theme: name: 'material' From 070d749f66999206c4c71cb203a2ea400e0b3c00 Mon Sep 17 00:00:00 2001 From: Amin Alaee Date: Sun, 27 Jun 2021 16:56:14 +0430 Subject: [PATCH 10/37] Make Jinja2Templates.get_env private & rename (#1218) * make-jinja2-get-env-internal * rename _get_env to _create_env --- starlette/templating.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/starlette/templating.py b/starlette/templating.py index 64fdbd14f6..36f613fdfd 100644 --- a/starlette/templating.py +++ b/starlette/templating.py @@ -56,9 +56,9 @@ class Jinja2Templates: def __init__(self, directory: str) -> None: assert jinja2 is not None, "jinja2 must be installed to use Jinja2Templates" - self.env = self.get_env(directory) + self.env = self._create_env(directory) - def get_env(self, directory: str) -> "jinja2.Environment": + def _create_env(self, directory: str) -> "jinja2.Environment": @pass_context def url_for(context: dict, name: str, **path_params: typing.Any) -> str: request = context["request"] From 0ef44186331522f455d0038c04beb7f6da2568ca Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Mon, 28 Jun 2021 12:15:08 +0200 Subject: [PATCH 11/37] :wrench: Add funding option (#1219) --- .github/FUNDING.yml | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000000..2f87d94ca1 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: encode From 906e9073a4cc6180a6c1f240118dd29d47e0f7e3 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Mon, 28 Jun 2021 13:02:18 +0100 Subject: [PATCH 12/37] =?UTF-8?q?reset=20the=20`=5F=5Feq=5F=5F`=20and=20`?= =?UTF-8?q?=5F=5Fhash=5F=5F`=20of=20HTTPConnection=20to=20allow=20WebSocke?= =?UTF-8?q?ts=20to=20be=20added=20to=20=E2=80=A6=20(#1039)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- starlette/requests.py | 6 ++++++ tests/test_websockets.py | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/starlette/requests.py b/starlette/requests.py index 54ed8611e3..f88021645c 100644 --- a/starlette/requests.py +++ b/starlette/requests.py @@ -74,6 +74,12 @@ def __iter__(self) -> typing.Iterator[str]: def __len__(self) -> int: return len(self.scope) + # Don't use the `abc.Mapping.__eq__` implementation. + # Connection instances should never be considered equal + # unless `self is other`. + __eq__ = object.__eq__ + __hash__ = object.__hash__ + @property def app(self) -> typing.Any: return self.scope["app"] diff --git a/tests/test_websockets.py b/tests/test_websockets.py index 63ecd050ac..f5d52215b2 100644 --- a/tests/test_websockets.py +++ b/tests/test_websockets.py @@ -368,3 +368,13 @@ async def mock_send(message): assert websocket["type"] == "websocket" assert dict(websocket) == {"type": "websocket", "path": "/abc/", "headers": []} assert len(websocket) == 3 + + # check __eq__ and __hash__ + assert websocket != WebSocket( + {"type": "websocket", "path": "/abc/", "headers": []}, + receive=mock_receive, + send=mock_send, + ) + assert websocket == websocket + assert websocket in {websocket} + assert {websocket} == {websocket} From d222b87cb4601ecda5d642ab504a14974d364db4 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Mon, 28 Jun 2021 21:36:13 +0100 Subject: [PATCH 13/37] TestClient accepts backend and backend_options as arguments to constructor (#1211) as opposed to ClassVar assignment Co-authored-by: Jamie Hewland Co-authored-by: Jordan Speicher Co-authored-by: Jordan Speicher --- docs/testclient.md | 23 +++--- setup.py | 5 +- starlette/testclient.py | 30 +++++--- tests/conftest.py | 29 ++++---- tests/middleware/test_base.py | 21 +++--- tests/middleware/test_cors.py | 67 +++++++++-------- tests/middleware/test_errors.py | 21 +++--- tests/middleware/test_gzip.py | 17 +++-- tests/middleware/test_https_redirect.py | 13 ++-- tests/middleware/test_session.py | 19 +++-- tests/middleware/test_trusted_host.py | 13 ++-- tests/middleware/test_wsgi.py | 19 +++-- tests/test_applications.py | 64 +++++++++-------- tests/test_authentication.py | 21 +++--- tests/test_background.py | 13 ++-- tests/test_database.py | 15 ++-- tests/test_endpoints.py | 41 ++++++----- tests/test_exceptions.py | 22 +++--- tests/test_formparsers.py | 61 ++++++++-------- tests/test_graphql.py | 44 ++++++------ tests/test_requests.py | 96 +++++++++++++------------ tests/test_responses.py | 79 ++++++++++---------- tests/test_routing.py | 80 +++++++++++---------- tests/test_schemas.py | 5 +- tests/test_staticfiles.py | 67 +++++++++-------- tests/test_templates.py | 5 +- tests/test_testclient.py | 43 +++++------ tests/test_websockets.py | 77 ++++++++++---------- 28 files changed, 525 insertions(+), 485 deletions(-) diff --git a/docs/testclient.md b/docs/testclient.md index f378584018..a1861efec7 100644 --- a/docs/testclient.md +++ b/docs/testclient.md @@ -33,18 +33,25 @@ case you should use `client = TestClient(app, raise_server_exceptions=False)`. ### Selecting the Async backend -`TestClient.async_backend` is a dictionary which allows you to set the options -for the backend used to run tests. These options are passed to -`anyio.start_blocking_portal()`. See the [anyio documentation](https://anyio.readthedocs.io/en/stable/basics.html#backend-options) -for more information about backend options. By default, `asyncio` is used. +`TestClient` takes arguments `backend` (a string) and `backend_options` (a dictionary). +These options are passed to `anyio.start_blocking_portal()`. See the [anyio documentation](https://anyio.readthedocs.io/en/stable/basics.html#backend-options) +for more information about the accepted backend options. +By default, `asyncio` is used with default options. -To run `Trio`, set `async_backend["backend"] = "trio"`, for example: +To run `Trio`, pass `backend="trio"`. For example: ```python def test_app() - client = TestClient(app) - client.async_backend["backend"] = "trio" - ... + with TestClient(app, backend="trio") as client: + ... +``` + +To run `asyncio` with `uvloop`, pass `backend_options={"use_uvloop": True}`. For example: + +```python +def test_app() + with TestClient(app, backend_options={"use_uvloop": True}) as client: + ... ``` ### Testing WebSocket sessions diff --git a/setup.py b/setup.py index 978a606c76..ac6479746f 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,10 @@ def get_long_description(): packages=find_packages(exclude=["tests*"]), package_data={"starlette": ["py.typed"]}, include_package_data=True, - install_requires=["anyio>=3.0.0,<4"], + install_requires=[ + "anyio>=3.0.0,<4", + "typing_extensions; python_version < '3.8'", + ], extras_require={ "full": [ "graphene; python_version<'3.10'", diff --git a/starlette/testclient.py b/starlette/testclient.py index 6675f49714..33bb410d02 100644 --- a/starlette/testclient.py +++ b/starlette/testclient.py @@ -6,6 +6,7 @@ import json import math import queue +import sys import types import typing from concurrent.futures import Future @@ -18,6 +19,11 @@ from starlette.types import Message, Receive, Scope, Send from starlette.websockets import WebSocketDisconnect +if sys.version_info >= (3, 8): # pragma: no cover + from typing import TypedDict +else: # pragma: no cover + from typing_extensions import TypedDict + # Annotations for `Session.request()` Cookies = typing.Union[ typing.MutableMapping[str, str], requests.cookies.RequestsCookieJar @@ -91,11 +97,16 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: await instance(receive, send) +class _AsyncBackend(TypedDict): + backend: str + backend_options: typing.Dict[str, typing.Any] + + class _ASGIAdapter(requests.adapters.HTTPAdapter): def __init__( self, app: ASGI3App, - async_backend: typing.Dict[str, typing.Any], + async_backend: _AsyncBackend, raise_server_exceptions: bool = True, root_path: str = "", ) -> None: @@ -271,7 +282,10 @@ async def send(message: Message) -> None: class WebSocketTestSession: def __init__( - self, app: ASGI3App, scope: Scope, async_backend: typing.Dict[str, typing.Any] + self, + app: ASGI3App, + scope: Scope, + async_backend: _AsyncBackend, ) -> None: self.app = app self.scope = scope @@ -381,13 +395,6 @@ def receive_json(self, mode: str = "text") -> typing.Any: class TestClient(requests.Session): __test__ = False # For pytest to not discover this up. - - #: These options are passed to `anyio.start_blocking_portal()` - async_backend: typing.Dict[str, typing.Any] = { - "backend": "asyncio", - "backend_options": {}, - } - task: "Future[None]" def __init__( @@ -396,8 +403,13 @@ def __init__( base_url: str = "http://testserver", raise_server_exceptions: bool = True, root_path: str = "", + backend: str = "asyncio", + backend_options: typing.Optional[typing.Dict[str, typing.Any]] = None, ) -> None: super().__init__() + self.async_backend = _AsyncBackend( + backend=backend, backend_options=backend_options or {} + ) if _is_asgi3(app): app = typing.cast(ASGI3App, app) asgi_app = app diff --git a/tests/conftest.py b/tests/conftest.py index 9ed420305d..bb68aa5e20 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +import functools import sys import pytest @@ -7,22 +8,18 @@ collect_ignore = ["test_graphql.py"] if sys.version_info >= (3, 10) else [] -@pytest.fixture( - params=[ - pytest.param( - {"backend": "asyncio", "backend_options": {"use_uvloop": False}}, - id="asyncio", - ), - pytest.param({"backend": "trio", "backend_options": {}}, id="trio"), - ], - autouse=True, -) -def anyio_backend(request, monkeypatch): - monkeypatch.setattr(TestClient, "async_backend", request.param) - return request.param["backend"] +@pytest.fixture +def no_trio_support(anyio_backend_name): + if anyio_backend_name == "trio": + pytest.skip("Trio not supported (yet!)") @pytest.fixture -def no_trio_support(request): - if request.keywords.get("trio"): - pytest.skip("Trio not supported (yet!)") +def test_client_factory(anyio_backend_name, anyio_backend_options): + # anyio_backend_name defined by: + # https://anyio.readthedocs.io/en/stable/testing.html#specifying-the-backends-to-run-on + return functools.partial( + TestClient, + backend=anyio_backend_name, + backend_options=anyio_backend_options, + ) diff --git a/tests/middleware/test_base.py b/tests/middleware/test_base.py index df8901934a..8a8df4ea66 100644 --- a/tests/middleware/test_base.py +++ b/tests/middleware/test_base.py @@ -5,7 +5,6 @@ from starlette.middleware.base import BaseHTTPMiddleware from starlette.responses import PlainTextResponse from starlette.routing import Route -from starlette.testclient import TestClient class CustomMiddleware(BaseHTTPMiddleware): @@ -48,8 +47,8 @@ async def websocket_endpoint(session): await session.close() -def test_custom_middleware(): - client = TestClient(app) +def test_custom_middleware(test_client_factory): + client = test_client_factory(app) response = client.get("/") assert response.headers["Custom-Header"] == "Example" @@ -64,7 +63,7 @@ def test_custom_middleware(): assert text == "Hello, world!" -def test_middleware_decorator(): +def test_middleware_decorator(test_client_factory): app = Starlette() @app.route("/homepage") @@ -79,7 +78,7 @@ async def plaintext(request, call_next): response.headers["Custom"] = "Example" return response - client = TestClient(app) + client = test_client_factory(app) response = client.get("/") assert response.text == "OK" @@ -88,7 +87,7 @@ async def plaintext(request, call_next): assert response.headers["Custom"] == "Example" -def test_state_data_across_multiple_middlewares(): +def test_state_data_across_multiple_middlewares(test_client_factory): expected_value1 = "foo" expected_value2 = "bar" @@ -120,14 +119,14 @@ async def dispatch(self, request, call_next): def homepage(request): return PlainTextResponse("OK") - client = TestClient(app) + client = test_client_factory(app) response = client.get("/") assert response.text == "OK" assert response.headers["X-State-Foo"] == expected_value1 assert response.headers["X-State-Bar"] == expected_value2 -def test_app_middleware_argument(): +def test_app_middleware_argument(test_client_factory): def homepage(request): return PlainTextResponse("Homepage") @@ -135,7 +134,7 @@ def homepage(request): routes=[Route("/", homepage)], middleware=[Middleware(CustomMiddleware)] ) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/") assert response.headers["Custom-Header"] == "Example" @@ -145,7 +144,7 @@ def test_middleware_repr(): assert repr(middleware) == "Middleware(CustomMiddleware)" -def test_fully_evaluated_response(): +def test_fully_evaluated_response(test_client_factory): # Test for https://github.com/encode/starlette/issues/1022 class CustomMiddleware(BaseHTTPMiddleware): async def dispatch(self, request, call_next): @@ -155,6 +154,6 @@ async def dispatch(self, request, call_next): app = Starlette() app.add_middleware(CustomMiddleware) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/does_not_exist") assert response.text == "Custom" diff --git a/tests/middleware/test_cors.py b/tests/middleware/test_cors.py index 266ebca5b4..65252e5024 100644 --- a/tests/middleware/test_cors.py +++ b/tests/middleware/test_cors.py @@ -1,10 +1,9 @@ from starlette.applications import Starlette from starlette.middleware.cors import CORSMiddleware from starlette.responses import PlainTextResponse -from starlette.testclient import TestClient -def test_cors_allow_all(): +def test_cors_allow_all(test_client_factory): app = Starlette() app.add_middleware( @@ -20,7 +19,7 @@ def test_cors_allow_all(): def homepage(request): return PlainTextResponse("Homepage", status_code=200) - client = TestClient(app) + client = test_client_factory(app) # Test pre-flight response headers = { @@ -61,7 +60,7 @@ def homepage(request): assert "access-control-allow-origin" not in response.headers -def test_cors_allow_all_except_credentials(): +def test_cors_allow_all_except_credentials(test_client_factory): app = Starlette() app.add_middleware( @@ -76,7 +75,7 @@ def test_cors_allow_all_except_credentials(): def homepage(request): return PlainTextResponse("Homepage", status_code=200) - client = TestClient(app) + client = test_client_factory(app) # Test pre-flight response headers = { @@ -108,7 +107,7 @@ def homepage(request): assert "access-control-allow-origin" not in response.headers -def test_cors_allow_specific_origin(): +def test_cors_allow_specific_origin(test_client_factory): app = Starlette() app.add_middleware( @@ -121,7 +120,7 @@ def test_cors_allow_specific_origin(): def homepage(request): return PlainTextResponse("Homepage", status_code=200) - client = TestClient(app) + client = test_client_factory(app) # Test pre-flight response headers = { @@ -153,7 +152,7 @@ def homepage(request): assert "access-control-allow-origin" not in response.headers -def test_cors_disallowed_preflight(): +def test_cors_disallowed_preflight(test_client_factory): app = Starlette() app.add_middleware( @@ -166,7 +165,7 @@ def test_cors_disallowed_preflight(): def homepage(request): pass # pragma: no cover - client = TestClient(app) + client = test_client_factory(app) # Test pre-flight response headers = { @@ -190,7 +189,9 @@ def homepage(request): assert response.text == "Disallowed CORS headers" -def test_preflight_allows_request_origin_if_origins_wildcard_and_credentials_allowed(): +def test_preflight_allows_request_origin_if_origins_wildcard_and_credentials_allowed( + test_client_factory, +): app = Starlette() app.add_middleware( @@ -204,7 +205,7 @@ def test_preflight_allows_request_origin_if_origins_wildcard_and_credentials_all def homepage(request): return # pragma: no cover - client = TestClient(app) + client = test_client_factory(app) # Test pre-flight response headers = { @@ -221,7 +222,7 @@ def homepage(request): assert response.headers["vary"] == "Origin" -def test_cors_preflight_allow_all_methods(): +def test_cors_preflight_allow_all_methods(test_client_factory): app = Starlette() app.add_middleware( @@ -234,7 +235,7 @@ def test_cors_preflight_allow_all_methods(): def homepage(request): pass # pragma: no cover - client = TestClient(app) + client = test_client_factory(app) headers = { "Origin": "https://example.org", @@ -247,7 +248,7 @@ def homepage(request): assert method in response.headers["access-control-allow-methods"] -def test_cors_allow_all_methods(): +def test_cors_allow_all_methods(test_client_factory): app = Starlette() app.add_middleware( @@ -262,7 +263,7 @@ def test_cors_allow_all_methods(): def homepage(request): return PlainTextResponse("Homepage", status_code=200) - client = TestClient(app) + client = test_client_factory(app) headers = {"Origin": "https://example.org"} @@ -271,7 +272,7 @@ def homepage(request): assert response.status_code == 200 -def test_cors_allow_origin_regex(): +def test_cors_allow_origin_regex(test_client_factory): app = Starlette() app.add_middleware( @@ -285,7 +286,7 @@ def test_cors_allow_origin_regex(): def homepage(request): return PlainTextResponse("Homepage", status_code=200) - client = TestClient(app) + client = test_client_factory(app) # Test standard response headers = {"Origin": "https://example.org"} @@ -339,7 +340,7 @@ def homepage(request): assert "access-control-allow-origin" not in response.headers -def test_cors_allow_origin_regex_fullmatch(): +def test_cors_allow_origin_regex_fullmatch(test_client_factory): app = Starlette() app.add_middleware( @@ -352,7 +353,7 @@ def test_cors_allow_origin_regex_fullmatch(): def homepage(request): return PlainTextResponse("Homepage", status_code=200) - client = TestClient(app) + client = test_client_factory(app) # Test standard response headers = {"Origin": "https://subdomain.example.org"} @@ -373,7 +374,7 @@ def homepage(request): assert "access-control-allow-origin" not in response.headers -def test_cors_credentialed_requests_return_specific_origin(): +def test_cors_credentialed_requests_return_specific_origin(test_client_factory): app = Starlette() app.add_middleware(CORSMiddleware, allow_origins=["*"]) @@ -382,7 +383,7 @@ def test_cors_credentialed_requests_return_specific_origin(): def homepage(request): return PlainTextResponse("Homepage", status_code=200) - client = TestClient(app) + client = test_client_factory(app) # Test credentialed request headers = {"Origin": "https://example.org", "Cookie": "star_cookie=sugar"} @@ -393,7 +394,7 @@ def homepage(request): assert "access-control-allow-credentials" not in response.headers -def test_cors_vary_header_defaults_to_origin(): +def test_cors_vary_header_defaults_to_origin(test_client_factory): app = Starlette() app.add_middleware(CORSMiddleware, allow_origins=["https://example.org"]) @@ -404,14 +405,14 @@ def test_cors_vary_header_defaults_to_origin(): def homepage(request): return PlainTextResponse("Homepage", status_code=200) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/", headers=headers) assert response.status_code == 200 assert response.headers["vary"] == "Origin" -def test_cors_vary_header_is_not_set_for_non_credentialed_request(): +def test_cors_vary_header_is_not_set_for_non_credentialed_request(test_client_factory): app = Starlette() app.add_middleware(CORSMiddleware, allow_origins=["*"]) @@ -422,14 +423,14 @@ def homepage(request): "Homepage", status_code=200, headers={"Vary": "Accept-Encoding"} ) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/", headers={"Origin": "https://someplace.org"}) assert response.status_code == 200 assert response.headers["vary"] == "Accept-Encoding" -def test_cors_vary_header_is_properly_set_for_credentialed_request(): +def test_cors_vary_header_is_properly_set_for_credentialed_request(test_client_factory): app = Starlette() app.add_middleware(CORSMiddleware, allow_origins=["*"]) @@ -440,7 +441,7 @@ def homepage(request): "Homepage", status_code=200, headers={"Vary": "Accept-Encoding"} ) - client = TestClient(app) + client = test_client_factory(app) response = client.get( "/", headers={"Cookie": "foo=bar", "Origin": "https://someplace.org"} @@ -449,7 +450,9 @@ def homepage(request): assert response.headers["vary"] == "Accept-Encoding, Origin" -def test_cors_vary_header_is_properly_set_when_allow_origins_is_not_wildcard(): +def test_cors_vary_header_is_properly_set_when_allow_origins_is_not_wildcard( + test_client_factory, +): app = Starlette() app.add_middleware(CORSMiddleware, allow_origins=["https://example.org"]) @@ -460,14 +463,16 @@ def homepage(request): "Homepage", status_code=200, headers={"Vary": "Accept-Encoding"} ) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/", headers={"Origin": "https://example.org"}) assert response.status_code == 200 assert response.headers["vary"] == "Accept-Encoding, Origin" -def test_cors_allowed_origin_does_not_leak_between_credentialed_requests(): +def test_cors_allowed_origin_does_not_leak_between_credentialed_requests( + test_client_factory, +): app = Starlette() app.add_middleware( @@ -478,7 +483,7 @@ def test_cors_allowed_origin_does_not_leak_between_credentialed_requests(): def homepage(request): return PlainTextResponse("Homepage", status_code=200) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/", headers={"Origin": "https://someplace.org"}) assert response.headers["access-control-allow-origin"] == "*" assert "access-control-allow-credentials" not in response.headers diff --git a/tests/middleware/test_errors.py b/tests/middleware/test_errors.py index 28b2a7ba32..2c926a9b2d 100644 --- a/tests/middleware/test_errors.py +++ b/tests/middleware/test_errors.py @@ -2,10 +2,9 @@ from starlette.middleware.errors import ServerErrorMiddleware from starlette.responses import JSONResponse, Response -from starlette.testclient import TestClient -def test_handler(): +def test_handler(test_client_factory): async def app(scope, receive, send): raise RuntimeError("Something went wrong") @@ -13,49 +12,49 @@ def error_500(request, exc): return JSONResponse({"detail": "Server Error"}, status_code=500) app = ServerErrorMiddleware(app, handler=error_500) - client = TestClient(app, raise_server_exceptions=False) + client = test_client_factory(app, raise_server_exceptions=False) response = client.get("/") assert response.status_code == 500 assert response.json() == {"detail": "Server Error"} -def test_debug_text(): +def test_debug_text(test_client_factory): async def app(scope, receive, send): raise RuntimeError("Something went wrong") app = ServerErrorMiddleware(app, debug=True) - client = TestClient(app, raise_server_exceptions=False) + client = test_client_factory(app, raise_server_exceptions=False) response = client.get("/") assert response.status_code == 500 assert response.headers["content-type"].startswith("text/plain") assert "RuntimeError: Something went wrong" in response.text -def test_debug_html(): +def test_debug_html(test_client_factory): async def app(scope, receive, send): raise RuntimeError("Something went wrong") app = ServerErrorMiddleware(app, debug=True) - client = TestClient(app, raise_server_exceptions=False) + client = test_client_factory(app, raise_server_exceptions=False) response = client.get("/", headers={"Accept": "text/html, */*"}) assert response.status_code == 500 assert response.headers["content-type"].startswith("text/html") assert "RuntimeError" in response.text -def test_debug_after_response_sent(): +def test_debug_after_response_sent(test_client_factory): async def app(scope, receive, send): response = Response(b"", status_code=204) await response(scope, receive, send) raise RuntimeError("Something went wrong") app = ServerErrorMiddleware(app, debug=True) - client = TestClient(app) + client = test_client_factory(app) with pytest.raises(RuntimeError): client.get("/") -def test_debug_not_http(): +def test_debug_not_http(test_client_factory): """ DebugMiddleware should just pass through any non-http messages as-is. """ @@ -66,6 +65,6 @@ async def app(scope, receive, send): app = ServerErrorMiddleware(app) with pytest.raises(RuntimeError): - client = TestClient(app) + client = test_client_factory(app) with client.websocket_connect("/"): pass # pragma: nocover diff --git a/tests/middleware/test_gzip.py b/tests/middleware/test_gzip.py index cd989b8c1f..b917ea4dbb 100644 --- a/tests/middleware/test_gzip.py +++ b/tests/middleware/test_gzip.py @@ -1,10 +1,9 @@ from starlette.applications import Starlette from starlette.middleware.gzip import GZipMiddleware from starlette.responses import PlainTextResponse, StreamingResponse -from starlette.testclient import TestClient -def test_gzip_responses(): +def test_gzip_responses(test_client_factory): app = Starlette() app.add_middleware(GZipMiddleware) @@ -13,7 +12,7 @@ def test_gzip_responses(): def homepage(request): return PlainTextResponse("x" * 4000, status_code=200) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/", headers={"accept-encoding": "gzip"}) assert response.status_code == 200 assert response.text == "x" * 4000 @@ -21,7 +20,7 @@ def homepage(request): assert int(response.headers["Content-Length"]) < 4000 -def test_gzip_not_in_accept_encoding(): +def test_gzip_not_in_accept_encoding(test_client_factory): app = Starlette() app.add_middleware(GZipMiddleware) @@ -30,7 +29,7 @@ def test_gzip_not_in_accept_encoding(): def homepage(request): return PlainTextResponse("x" * 4000, status_code=200) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/", headers={"accept-encoding": "identity"}) assert response.status_code == 200 assert response.text == "x" * 4000 @@ -38,7 +37,7 @@ def homepage(request): assert int(response.headers["Content-Length"]) == 4000 -def test_gzip_ignored_for_small_responses(): +def test_gzip_ignored_for_small_responses(test_client_factory): app = Starlette() app.add_middleware(GZipMiddleware) @@ -47,7 +46,7 @@ def test_gzip_ignored_for_small_responses(): def homepage(request): return PlainTextResponse("OK", status_code=200) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/", headers={"accept-encoding": "gzip"}) assert response.status_code == 200 assert response.text == "OK" @@ -55,7 +54,7 @@ def homepage(request): assert int(response.headers["Content-Length"]) == 2 -def test_gzip_streaming_response(): +def test_gzip_streaming_response(test_client_factory): app = Starlette() app.add_middleware(GZipMiddleware) @@ -69,7 +68,7 @@ async def generator(bytes, count): streaming = generator(bytes=b"x" * 400, count=10) return StreamingResponse(streaming, status_code=200) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/", headers={"accept-encoding": "gzip"}) assert response.status_code == 200 assert response.text == "x" * 4000 diff --git a/tests/middleware/test_https_redirect.py b/tests/middleware/test_https_redirect.py index 757770b853..8db9506342 100644 --- a/tests/middleware/test_https_redirect.py +++ b/tests/middleware/test_https_redirect.py @@ -1,10 +1,9 @@ from starlette.applications import Starlette from starlette.middleware.httpsredirect import HTTPSRedirectMiddleware from starlette.responses import PlainTextResponse -from starlette.testclient import TestClient -def test_https_redirect_middleware(): +def test_https_redirect_middleware(test_client_factory): app = Starlette() app.add_middleware(HTTPSRedirectMiddleware) @@ -13,26 +12,26 @@ def test_https_redirect_middleware(): def homepage(request): return PlainTextResponse("OK", status_code=200) - client = TestClient(app, base_url="https://testserver") + client = test_client_factory(app, base_url="https://testserver") response = client.get("/") assert response.status_code == 200 - client = TestClient(app) + client = test_client_factory(app) response = client.get("/", allow_redirects=False) assert response.status_code == 307 assert response.headers["location"] == "https://testserver/" - client = TestClient(app, base_url="http://testserver:80") + client = test_client_factory(app, base_url="http://testserver:80") response = client.get("/", allow_redirects=False) assert response.status_code == 307 assert response.headers["location"] == "https://testserver/" - client = TestClient(app, base_url="http://testserver:443") + client = test_client_factory(app, base_url="http://testserver:443") response = client.get("/", allow_redirects=False) assert response.status_code == 307 assert response.headers["location"] == "https://testserver/" - client = TestClient(app, base_url="http://testserver:123") + client = test_client_factory(app, base_url="http://testserver:123") response = client.get("/", allow_redirects=False) assert response.status_code == 307 assert response.headers["location"] == "https://testserver:123/" diff --git a/tests/middleware/test_session.py b/tests/middleware/test_session.py index 68cf36df99..314f2be583 100644 --- a/tests/middleware/test_session.py +++ b/tests/middleware/test_session.py @@ -3,7 +3,6 @@ from starlette.applications import Starlette from starlette.middleware.sessions import SessionMiddleware from starlette.responses import JSONResponse -from starlette.testclient import TestClient def view_session(request): @@ -29,10 +28,10 @@ def create_app(): return app -def test_session(): +def test_session(test_client_factory): app = create_app() app.add_middleware(SessionMiddleware, secret_key="example") - client = TestClient(app) + client = test_client_factory(app) response = client.get("/view_session") assert response.json() == {"session": {}} @@ -56,10 +55,10 @@ def test_session(): assert response.json() == {"session": {}} -def test_session_expires(): +def test_session_expires(test_client_factory): app = create_app() app.add_middleware(SessionMiddleware, secret_key="example", max_age=-1) - client = TestClient(app) + client = test_client_factory(app) response = client.post("/update_session", json={"some": "data"}) assert response.json() == {"session": {"some": "data"}} @@ -72,11 +71,11 @@ def test_session_expires(): assert response.json() == {"session": {}} -def test_secure_session(): +def test_secure_session(test_client_factory): app = create_app() app.add_middleware(SessionMiddleware, secret_key="example", https_only=True) - secure_client = TestClient(app, base_url="https://testserver") - unsecure_client = TestClient(app, base_url="http://testserver") + secure_client = test_client_factory(app, base_url="https://testserver") + unsecure_client = test_client_factory(app, base_url="http://testserver") response = unsecure_client.get("/view_session") assert response.json() == {"session": {}} @@ -103,12 +102,12 @@ def test_secure_session(): assert response.json() == {"session": {}} -def test_session_cookie_subpath(): +def test_session_cookie_subpath(test_client_factory): app = create_app() second_app = create_app() second_app.add_middleware(SessionMiddleware, secret_key="example") app.mount("/second_app", second_app) - client = TestClient(app, base_url="http://testserver/second_app") + client = test_client_factory(app, base_url="http://testserver/second_app") response = client.post("second_app/update_session", json={"some": "data"}) cookie = response.headers["set-cookie"] cookie_path = re.search(r"; path=(\S+);", cookie).groups()[0] diff --git a/tests/middleware/test_trusted_host.py b/tests/middleware/test_trusted_host.py index 934f2477bf..de9c79e66a 100644 --- a/tests/middleware/test_trusted_host.py +++ b/tests/middleware/test_trusted_host.py @@ -1,10 +1,9 @@ from starlette.applications import Starlette from starlette.middleware.trustedhost import TrustedHostMiddleware from starlette.responses import PlainTextResponse -from starlette.testclient import TestClient -def test_trusted_host_middleware(): +def test_trusted_host_middleware(test_client_factory): app = Starlette() app.add_middleware( @@ -15,15 +14,15 @@ def test_trusted_host_middleware(): def homepage(request): return PlainTextResponse("OK", status_code=200) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/") assert response.status_code == 200 - client = TestClient(app, base_url="http://subdomain.testserver") + client = test_client_factory(app, base_url="http://subdomain.testserver") response = client.get("/") assert response.status_code == 200 - client = TestClient(app, base_url="http://invalidhost") + client = test_client_factory(app, base_url="http://invalidhost") response = client.get("/") assert response.status_code == 400 @@ -34,7 +33,7 @@ def test_default_allowed_hosts(): assert middleware.allowed_hosts == ["*"] -def test_www_redirect(): +def test_www_redirect(test_client_factory): app = Starlette() app.add_middleware(TrustedHostMiddleware, allowed_hosts=["www.example.com"]) @@ -43,7 +42,7 @@ def test_www_redirect(): def homepage(request): return PlainTextResponse("OK", status_code=200) - client = TestClient(app, base_url="https://example.com") + client = test_client_factory(app, base_url="https://example.com") response = client.get("/") assert response.status_code == 200 assert response.url == "https://www.example.com/" diff --git a/tests/middleware/test_wsgi.py b/tests/middleware/test_wsgi.py index 615805a94d..bcb4cd6ff2 100644 --- a/tests/middleware/test_wsgi.py +++ b/tests/middleware/test_wsgi.py @@ -3,7 +3,6 @@ import pytest from starlette.middleware.wsgi import WSGIMiddleware, build_environ -from starlette.testclient import TestClient def hello_world(environ, start_response): @@ -46,41 +45,41 @@ def return_exc_info(environ, start_response): return [output] -def test_wsgi_get(): +def test_wsgi_get(test_client_factory): app = WSGIMiddleware(hello_world) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/") assert response.status_code == 200 assert response.text == "Hello World!\n" -def test_wsgi_post(): +def test_wsgi_post(test_client_factory): app = WSGIMiddleware(echo_body) - client = TestClient(app) + client = test_client_factory(app) response = client.post("/", json={"example": 123}) assert response.status_code == 200 assert response.text == '{"example": 123}' -def test_wsgi_exception(): +def test_wsgi_exception(test_client_factory): # Note that we're testing the WSGI app directly here. # The HTTP protocol implementations would catch this error and return 500. app = WSGIMiddleware(raise_exception) - client = TestClient(app) + client = test_client_factory(app) with pytest.raises(RuntimeError): client.get("/") -def test_wsgi_exc_info(): +def test_wsgi_exc_info(test_client_factory): # Note that we're testing the WSGI app directly here. # The HTTP protocol implementations would catch this error and return 500. app = WSGIMiddleware(return_exc_info) - client = TestClient(app) + client = test_client_factory(app) with pytest.raises(RuntimeError): response = client.get("/") app = WSGIMiddleware(return_exc_info) - client = TestClient(app, raise_server_exceptions=False) + client = test_client_factory(app, raise_server_exceptions=False) response = client.get("/") assert response.status_code == 500 assert response.text == "Internal Server Error" diff --git a/tests/test_applications.py b/tests/test_applications.py index ad8504cbd5..6cb490696d 100644 --- a/tests/test_applications.py +++ b/tests/test_applications.py @@ -1,5 +1,7 @@ import os +import pytest + from starlette.applications import Starlette from starlette.endpoints import HTTPEndpoint from starlette.exceptions import HTTPException @@ -7,7 +9,6 @@ from starlette.responses import JSONResponse, PlainTextResponse from starlette.routing import Host, Mount, Route, Router, WebSocketRoute from starlette.staticfiles import StaticFiles -from starlette.testclient import TestClient app = Starlette() @@ -86,14 +87,17 @@ async def websocket_endpoint(session): await session.close() -client = TestClient(app) +@pytest.fixture +def client(test_client_factory): + with test_client_factory(app) as client: + yield client def test_url_path_for(): assert app.url_path_for("func_homepage") == "/func" -def test_func_route(): +def test_func_route(client): response = client.get("/func") assert response.status_code == 200 assert response.text == "Hello, world!" @@ -103,51 +107,51 @@ def test_func_route(): assert response.text == "" -def test_async_route(): +def test_async_route(client): response = client.get("/async") assert response.status_code == 200 assert response.text == "Hello, world!" -def test_class_route(): +def test_class_route(client): response = client.get("/class") assert response.status_code == 200 assert response.text == "Hello, world!" -def test_mounted_route(): +def test_mounted_route(client): response = client.get("/users/") assert response.status_code == 200 assert response.text == "Hello, everyone!" -def test_mounted_route_path_params(): +def test_mounted_route_path_params(client): response = client.get("/users/tomchristie") assert response.status_code == 200 assert response.text == "Hello, tomchristie!" -def test_subdomain_route(): - client = TestClient(app, base_url="https://foo.example.org/") +def test_subdomain_route(test_client_factory): + client = test_client_factory(app, base_url="https://foo.example.org/") response = client.get("/") assert response.status_code == 200 assert response.text == "Subdomain: foo" -def test_websocket_route(): +def test_websocket_route(client): with client.websocket_connect("/ws") as session: text = session.receive_text() assert text == "Hello, world!" -def test_400(): +def test_400(client): response = client.get("/404") assert response.status_code == 404 assert response.json() == {"detail": "Not Found"} -def test_405(): +def test_405(client): response = client.post("/func") assert response.status_code == 405 assert response.json() == {"detail": "Custom message"} @@ -157,15 +161,15 @@ def test_405(): assert response.json() == {"detail": "Custom message"} -def test_500(): - client = TestClient(app, raise_server_exceptions=False) +def test_500(test_client_factory): + client = test_client_factory(app, raise_server_exceptions=False) response = client.get("/500") assert response.status_code == 500 assert response.json() == {"detail": "Server Error"} -def test_middleware(): - client = TestClient(app, base_url="http://incorrecthost") +def test_middleware(test_client_factory): + client = test_client_factory(app, base_url="http://incorrecthost") response = client.get("/func") assert response.status_code == 400 assert response.text == "Invalid host header" @@ -194,7 +198,7 @@ def test_routes(): ] -def test_app_mount(tmpdir): +def test_app_mount(tmpdir, test_client_factory): path = os.path.join(tmpdir, "example.txt") with open(path, "w") as file: file.write("") @@ -202,7 +206,7 @@ def test_app_mount(tmpdir): app = Starlette() app.mount("/static", StaticFiles(directory=tmpdir)) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/static/example.txt") assert response.status_code == 200 @@ -213,7 +217,7 @@ def test_app_mount(tmpdir): assert response.text == "Method Not Allowed" -def test_app_debug(): +def test_app_debug(test_client_factory): app = Starlette() app.debug = True @@ -221,27 +225,27 @@ def test_app_debug(): async def homepage(request): raise RuntimeError() - client = TestClient(app, raise_server_exceptions=False) + client = test_client_factory(app, raise_server_exceptions=False) response = client.get("/") assert response.status_code == 500 assert "RuntimeError" in response.text assert app.debug -def test_app_add_route(): +def test_app_add_route(test_client_factory): app = Starlette() async def homepage(request): return PlainTextResponse("Hello, World!") app.add_route("/", homepage) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/") assert response.status_code == 200 assert response.text == "Hello, World!" -def test_app_add_websocket_route(): +def test_app_add_websocket_route(test_client_factory): app = Starlette() async def websocket_endpoint(session): @@ -250,14 +254,14 @@ async def websocket_endpoint(session): await session.close() app.add_websocket_route("/ws", websocket_endpoint) - client = TestClient(app) + client = test_client_factory(app) with client.websocket_connect("/ws") as session: text = session.receive_text() assert text == "Hello, world!" -def test_app_add_event_handler(): +def test_app_add_event_handler(test_client_factory): startup_complete = False cleanup_complete = False app = Starlette() @@ -275,14 +279,14 @@ def run_cleanup(): assert not startup_complete assert not cleanup_complete - with TestClient(app): + with test_client_factory(app): assert startup_complete assert not cleanup_complete assert startup_complete assert cleanup_complete -def test_app_async_lifespan(): +def test_app_async_lifespan(test_client_factory): startup_complete = False cleanup_complete = False @@ -296,14 +300,14 @@ async def lifespan(app): assert not startup_complete assert not cleanup_complete - with TestClient(app): + with test_client_factory(app): assert startup_complete assert not cleanup_complete assert startup_complete assert cleanup_complete -def test_app_sync_lifespan(): +def test_app_sync_lifespan(test_client_factory): startup_complete = False cleanup_complete = False @@ -317,7 +321,7 @@ def lifespan(app): assert not startup_complete assert not cleanup_complete - with TestClient(app): + with test_client_factory(app): assert startup_complete assert not cleanup_complete assert startup_complete diff --git a/tests/test_authentication.py b/tests/test_authentication.py index 8ee87932a1..43c7ab96dc 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -15,7 +15,6 @@ from starlette.middleware.authentication import AuthenticationMiddleware from starlette.requests import Request from starlette.responses import JSONResponse -from starlette.testclient import TestClient from starlette.websockets import WebSocketDisconnect @@ -195,8 +194,8 @@ def foo(): pass # pragma: nocover -def test_user_interface(): - with TestClient(app) as client: +def test_user_interface(test_client_factory): + with test_client_factory(app) as client: response = client.get("/") assert response.status_code == 200 assert response.json() == {"authenticated": False, "user": ""} @@ -206,8 +205,8 @@ def test_user_interface(): assert response.json() == {"authenticated": True, "user": "tomchristie"} -def test_authentication_required(): - with TestClient(app) as client: +def test_authentication_required(test_client_factory): + with test_client_factory(app) as client: response = client.get("/dashboard") assert response.status_code == 403 @@ -258,8 +257,8 @@ def test_authentication_required(): assert response.text == "Invalid basic auth credentials" -def test_websocket_authentication_required(): - with TestClient(app) as client: +def test_websocket_authentication_required(test_client_factory): + with test_client_factory(app) as client: with pytest.raises(WebSocketDisconnect): with client.websocket_connect("/ws"): pass # pragma: nocover @@ -297,8 +296,8 @@ def test_websocket_authentication_required(): } -def test_authentication_redirect(): - with TestClient(app) as client: +def test_authentication_redirect(test_client_factory): + with test_client_factory(app) as client: response = client.get("/admin") assert response.status_code == 200 assert response.url == "http://testserver/" @@ -337,8 +336,8 @@ def control_panel(request): ) -def test_custom_on_error(): - with TestClient(other_app) as client: +def test_custom_on_error(test_client_factory): + with test_client_factory(other_app) as client: response = client.get("/control-panel", auth=("tomchristie", "example")) assert response.status_code == 200 assert response.json() == {"authenticated": True, "user": "tomchristie"} diff --git a/tests/test_background.py b/tests/test_background.py index d9d7ddd872..e299ec3628 100644 --- a/tests/test_background.py +++ b/tests/test_background.py @@ -1,9 +1,8 @@ from starlette.background import BackgroundTask, BackgroundTasks from starlette.responses import Response -from starlette.testclient import TestClient -def test_async_task(): +def test_async_task(test_client_factory): TASK_COMPLETE = False async def async_task(): @@ -16,13 +15,13 @@ async def app(scope, receive, send): response = Response("task initiated", media_type="text/plain", background=task) await response(scope, receive, send) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/") assert response.text == "task initiated" assert TASK_COMPLETE -def test_sync_task(): +def test_sync_task(test_client_factory): TASK_COMPLETE = False def sync_task(): @@ -35,13 +34,13 @@ async def app(scope, receive, send): response = Response("task initiated", media_type="text/plain", background=task) await response(scope, receive, send) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/") assert response.text == "task initiated" assert TASK_COMPLETE -def test_multiple_tasks(): +def test_multiple_tasks(test_client_factory): TASK_COUNTER = 0 def increment(amount): @@ -58,7 +57,7 @@ async def app(scope, receive, send): ) await response(scope, receive, send) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/") assert response.text == "tasks initiated" assert TASK_COUNTER == 1 + 2 + 3 diff --git a/tests/test_database.py b/tests/test_database.py index f7280c2c71..1230fc8f67 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -4,7 +4,6 @@ from starlette.applications import Starlette from starlette.responses import JSONResponse -from starlette.testclient import TestClient DATABASE_URL = "sqlite:///test.db" @@ -90,8 +89,8 @@ async def read_note_text(request): return JSONResponse(result[0]) -def test_database(): - with TestClient(app) as client: +def test_database(test_client_factory): + with test_client_factory(app) as client: response = client.post( "/notes", json={"text": "buy the milk", "completed": True} ) @@ -125,8 +124,8 @@ def test_database(): assert response.json() == "buy the milk" -def test_database_execute_many(): - with TestClient(app) as client: +def test_database_execute_many(test_client_factory): + with test_client_factory(app) as client: response = client.get("/notes") data = [ @@ -144,11 +143,11 @@ def test_database_execute_many(): ] -def test_database_isolated_during_test_cases(): +def test_database_isolated_during_test_cases(test_client_factory): """ Using `TestClient` as a context manager """ - with TestClient(app) as client: + with test_client_factory(app) as client: response = client.post( "/notes", json={"text": "just one note", "completed": True} ) @@ -158,7 +157,7 @@ def test_database_isolated_during_test_cases(): assert response.status_code == 200 assert response.json() == [{"text": "just one note", "completed": True}] - with TestClient(app) as client: + with test_client_factory(app) as client: response = client.post( "/notes", json={"text": "just one note", "completed": True} ) diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index e491c085f3..e57d47486a 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -3,7 +3,6 @@ from starlette.endpoints import HTTPEndpoint, WebSocketEndpoint from starlette.responses import PlainTextResponse from starlette.routing import Route, Router -from starlette.testclient import TestClient class Homepage(HTTPEndpoint): @@ -18,46 +17,50 @@ async def get(self, request): routes=[Route("/", endpoint=Homepage), Route("/{username}", endpoint=Homepage)] ) -client = TestClient(app) +@pytest.fixture +def client(test_client_factory): + with test_client_factory(app) as client: + yield client -def test_http_endpoint_route(): + +def test_http_endpoint_route(client): response = client.get("/") assert response.status_code == 200 assert response.text == "Hello, world!" -def test_http_endpoint_route_path_params(): +def test_http_endpoint_route_path_params(client): response = client.get("/tomchristie") assert response.status_code == 200 assert response.text == "Hello, tomchristie!" -def test_http_endpoint_route_method(): +def test_http_endpoint_route_method(client): response = client.post("/") assert response.status_code == 405 assert response.text == "Method Not Allowed" -def test_websocket_endpoint_on_connect(): +def test_websocket_endpoint_on_connect(test_client_factory): class WebSocketApp(WebSocketEndpoint): async def on_connect(self, websocket): assert websocket["subprotocols"] == ["soap", "wamp"] await websocket.accept(subprotocol="wamp") - client = TestClient(WebSocketApp) + client = test_client_factory(WebSocketApp) with client.websocket_connect("/ws", subprotocols=["soap", "wamp"]) as websocket: assert websocket.accepted_subprotocol == "wamp" -def test_websocket_endpoint_on_receive_bytes(): +def test_websocket_endpoint_on_receive_bytes(test_client_factory): class WebSocketApp(WebSocketEndpoint): encoding = "bytes" async def on_receive(self, websocket, data): await websocket.send_bytes(b"Message bytes was: " + data) - client = TestClient(WebSocketApp) + client = test_client_factory(WebSocketApp) with client.websocket_connect("/ws") as websocket: websocket.send_bytes(b"Hello, world!") _bytes = websocket.receive_bytes() @@ -68,14 +71,14 @@ async def on_receive(self, websocket, data): websocket.send_text("Hello world") -def test_websocket_endpoint_on_receive_json(): +def test_websocket_endpoint_on_receive_json(test_client_factory): class WebSocketApp(WebSocketEndpoint): encoding = "json" async def on_receive(self, websocket, data): await websocket.send_json({"message": data}) - client = TestClient(WebSocketApp) + client = test_client_factory(WebSocketApp) with client.websocket_connect("/ws") as websocket: websocket.send_json({"hello": "world"}) data = websocket.receive_json() @@ -86,28 +89,28 @@ async def on_receive(self, websocket, data): websocket.send_text("Hello world") -def test_websocket_endpoint_on_receive_json_binary(): +def test_websocket_endpoint_on_receive_json_binary(test_client_factory): class WebSocketApp(WebSocketEndpoint): encoding = "json" async def on_receive(self, websocket, data): await websocket.send_json({"message": data}, mode="binary") - client = TestClient(WebSocketApp) + client = test_client_factory(WebSocketApp) with client.websocket_connect("/ws") as websocket: websocket.send_json({"hello": "world"}, mode="binary") data = websocket.receive_json(mode="binary") assert data == {"message": {"hello": "world"}} -def test_websocket_endpoint_on_receive_text(): +def test_websocket_endpoint_on_receive_text(test_client_factory): class WebSocketApp(WebSocketEndpoint): encoding = "text" async def on_receive(self, websocket, data): await websocket.send_text(f"Message text was: {data}") - client = TestClient(WebSocketApp) + client = test_client_factory(WebSocketApp) with client.websocket_connect("/ws") as websocket: websocket.send_text("Hello, world!") _text = websocket.receive_text() @@ -118,26 +121,26 @@ async def on_receive(self, websocket, data): websocket.send_bytes(b"Hello world") -def test_websocket_endpoint_on_default(): +def test_websocket_endpoint_on_default(test_client_factory): class WebSocketApp(WebSocketEndpoint): encoding = None async def on_receive(self, websocket, data): await websocket.send_text(f"Message text was: {data}") - client = TestClient(WebSocketApp) + client = test_client_factory(WebSocketApp) with client.websocket_connect("/ws") as websocket: websocket.send_text("Hello, world!") _text = websocket.receive_text() assert _text == "Message text was: Hello, world!" -def test_websocket_endpoint_on_disconnect(): +def test_websocket_endpoint_on_disconnect(test_client_factory): class WebSocketApp(WebSocketEndpoint): async def on_disconnect(self, websocket, close_code): assert close_code == 1001 await websocket.close(code=close_code) - client = TestClient(WebSocketApp) + client = test_client_factory(WebSocketApp) with client.websocket_connect("/ws") as websocket: websocket.close(code=1001) diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index bab6961b52..5fba9981b1 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -3,7 +3,6 @@ from starlette.exceptions import ExceptionMiddleware, HTTPException from starlette.responses import PlainTextResponse from starlette.routing import Route, Router, WebSocketRoute -from starlette.testclient import TestClient def raise_runtime_error(request): @@ -37,28 +36,33 @@ async def __call__(self, scope, receive, send): app = ExceptionMiddleware(router) -client = TestClient(app) -def test_not_acceptable(): +@pytest.fixture +def client(test_client_factory): + with test_client_factory(app) as client: + yield client + + +def test_not_acceptable(client): response = client.get("/not_acceptable") assert response.status_code == 406 assert response.text == "Not Acceptable" -def test_not_modified(): +def test_not_modified(client): response = client.get("/not_modified") assert response.status_code == 304 assert response.text == "" -def test_websockets_should_raise(): +def test_websockets_should_raise(client): with pytest.raises(RuntimeError): with client.websocket_connect("/runtime_error"): pass # pragma: nocover -def test_handled_exc_after_response(): +def test_handled_exc_after_response(test_client_factory, client): # A 406 HttpException is raised *after* the response has already been sent. # The exception middleware should raise a RuntimeError. with pytest.raises(RuntimeError): @@ -66,17 +70,17 @@ def test_handled_exc_after_response(): # If `raise_server_exceptions=False` then the test client will still allow # us to see the response as it will have been seen by the client. - allow_200_client = TestClient(app, raise_server_exceptions=False) + allow_200_client = test_client_factory(app, raise_server_exceptions=False) response = allow_200_client.get("/handled_exc_after_response") assert response.status_code == 200 assert response.text == "OK" -def test_force_500_response(): +def test_force_500_response(test_client_factory): def app(scope): raise RuntimeError() - force_500_client = TestClient(app, raise_server_exceptions=False) + force_500_client = test_client_factory(app, raise_server_exceptions=False) response = force_500_client.get("/") assert response.status_code == 500 assert response.text == "" diff --git a/tests/test_formparsers.py b/tests/test_formparsers.py index 73a720fd13..8a1174e1d2 100644 --- a/tests/test_formparsers.py +++ b/tests/test_formparsers.py @@ -3,7 +3,6 @@ from starlette.formparsers import UploadFile, _user_safe_decode from starlette.requests import Request from starlette.responses import JSONResponse -from starlette.testclient import TestClient class ForceMultipartDict(dict): @@ -70,18 +69,18 @@ async def app_read_body(scope, receive, send): await response(scope, receive, send) -def test_multipart_request_data(tmpdir): - client = TestClient(app) +def test_multipart_request_data(tmpdir, test_client_factory): + client = test_client_factory(app) response = client.post("/", data={"some": "data"}, files=FORCE_MULTIPART) assert response.json() == {"some": "data"} -def test_multipart_request_files(tmpdir): +def test_multipart_request_files(tmpdir, test_client_factory): path = os.path.join(tmpdir, "test.txt") with open(path, "wb") as file: file.write(b"") - client = TestClient(app) + client = test_client_factory(app) with open(path, "rb") as f: response = client.post("/", files={"test": f}) assert response.json() == { @@ -93,12 +92,12 @@ def test_multipart_request_files(tmpdir): } -def test_multipart_request_files_with_content_type(tmpdir): +def test_multipart_request_files_with_content_type(tmpdir, test_client_factory): path = os.path.join(tmpdir, "test.txt") with open(path, "wb") as file: file.write(b"") - client = TestClient(app) + client = test_client_factory(app) with open(path, "rb") as f: response = client.post("/", files={"test": ("test.txt", f, "text/plain")}) assert response.json() == { @@ -110,7 +109,7 @@ def test_multipart_request_files_with_content_type(tmpdir): } -def test_multipart_request_multiple_files(tmpdir): +def test_multipart_request_multiple_files(tmpdir, test_client_factory): path1 = os.path.join(tmpdir, "test1.txt") with open(path1, "wb") as file: file.write(b"") @@ -119,7 +118,7 @@ def test_multipart_request_multiple_files(tmpdir): with open(path2, "wb") as file: file.write(b"") - client = TestClient(app) + client = test_client_factory(app) with open(path1, "rb") as f1, open(path2, "rb") as f2: response = client.post( "/", files={"test1": f1, "test2": ("test2.txt", f2, "text/plain")} @@ -138,7 +137,7 @@ def test_multipart_request_multiple_files(tmpdir): } -def test_multi_items(tmpdir): +def test_multi_items(tmpdir, test_client_factory): path1 = os.path.join(tmpdir, "test1.txt") with open(path1, "wb") as file: file.write(b"") @@ -147,7 +146,7 @@ def test_multi_items(tmpdir): with open(path2, "wb") as file: file.write(b"") - client = TestClient(multi_items_app) + client = test_client_factory(multi_items_app) with open(path1, "rb") as f1, open(path2, "rb") as f2: response = client.post( "/", @@ -171,8 +170,8 @@ def test_multi_items(tmpdir): } -def test_multipart_request_mixed_files_and_data(tmpdir): - client = TestClient(app) +def test_multipart_request_mixed_files_and_data(tmpdir, test_client_factory): + client = test_client_factory(app) response = client.post( "/", data=( @@ -208,8 +207,8 @@ def test_multipart_request_mixed_files_and_data(tmpdir): } -def test_multipart_request_with_charset_for_filename(tmpdir): - client = TestClient(app) +def test_multipart_request_with_charset_for_filename(tmpdir, test_client_factory): + client = test_client_factory(app) response = client.post( "/", data=( @@ -236,8 +235,8 @@ def test_multipart_request_with_charset_for_filename(tmpdir): } -def test_multipart_request_without_charset_for_filename(tmpdir): - client = TestClient(app) +def test_multipart_request_without_charset_for_filename(tmpdir, test_client_factory): + client = test_client_factory(app) response = client.post( "/", data=( @@ -263,8 +262,8 @@ def test_multipart_request_without_charset_for_filename(tmpdir): } -def test_multipart_request_with_encoded_value(tmpdir): - client = TestClient(app) +def test_multipart_request_with_encoded_value(tmpdir, test_client_factory): + client = test_client_factory(app) response = client.post( "/", data=( @@ -284,38 +283,38 @@ def test_multipart_request_with_encoded_value(tmpdir): assert response.json() == {"value": "Transférer"} -def test_urlencoded_request_data(tmpdir): - client = TestClient(app) +def test_urlencoded_request_data(tmpdir, test_client_factory): + client = test_client_factory(app) response = client.post("/", data={"some": "data"}) assert response.json() == {"some": "data"} -def test_no_request_data(tmpdir): - client = TestClient(app) +def test_no_request_data(tmpdir, test_client_factory): + client = test_client_factory(app) response = client.post("/") assert response.json() == {} -def test_urlencoded_percent_encoding(tmpdir): - client = TestClient(app) +def test_urlencoded_percent_encoding(tmpdir, test_client_factory): + client = test_client_factory(app) response = client.post("/", data={"some": "da ta"}) assert response.json() == {"some": "da ta"} -def test_urlencoded_percent_encoding_keys(tmpdir): - client = TestClient(app) +def test_urlencoded_percent_encoding_keys(tmpdir, test_client_factory): + client = test_client_factory(app) response = client.post("/", data={"so me": "data"}) assert response.json() == {"so me": "data"} -def test_urlencoded_multi_field_app_reads_body(tmpdir): - client = TestClient(app_read_body) +def test_urlencoded_multi_field_app_reads_body(tmpdir, test_client_factory): + client = test_client_factory(app_read_body) response = client.post("/", data={"some": "data", "second": "key pair"}) assert response.json() == {"some": "data", "second": "key pair"} -def test_multipart_multi_field_app_reads_body(tmpdir): - client = TestClient(app_read_body) +def test_multipart_multi_field_app_reads_body(tmpdir, test_client_factory): + client = test_client_factory(app_read_body) response = client.post( "/", data={"some": "data", "second": "key pair"}, files=FORCE_MULTIPART ) diff --git a/tests/test_graphql.py b/tests/test_graphql.py index b945a5cfe1..8492439f83 100644 --- a/tests/test_graphql.py +++ b/tests/test_graphql.py @@ -1,10 +1,10 @@ import graphene +import pytest from graphql.execution.executors.asyncio import AsyncioExecutor from starlette.applications import Starlette from starlette.datastructures import Headers from starlette.graphql import GraphQLApp -from starlette.testclient import TestClient class FakeAuthMiddleware: @@ -33,29 +33,33 @@ def resolve_whoami(self, info): schema = graphene.Schema(query=Query) -app = GraphQLApp(schema=schema, graphiql=True) -client = TestClient(app) -def test_graphql_get(): +@pytest.fixture +def client(test_client_factory): + app = GraphQLApp(schema=schema, graphiql=True) + return test_client_factory(app) + + +def test_graphql_get(client): response = client.get("/?query={ hello }") assert response.status_code == 200 assert response.json() == {"data": {"hello": "Hello stranger"}} -def test_graphql_post(): +def test_graphql_post(client): response = client.post("/?query={ hello }") assert response.status_code == 200 assert response.json() == {"data": {"hello": "Hello stranger"}} -def test_graphql_post_json(): +def test_graphql_post_json(client): response = client.post("/", json={"query": "{ hello }"}) assert response.status_code == 200 assert response.json() == {"data": {"hello": "Hello stranger"}} -def test_graphql_post_graphql(): +def test_graphql_post_graphql(client): response = client.post( "/", data="{ hello }", headers={"content-type": "application/graphql"} ) @@ -63,25 +67,25 @@ def test_graphql_post_graphql(): assert response.json() == {"data": {"hello": "Hello stranger"}} -def test_graphql_post_invalid_media_type(): +def test_graphql_post_invalid_media_type(client): response = client.post("/", data="{ hello }", headers={"content-type": "dummy"}) assert response.status_code == 415 assert response.text == "Unsupported Media Type" -def test_graphql_put(): +def test_graphql_put(client): response = client.put("/", json={"query": "{ hello }"}) assert response.status_code == 405 assert response.text == "Method Not Allowed" -def test_graphql_no_query(): +def test_graphql_no_query(client): response = client.get("/") assert response.status_code == 400 assert response.text == "No GraphQL query found in the request" -def test_graphql_invalid_field(): +def test_graphql_invalid_field(client): response = client.post("/", json={"query": "{ dummy }"}) assert response.status_code == 400 assert response.json() == { @@ -95,34 +99,34 @@ def test_graphql_invalid_field(): } -def test_graphiql_get(): +def test_graphiql_get(client): response = client.get("/", headers={"accept": "text/html"}) assert response.status_code == 200 assert "" in response.text -def test_graphiql_not_found(): +def test_graphiql_not_found(test_client_factory): app = GraphQLApp(schema=schema, graphiql=False) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/", headers={"accept": "text/html"}) assert response.status_code == 404 assert response.text == "Not Found" -def test_add_graphql_route(): +def test_add_graphql_route(test_client_factory): app = Starlette() app.add_route("/", GraphQLApp(schema=schema)) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/?query={ hello }") assert response.status_code == 200 assert response.json() == {"data": {"hello": "Hello stranger"}} -def test_graphql_context(): +def test_graphql_context(test_client_factory): app = Starlette() app.add_middleware(FakeAuthMiddleware) app.add_route("/", GraphQLApp(schema=schema)) - client = TestClient(app) + client = test_client_factory(app) response = client.post( "/", json={"query": "{ whoami }"}, headers={"Authorization": "Bearer 123"} ) @@ -141,8 +145,8 @@ async def resolve_hello(self, info, name): async_app = GraphQLApp(schema=async_schema, executor_class=AsyncioExecutor) -def test_graphql_async(no_trio_support): - client = TestClient(async_app) +def test_graphql_async(no_trio_support, test_client_factory): + client = test_client_factory(async_app) response = client.get("/?query={ hello }") assert response.status_code == 200 assert response.json() == {"data": {"hello": "Hello stranger"}} diff --git a/tests/test_requests.py b/tests/test_requests.py index fee059ab2e..d7c69fbeb2 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -3,17 +3,16 @@ from starlette.requests import ClientDisconnect, Request, State from starlette.responses import JSONResponse, Response -from starlette.testclient import TestClient -def test_request_url(): +def test_request_url(test_client_factory): async def app(scope, receive, send): request = Request(scope, receive) data = {"method": request.method, "url": str(request.url)} response = JSONResponse(data) await response(scope, receive, send) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/123?a=abc") assert response.json() == {"method": "GET", "url": "http://testserver/123?a=abc"} @@ -21,26 +20,26 @@ async def app(scope, receive, send): assert response.json() == {"method": "GET", "url": "https://example.org:123/"} -def test_request_query_params(): +def test_request_query_params(test_client_factory): async def app(scope, receive, send): request = Request(scope, receive) params = dict(request.query_params) response = JSONResponse({"params": params}) await response(scope, receive, send) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/?a=123&b=456") assert response.json() == {"params": {"a": "123", "b": "456"}} -def test_request_headers(): +def test_request_headers(test_client_factory): async def app(scope, receive, send): request = Request(scope, receive) headers = dict(request.headers) response = JSONResponse({"headers": headers}) await response(scope, receive, send) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/", headers={"host": "example.org"}) assert response.json() == { "headers": { @@ -53,7 +52,7 @@ async def app(scope, receive, send): } -def test_request_client(): +def test_request_client(test_client_factory): async def app(scope, receive, send): request = Request(scope, receive) response = JSONResponse( @@ -61,19 +60,19 @@ async def app(scope, receive, send): ) await response(scope, receive, send) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/") assert response.json() == {"host": "testclient", "port": 50000} -def test_request_body(): +def test_request_body(test_client_factory): async def app(scope, receive, send): request = Request(scope, receive) body = await request.body() response = JSONResponse({"body": body.decode()}) await response(scope, receive, send) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/") assert response.json() == {"body": ""} @@ -85,7 +84,7 @@ async def app(scope, receive, send): assert response.json() == {"body": "abc"} -def test_request_stream(): +def test_request_stream(test_client_factory): async def app(scope, receive, send): request = Request(scope, receive) body = b"" @@ -94,7 +93,7 @@ async def app(scope, receive, send): response = JSONResponse({"body": body.decode()}) await response(scope, receive, send) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/") assert response.json() == {"body": ""} @@ -106,20 +105,20 @@ async def app(scope, receive, send): assert response.json() == {"body": "abc"} -def test_request_form_urlencoded(): +def test_request_form_urlencoded(test_client_factory): async def app(scope, receive, send): request = Request(scope, receive) form = await request.form() response = JSONResponse({"form": dict(form)}) await response(scope, receive, send) - client = TestClient(app) + client = test_client_factory(app) response = client.post("/", data={"abc": "123 @"}) assert response.json() == {"form": {"abc": "123 @"}} -def test_request_body_then_stream(): +def test_request_body_then_stream(test_client_factory): async def app(scope, receive, send): request = Request(scope, receive) body = await request.body() @@ -129,13 +128,13 @@ async def app(scope, receive, send): response = JSONResponse({"body": body.decode(), "stream": chunks.decode()}) await response(scope, receive, send) - client = TestClient(app) + client = test_client_factory(app) response = client.post("/", data="abc") assert response.json() == {"body": "abc", "stream": "abc"} -def test_request_stream_then_body(): +def test_request_stream_then_body(test_client_factory): async def app(scope, receive, send): request = Request(scope, receive) chunks = b"" @@ -148,20 +147,20 @@ async def app(scope, receive, send): response = JSONResponse({"body": body.decode(), "stream": chunks.decode()}) await response(scope, receive, send) - client = TestClient(app) + client = test_client_factory(app) response = client.post("/", data="abc") assert response.json() == {"body": "", "stream": "abc"} -def test_request_json(): +def test_request_json(test_client_factory): async def app(scope, receive, send): request = Request(scope, receive) data = await request.json() response = JSONResponse({"json": data}) await response(scope, receive, send) - client = TestClient(app) + client = test_client_factory(app) response = client.post("/", json={"a": "123"}) assert response.json() == {"json": {"a": "123"}} @@ -177,7 +176,7 @@ def test_request_scope_interface(): assert len(request) == 3 -def test_request_without_setting_receive(): +def test_request_without_setting_receive(test_client_factory): """ If Request is instantiated without the receive channel, then .body() is not available. @@ -192,12 +191,12 @@ async def app(scope, receive, send): response = JSONResponse({"json": data}) await response(scope, receive, send) - client = TestClient(app) + client = test_client_factory(app) response = client.post("/", json={"a": "123"}) assert response.json() == {"json": "Receive channel not available"} -def test_request_disconnect(): +def test_request_disconnect(anyio_backend_name, anyio_backend_options): """ If a client disconnect occurs while reading request body then ClientDisconnect should be raised. @@ -212,10 +211,17 @@ async def receiver(): scope = {"type": "http", "method": "POST", "path": "/"} with pytest.raises(ClientDisconnect): - anyio.run(app, scope, receiver, None) + anyio.run( + app, + scope, + receiver, + None, + backend=anyio_backend_name, + backend_options=anyio_backend_options, + ) -def test_request_is_disconnected(): +def test_request_is_disconnected(test_client_factory): """ If a client disconnect occurs while reading request body then ClientDisconnect should be raised. @@ -232,7 +238,7 @@ async def app(scope, receive, send): await response(scope, receive, send) disconnected_after_response = await request.is_disconnected() - client = TestClient(app) + client = test_client_factory(app) response = client.get("/") assert response.json() == {"disconnected": False} assert disconnected_after_response @@ -252,19 +258,19 @@ def test_request_state_object(): s.new -def test_request_state(): +def test_request_state(test_client_factory): async def app(scope, receive, send): request = Request(scope, receive) request.state.example = 123 response = JSONResponse({"state.example": request.state.example}) await response(scope, receive, send) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/123?a=abc") assert response.json() == {"state.example": 123} -def test_request_cookies(): +def test_request_cookies(test_client_factory): async def app(scope, receive, send): request = Request(scope, receive) mycookie = request.cookies.get("mycookie") @@ -276,14 +282,14 @@ async def app(scope, receive, send): await response(scope, receive, send) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/") assert response.text == "Hello, world!" response = client.get("/") assert response.text == "Hello, cookies!" -def test_cookie_lenient_parsing(): +def test_cookie_lenient_parsing(test_client_factory): """ The following test is based on a cookie set by Okta, a well-known authorization service. It turns out that it's common practice to set cookies that would be @@ -310,7 +316,7 @@ async def app(scope, receive, send): response = JSONResponse({"cookies": request.cookies}) await response(scope, receive, send) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/", headers={"cookie": tough_cookie}) result = response.json() assert len(result["cookies"]) == 4 @@ -339,13 +345,13 @@ async def app(scope, receive, send): ("a=b; h=i; a=c", {"a": "c", "h": "i"}), ], ) -def test_cookies_edge_cases(set_cookie, expected): +def test_cookies_edge_cases(set_cookie, expected, test_client_factory): async def app(scope, receive, send): request = Request(scope, receive) response = JSONResponse({"cookies": request.cookies}) await response(scope, receive, send) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/", headers={"cookie": set_cookie}) result = response.json() assert result["cookies"] == expected @@ -374,7 +380,7 @@ async def app(scope, receive, send): # (" = b ; ; = ; c = ; ", {"": "b", "c": ""}), ], ) -def test_cookies_invalid(set_cookie, expected): +def test_cookies_invalid(set_cookie, expected, test_client_factory): """ Cookie strings that are against the RFC6265 spec but which browsers will send if set via document.cookie. @@ -385,20 +391,20 @@ async def app(scope, receive, send): response = JSONResponse({"cookies": request.cookies}) await response(scope, receive, send) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/", headers={"cookie": set_cookie}) result = response.json() assert result["cookies"] == expected -def test_chunked_encoding(): +def test_chunked_encoding(test_client_factory): async def app(scope, receive, send): request = Request(scope, receive) body = await request.body() response = JSONResponse({"body": body.decode()}) await response(scope, receive, send) - client = TestClient(app) + client = test_client_factory(app) def post_body(): yield b"foo" @@ -408,7 +414,7 @@ def post_body(): assert response.json() == {"body": "foobar"} -def test_request_send_push_promise(): +def test_request_send_push_promise(test_client_factory): async def app(scope, receive, send): # the server is push-enabled scope["extensions"]["http.response.push"] = {} @@ -419,12 +425,12 @@ async def app(scope, receive, send): response = JSONResponse({"json": "OK"}) await response(scope, receive, send) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/") assert response.json() == {"json": "OK"} -def test_request_send_push_promise_without_push_extension(): +def test_request_send_push_promise_without_push_extension(test_client_factory): """ If server does not support the `http.response.push` extension, .send_push_promise() does nothing. @@ -437,12 +443,12 @@ async def app(scope, receive, send): response = JSONResponse({"json": "OK"}) await response(scope, receive, send) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/") assert response.json() == {"json": "OK"} -def test_request_send_push_promise_without_setting_send(): +def test_request_send_push_promise_without_setting_send(test_client_factory): """ If Request is instantiated without the send channel, then .send_push_promise() is not available. @@ -461,6 +467,6 @@ async def app(scope, receive, send): response = JSONResponse({"json": data}) await response(scope, receive, send) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/") assert response.json() == {"json": "Send channel not available"} diff --git a/tests/test_responses.py b/tests/test_responses.py index 496e64c86a..baba549baf 100644 --- a/tests/test_responses.py +++ b/tests/test_responses.py @@ -13,40 +13,39 @@ Response, StreamingResponse, ) -from starlette.testclient import TestClient -def test_text_response(): +def test_text_response(test_client_factory): async def app(scope, receive, send): response = Response("hello, world", media_type="text/plain") await response(scope, receive, send) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/") assert response.text == "hello, world" -def test_bytes_response(): +def test_bytes_response(test_client_factory): async def app(scope, receive, send): response = Response(b"xxxxx", media_type="image/png") await response(scope, receive, send) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/") assert response.content == b"xxxxx" -def test_json_none_response(): +def test_json_none_response(test_client_factory): async def app(scope, receive, send): response = JSONResponse(None) await response(scope, receive, send) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/") assert response.json() is None -def test_redirect_response(): +def test_redirect_response(test_client_factory): async def app(scope, receive, send): if scope["path"] == "/": response = Response("hello, world", media_type="text/plain") @@ -54,13 +53,13 @@ async def app(scope, receive, send): response = RedirectResponse("/") await response(scope, receive, send) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/redirect") assert response.text == "hello, world" assert response.url == "http://testserver/" -def test_quoting_redirect_response(): +def test_quoting_redirect_response(test_client_factory): async def app(scope, receive, send): if scope["path"] == "/I ♥ Starlette/": response = Response("hello, world", media_type="text/plain") @@ -68,13 +67,13 @@ async def app(scope, receive, send): response = RedirectResponse("/I ♥ Starlette/") await response(scope, receive, send) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/redirect") assert response.text == "hello, world" assert response.url == "http://testserver/I%20%E2%99%A5%20Starlette/" -def test_streaming_response(): +def test_streaming_response(test_client_factory): filled_by_bg_task = "" async def app(scope, receive, send): @@ -98,13 +97,13 @@ async def numbers_for_cleanup(start=1, stop=5): await response(scope, receive, send) assert filled_by_bg_task == "" - client = TestClient(app) + client = test_client_factory(app) response = client.get("/") assert response.text == "1, 2, 3, 4, 5" assert filled_by_bg_task == "6, 7, 8, 9" -def test_streaming_response_custom_iterator(): +def test_streaming_response_custom_iterator(test_client_factory): async def app(scope, receive, send): class CustomAsyncIterator: def __init__(self): @@ -122,12 +121,12 @@ async def __anext__(self): response = StreamingResponse(CustomAsyncIterator(), media_type="text/plain") await response(scope, receive, send) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/") assert response.text == "12345" -def test_streaming_response_custom_iterable(): +def test_streaming_response_custom_iterable(test_client_factory): async def app(scope, receive, send): class CustomAsyncIterable: async def __aiter__(self): @@ -137,12 +136,12 @@ async def __aiter__(self): response = StreamingResponse(CustomAsyncIterable(), media_type="text/plain") await response(scope, receive, send) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/") assert response.text == "12345" -def test_sync_streaming_response(): +def test_sync_streaming_response(test_client_factory): async def app(scope, receive, send): def numbers(minimum, maximum): for i in range(minimum, maximum + 1): @@ -154,37 +153,37 @@ def numbers(minimum, maximum): response = StreamingResponse(generator, media_type="text/plain") await response(scope, receive, send) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/") assert response.text == "1, 2, 3, 4, 5" -def test_response_headers(): +def test_response_headers(test_client_factory): async def app(scope, receive, send): headers = {"x-header-1": "123", "x-header-2": "456"} response = Response("hello, world", media_type="text/plain", headers=headers) response.headers["x-header-2"] = "789" await response(scope, receive, send) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/") assert response.headers["x-header-1"] == "123" assert response.headers["x-header-2"] == "789" -def test_response_phrase(): +def test_response_phrase(test_client_factory): app = Response(status_code=204) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/") assert response.reason == "No Content" app = Response(b"", status_code=123) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/") assert response.reason == "" -def test_file_response(tmpdir): +def test_file_response(tmpdir, test_client_factory): path = os.path.join(tmpdir, "xyz") content = b"" * 1000 with open(path, "wb") as file: @@ -213,7 +212,7 @@ async def app(scope, receive, send): await response(scope, receive, send) assert filled_by_bg_task == "" - client = TestClient(app) + client = test_client_factory(app) response = client.get("/") expected_disposition = 'attachment; filename="example.png"' assert response.status_code == status.HTTP_200_OK @@ -226,31 +225,31 @@ async def app(scope, receive, send): assert filled_by_bg_task == "6, 7, 8, 9" -def test_file_response_with_directory_raises_error(tmpdir): +def test_file_response_with_directory_raises_error(tmpdir, test_client_factory): app = FileResponse(path=tmpdir, filename="example.png") - client = TestClient(app) + client = test_client_factory(app) with pytest.raises(RuntimeError) as exc_info: client.get("/") assert "is not a file" in str(exc_info.value) -def test_file_response_with_missing_file_raises_error(tmpdir): +def test_file_response_with_missing_file_raises_error(tmpdir, test_client_factory): path = os.path.join(tmpdir, "404.txt") app = FileResponse(path=path, filename="404.txt") - client = TestClient(app) + client = test_client_factory(app) with pytest.raises(RuntimeError) as exc_info: client.get("/") assert "does not exist" in str(exc_info.value) -def test_file_response_with_chinese_filename(tmpdir): +def test_file_response_with_chinese_filename(tmpdir, test_client_factory): content = b"file content" filename = "你好.txt" # probably "Hello.txt" in Chinese path = os.path.join(tmpdir, filename) with open(path, "wb") as f: f.write(content) app = FileResponse(path=path, filename=filename) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/") expected_disposition = "attachment; filename*=utf-8''%E4%BD%A0%E5%A5%BD.txt" assert response.status_code == status.HTTP_200_OK @@ -258,7 +257,7 @@ def test_file_response_with_chinese_filename(tmpdir): assert response.headers["content-disposition"] == expected_disposition -def test_set_cookie(): +def test_set_cookie(test_client_factory): async def app(scope, receive, send): response = Response("Hello, world!", media_type="text/plain") response.set_cookie( @@ -274,12 +273,12 @@ async def app(scope, receive, send): ) await response(scope, receive, send) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/") assert response.text == "Hello, world!" -def test_delete_cookie(): +def test_delete_cookie(test_client_factory): async def app(scope, receive, send): request = Request(scope, receive) response = Response("Hello, world!", media_type="text/plain") @@ -289,24 +288,24 @@ async def app(scope, receive, send): response.set_cookie("mycookie", "myvalue") await response(scope, receive, send) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/") assert response.cookies["mycookie"] response = client.get("/") assert not response.cookies.get("mycookie") -def test_populate_headers(): +def test_populate_headers(test_client_factory): app = Response(content="hi", headers={}, media_type="text/html") - client = TestClient(app) + client = test_client_factory(app) response = client.get("/") assert response.text == "hi" assert response.headers["content-length"] == "2" assert response.headers["content-type"] == "text/html; charset=utf-8" -def test_head_method(): +def test_head_method(test_client_factory): app = Response("hello, world", media_type="text/plain") - client = TestClient(app) + client = test_client_factory(app) response = client.head("/") assert response.text == "" diff --git a/tests/test_routing.py b/tests/test_routing.py index 1d8eb8d957..3c096125fe 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -6,7 +6,6 @@ from starlette.applications import Starlette from starlette.responses import JSONResponse, PlainTextResponse, Response from starlette.routing import Host, Mount, NoMatchFound, Route, Router, WebSocketRoute -from starlette.testclient import TestClient from starlette.websockets import WebSocket, WebSocketDisconnect @@ -105,10 +104,13 @@ async def websocket_params(session): await session.close() -client = TestClient(app) +@pytest.fixture +def client(test_client_factory): + with test_client_factory(app) as client: + yield client -def test_router(): +def test_router(client): response = client.get("/") assert response.status_code == 200 assert response.text == "Hello, world" @@ -147,7 +149,7 @@ def test_router(): assert response.text == "xxxxx" -def test_route_converters(): +def test_route_converters(client): # Test integer conversion response = client.get("/int/5") assert response.status_code == 200 @@ -232,19 +234,19 @@ def test_url_for(): ) -def test_router_add_route(): +def test_router_add_route(client): response = client.get("/func") assert response.status_code == 200 assert response.text == "Hello, world!" -def test_router_duplicate_path(): +def test_router_duplicate_path(client): response = client.post("/func") assert response.status_code == 200 assert response.text == "Hello, POST!" -def test_router_add_websocket_route(): +def test_router_add_websocket_route(client): with client.websocket_connect("/ws") as session: text = session.receive_text() assert text == "Hello, world!" @@ -275,8 +277,8 @@ async def __call__(self, scope, receive, send): ) -def test_protocol_switch(): - client = TestClient(mixed_protocol_app) +def test_protocol_switch(test_client_factory): + client = test_client_factory(mixed_protocol_app) response = client.get("/") assert response.status_code == 200 @@ -293,9 +295,9 @@ def test_protocol_switch(): ok = PlainTextResponse("OK") -def test_mount_urls(): +def test_mount_urls(test_client_factory): mounted = Router([Mount("/users", ok, name="users")]) - client = TestClient(mounted) + client = test_client_factory(mounted) assert client.get("/users").status_code == 200 assert client.get("/users").url == "http://testserver/users/" assert client.get("/users/").status_code == 200 @@ -318,9 +320,9 @@ def test_reverse_mount_urls(): ) -def test_mount_at_root(): +def test_mount_at_root(test_client_factory): mounted = Router([Mount("/", ok, name="users")]) - client = TestClient(mounted) + client = test_client_factory(mounted) assert client.get("/").status_code == 200 @@ -348,8 +350,8 @@ def users_api(request): ) -def test_host_routing(): - client = TestClient(mixed_hosts_app, base_url="https://api.example.org/") +def test_host_routing(test_client_factory): + client = test_client_factory(mixed_hosts_app, base_url="https://api.example.org/") response = client.get("/users") assert response.status_code == 200 @@ -358,7 +360,7 @@ def test_host_routing(): response = client.get("/") assert response.status_code == 404 - client = TestClient(mixed_hosts_app, base_url="https://www.example.org/") + client = test_client_factory(mixed_hosts_app, base_url="https://www.example.org/") response = client.get("/users") assert response.status_code == 200 @@ -393,8 +395,8 @@ async def subdomain_app(scope, receive, send): ) -def test_subdomain_routing(): - client = TestClient(subdomain_app, base_url="https://foo.example.org/") +def test_subdomain_routing(test_client_factory): + client = test_client_factory(subdomain_app, base_url="https://foo.example.org/") response = client.get("/") assert response.status_code == 200 @@ -429,9 +431,11 @@ async def echo_urls(request): ] -def test_url_for_with_root_path(): +def test_url_for_with_root_path(test_client_factory): app = Starlette(routes=echo_url_routes) - client = TestClient(app, base_url="https://www.example.org/", root_path="/sub_path") + client = test_client_factory( + app, base_url="https://www.example.org/", root_path="/sub_path" + ) response = client.get("/") assert response.json() == { "index": "https://www.example.org/sub_path/", @@ -459,17 +463,17 @@ def test_url_for_with_double_mount(): assert url == "/mount/static/123" -def test_standalone_route_matches(): +def test_standalone_route_matches(test_client_factory): app = Route("/", PlainTextResponse("Hello, World!")) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/") assert response.status_code == 200 assert response.text == "Hello, World!" -def test_standalone_route_does_not_match(): +def test_standalone_route_does_not_match(test_client_factory): app = Route("/", PlainTextResponse("Hello, World!")) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/invalid") assert response.status_code == 404 assert response.text == "Not Found" @@ -481,23 +485,23 @@ async def ws_helloworld(websocket): await websocket.close() -def test_standalone_ws_route_matches(): +def test_standalone_ws_route_matches(test_client_factory): app = WebSocketRoute("/", ws_helloworld) - client = TestClient(app) + client = test_client_factory(app) with client.websocket_connect("/") as websocket: text = websocket.receive_text() assert text == "Hello, world!" -def test_standalone_ws_route_does_not_match(): +def test_standalone_ws_route_does_not_match(test_client_factory): app = WebSocketRoute("/", ws_helloworld) - client = TestClient(app) + client = test_client_factory(app) with pytest.raises(WebSocketDisconnect): with client.websocket_connect("/invalid"): pass # pragma: nocover -def test_lifespan_async(): +def test_lifespan_async(test_client_factory): startup_complete = False shutdown_complete = False @@ -520,7 +524,7 @@ async def run_shutdown(): assert not startup_complete assert not shutdown_complete - with TestClient(app) as client: + with test_client_factory(app) as client: assert startup_complete assert not shutdown_complete client.get("/") @@ -528,7 +532,7 @@ async def run_shutdown(): assert shutdown_complete -def test_lifespan_sync(): +def test_lifespan_sync(test_client_factory): startup_complete = False shutdown_complete = False @@ -551,7 +555,7 @@ def run_shutdown(): assert not startup_complete assert not shutdown_complete - with TestClient(app) as client: + with test_client_factory(app) as client: assert startup_complete assert not shutdown_complete client.get("/") @@ -559,7 +563,7 @@ def run_shutdown(): assert shutdown_complete -def test_raise_on_startup(): +def test_raise_on_startup(test_client_factory): def run_startup(): raise RuntimeError() @@ -576,19 +580,19 @@ async def _send(message): startup_failed = False with pytest.raises(RuntimeError): - with TestClient(app): + with test_client_factory(app): pass # pragma: nocover assert startup_failed -def test_raise_on_shutdown(): +def test_raise_on_shutdown(test_client_factory): def run_shutdown(): raise RuntimeError() app = Router(on_shutdown=[run_shutdown]) with pytest.raises(RuntimeError): - with TestClient(app): + with test_client_factory(app): pass # pragma: nocover @@ -615,8 +619,8 @@ async def _partial_async_endpoint(arg, request): ) -def test_partial_async_endpoint(): - test_client = TestClient(partial_async_app) +def test_partial_async_endpoint(test_client_factory): + test_client = test_client_factory(partial_async_app) response = test_client.get("/") assert response.status_code == 200 assert response.json() == {"arg": "foo"} diff --git a/tests/test_schemas.py b/tests/test_schemas.py index 0ae43238fe..28fe777f09 100644 --- a/tests/test_schemas.py +++ b/tests/test_schemas.py @@ -1,7 +1,6 @@ from starlette.applications import Starlette from starlette.endpoints import HTTPEndpoint from starlette.schemas import SchemaGenerator -from starlette.testclient import TestClient schemas = SchemaGenerator( {"openapi": "3.0.0", "info": {"title": "Example API", "version": "1.0"}} @@ -213,8 +212,8 @@ def test_schema_generation(): """ -def test_schema_endpoint(): - client = TestClient(app) +def test_schema_endpoint(test_client_factory): + client = test_client_factory(app) response = client.get("/schema") assert response.headers["Content-Type"] == "application/vnd.oai.openapi" assert response.text.strip() == EXPECTED_SCHEMA.strip() diff --git a/tests/test_staticfiles.py b/tests/test_staticfiles.py index 3c8ff240e5..d5ec1afc5e 100644 --- a/tests/test_staticfiles.py +++ b/tests/test_staticfiles.py @@ -9,35 +9,34 @@ from starlette.requests import Request from starlette.routing import Mount from starlette.staticfiles import StaticFiles -from starlette.testclient import TestClient -def test_staticfiles(tmpdir): +def test_staticfiles(tmpdir, test_client_factory): path = os.path.join(tmpdir, "example.txt") with open(path, "w") as file: file.write("") app = StaticFiles(directory=tmpdir) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/example.txt") assert response.status_code == 200 assert response.text == "" -def test_staticfiles_with_pathlib(tmpdir): +def test_staticfiles_with_pathlib(tmpdir, test_client_factory): base_dir = pathlib.Path(tmpdir) path = base_dir / "example.txt" with open(path, "w") as file: file.write("") app = StaticFiles(directory=base_dir) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/example.txt") assert response.status_code == 200 assert response.text == "" -def test_staticfiles_head_with_middleware(tmpdir): +def test_staticfiles_head_with_middleware(tmpdir, test_client_factory): """ see https://github.com/encode/starlette/pull/935 """ @@ -53,51 +52,51 @@ async def does_nothing_middleware(request: Request, call_next): response = await call_next(request) return response - client = TestClient(app) + client = test_client_factory(app) response = client.head("/static/example.txt") assert response.status_code == 200 assert response.headers.get("content-length") == "100" -def test_staticfiles_with_package(): +def test_staticfiles_with_package(test_client_factory): app = StaticFiles(packages=["tests"]) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/example.txt") assert response.status_code == 200 assert response.text == "123\n" -def test_staticfiles_post(tmpdir): +def test_staticfiles_post(tmpdir, test_client_factory): path = os.path.join(tmpdir, "example.txt") with open(path, "w") as file: file.write("") app = StaticFiles(directory=tmpdir) - client = TestClient(app) + client = test_client_factory(app) response = client.post("/example.txt") assert response.status_code == 405 assert response.text == "Method Not Allowed" -def test_staticfiles_with_directory_returns_404(tmpdir): +def test_staticfiles_with_directory_returns_404(tmpdir, test_client_factory): path = os.path.join(tmpdir, "example.txt") with open(path, "w") as file: file.write("") app = StaticFiles(directory=tmpdir) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/") assert response.status_code == 404 assert response.text == "Not Found" -def test_staticfiles_with_missing_file_returns_404(tmpdir): +def test_staticfiles_with_missing_file_returns_404(tmpdir, test_client_factory): path = os.path.join(tmpdir, "example.txt") with open(path, "w") as file: file.write("") app = StaticFiles(directory=tmpdir) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/404.txt") assert response.status_code == 404 assert response.text == "Not Found" @@ -110,30 +109,32 @@ def test_staticfiles_instantiated_with_missing_directory(tmpdir): assert "does not exist" in str(exc_info.value) -def test_staticfiles_configured_with_missing_directory(tmpdir): +def test_staticfiles_configured_with_missing_directory(tmpdir, test_client_factory): path = os.path.join(tmpdir, "no_such_directory") app = StaticFiles(directory=path, check_dir=False) - client = TestClient(app) + client = test_client_factory(app) with pytest.raises(RuntimeError) as exc_info: client.get("/example.txt") assert "does not exist" in str(exc_info.value) -def test_staticfiles_configured_with_file_instead_of_directory(tmpdir): +def test_staticfiles_configured_with_file_instead_of_directory( + tmpdir, test_client_factory +): path = os.path.join(tmpdir, "example.txt") with open(path, "w") as file: file.write("") app = StaticFiles(directory=path, check_dir=False) - client = TestClient(app) + client = test_client_factory(app) with pytest.raises(RuntimeError) as exc_info: client.get("/example.txt") assert "is not a directory" in str(exc_info.value) -def test_staticfiles_config_check_occurs_only_once(tmpdir): +def test_staticfiles_config_check_occurs_only_once(tmpdir, test_client_factory): app = StaticFiles(directory=tmpdir) - client = TestClient(app) + client = test_client_factory(app) assert not app.config_checked client.get("/") assert app.config_checked @@ -158,26 +159,26 @@ def test_staticfiles_prevents_breaking_out_of_directory(tmpdir): assert response.body == b"Not Found" -def test_staticfiles_never_read_file_for_head_method(tmpdir): +def test_staticfiles_never_read_file_for_head_method(tmpdir, test_client_factory): path = os.path.join(tmpdir, "example.txt") with open(path, "w") as file: file.write("") app = StaticFiles(directory=tmpdir) - client = TestClient(app) + client = test_client_factory(app) response = client.head("/example.txt") assert response.status_code == 200 assert response.content == b"" assert response.headers["content-length"] == "14" -def test_staticfiles_304_with_etag_match(tmpdir): +def test_staticfiles_304_with_etag_match(tmpdir, test_client_factory): path = os.path.join(tmpdir, "example.txt") with open(path, "w") as file: file.write("") app = StaticFiles(directory=tmpdir) - client = TestClient(app) + client = test_client_factory(app) first_resp = client.get("/example.txt") assert first_resp.status_code == 200 last_etag = first_resp.headers["etag"] @@ -186,7 +187,9 @@ def test_staticfiles_304_with_etag_match(tmpdir): assert second_resp.content == b"" -def test_staticfiles_304_with_last_modified_compare_last_req(tmpdir): +def test_staticfiles_304_with_last_modified_compare_last_req( + tmpdir, test_client_factory +): path = os.path.join(tmpdir, "example.txt") file_last_modified_time = time.mktime( time.strptime("2013-10-10 23:40:00", "%Y-%m-%d %H:%M:%S") @@ -196,7 +199,7 @@ def test_staticfiles_304_with_last_modified_compare_last_req(tmpdir): os.utime(path, (file_last_modified_time, file_last_modified_time)) app = StaticFiles(directory=tmpdir) - client = TestClient(app) + client = test_client_factory(app) # last modified less than last request, 304 response = client.get( "/example.txt", headers={"If-Modified-Since": "Thu, 11 Oct 2013 15:30:19 GMT"} @@ -211,7 +214,7 @@ def test_staticfiles_304_with_last_modified_compare_last_req(tmpdir): assert response.content == b"" -def test_staticfiles_html(tmpdir): +def test_staticfiles_html(tmpdir, test_client_factory): path = os.path.join(tmpdir, "404.html") with open(path, "w") as file: file.write("

Custom not found page

") @@ -222,7 +225,7 @@ def test_staticfiles_html(tmpdir): file.write("

Hello

") app = StaticFiles(directory=tmpdir, html=True) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/dir/") assert response.url == "http://testserver/dir/" @@ -244,7 +247,9 @@ def test_staticfiles_html(tmpdir): assert response.text == "

Custom not found page

" -def test_staticfiles_cache_invalidation_for_deleted_file_html_mode(tmpdir): +def test_staticfiles_cache_invalidation_for_deleted_file_html_mode( + tmpdir, test_client_factory +): path_404 = os.path.join(tmpdir, "404.html") with open(path_404, "w") as file: file.write("

404 file

") @@ -259,7 +264,7 @@ def test_staticfiles_cache_invalidation_for_deleted_file_html_mode(tmpdir): os.utime(path_some, (common_modified_time, common_modified_time)) app = StaticFiles(directory=tmpdir, html=True) - client = TestClient(app) + client = test_client_factory(app) resp_exists = client.get("/some.html") assert resp_exists.status_code == 200 diff --git a/tests/test_templates.py b/tests/test_templates.py index a0ab3e1b0b..073482d65a 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -4,10 +4,9 @@ from starlette.applications import Starlette from starlette.templating import Jinja2Templates -from starlette.testclient import TestClient -def test_templates(tmpdir): +def test_templates(tmpdir, test_client_factory): path = os.path.join(tmpdir, "index.html") with open(path, "w") as file: file.write("Hello, world") @@ -19,7 +18,7 @@ def test_templates(tmpdir): async def homepage(request): return templates.TemplateResponse("index.html", {"request": request}) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/") assert response.text == "Hello, world" assert response.template.name == "index.html" diff --git a/tests/test_testclient.py b/tests/test_testclient.py index 86f36e172a..fd96f69a7e 100644 --- a/tests/test_testclient.py +++ b/tests/test_testclient.py @@ -4,7 +4,6 @@ from starlette.applications import Starlette from starlette.middleware import Middleware from starlette.responses import JSONResponse -from starlette.testclient import TestClient from starlette.websockets import WebSocket, WebSocketDisconnect mock_service = Starlette() @@ -15,14 +14,16 @@ def mock_service_endpoint(request): return JSONResponse({"mock": "example"}) -app = Starlette() +def create_app(test_client_factory): + app = Starlette() + @app.route("/") + def homepage(request): + client = test_client_factory(mock_service) + response = client.get("/") + return JSONResponse(response.json()) -@app.route("/") -def homepage(request): - client = TestClient(mock_service) - response = client.get("/") - return JSONResponse(response.json()) + return app startup_error_app = Starlette() @@ -33,30 +34,30 @@ def startup(): raise RuntimeError() -def test_use_testclient_in_endpoint(): +def test_use_testclient_in_endpoint(test_client_factory): """ We should be able to use the test client within applications. This is useful if we need to mock out other services, during tests or in development. """ - client = TestClient(app) + client = test_client_factory(create_app(test_client_factory)) response = client.get("/") assert response.json() == {"mock": "example"} -def test_use_testclient_as_contextmanager(): - with TestClient(app): +def test_use_testclient_as_contextmanager(test_client_factory): + with test_client_factory(create_app(test_client_factory)): pass -def test_error_on_startup(): +def test_error_on_startup(test_client_factory): with pytest.raises(RuntimeError): - with TestClient(startup_error_app): + with test_client_factory(startup_error_app): pass # pragma: no cover -def test_exception_in_middleware(): +def test_exception_in_middleware(test_client_factory): class MiddlewareException(Exception): pass @@ -70,11 +71,11 @@ async def __call__(self, scope, receive, send): broken_middleware = Starlette(middleware=[Middleware(BrokenMiddleware)]) with pytest.raises(MiddlewareException): - with TestClient(broken_middleware): + with test_client_factory(broken_middleware): pass # pragma: no cover -def test_testclient_asgi2(): +def test_testclient_asgi2(test_client_factory): def app(scope): async def inner(receive, send): await send( @@ -88,12 +89,12 @@ async def inner(receive, send): return inner - client = TestClient(app) + client = test_client_factory(app) response = client.get("/") assert response.text == "Hello, world!" -def test_testclient_asgi3(): +def test_testclient_asgi3(test_client_factory): async def app(scope, receive, send): await send( { @@ -104,12 +105,12 @@ async def app(scope, receive, send): ) await send({"type": "http.response.body", "body": b"Hello, world!"}) - client = TestClient(app) + client = test_client_factory(app) response = client.get("/") assert response.text == "Hello, world!" -def test_websocket_blocking_receive(): +def test_websocket_blocking_receive(test_client_factory): def app(scope): async def respond(websocket): await websocket.send_json({"message": "test"}) @@ -128,7 +129,7 @@ async def asgi(receive, send): return asgi - client = TestClient(app) + client = test_client_factory(app) with client.websocket_connect("/") as websocket: data = websocket.receive_json() assert data == {"message": "test"} diff --git a/tests/test_websockets.py b/tests/test_websockets.py index f5d52215b2..e02d433d57 100644 --- a/tests/test_websockets.py +++ b/tests/test_websockets.py @@ -2,11 +2,10 @@ import pytest from starlette import status -from starlette.testclient import TestClient from starlette.websockets import WebSocket, WebSocketDisconnect -def test_websocket_url(): +def test_websocket_url(test_client_factory): def app(scope): async def asgi(receive, send): websocket = WebSocket(scope, receive=receive, send=send) @@ -16,13 +15,13 @@ async def asgi(receive, send): return asgi - client = TestClient(app) + client = test_client_factory(app) with client.websocket_connect("/123?a=abc") as websocket: data = websocket.receive_json() assert data == {"url": "ws://testserver/123?a=abc"} -def test_websocket_binary_json(): +def test_websocket_binary_json(test_client_factory): def app(scope): async def asgi(receive, send): websocket = WebSocket(scope, receive=receive, send=send) @@ -33,14 +32,14 @@ async def asgi(receive, send): return asgi - client = TestClient(app) + client = test_client_factory(app) with client.websocket_connect("/123?a=abc") as websocket: websocket.send_json({"test": "data"}, mode="binary") data = websocket.receive_json(mode="binary") assert data == {"test": "data"} -def test_websocket_query_params(): +def test_websocket_query_params(test_client_factory): def app(scope): async def asgi(receive, send): websocket = WebSocket(scope, receive=receive, send=send) @@ -51,13 +50,13 @@ async def asgi(receive, send): return asgi - client = TestClient(app) + client = test_client_factory(app) with client.websocket_connect("/?a=abc&b=456") as websocket: data = websocket.receive_json() assert data == {"params": {"a": "abc", "b": "456"}} -def test_websocket_headers(): +def test_websocket_headers(test_client_factory): def app(scope): async def asgi(receive, send): websocket = WebSocket(scope, receive=receive, send=send) @@ -68,7 +67,7 @@ async def asgi(receive, send): return asgi - client = TestClient(app) + client = test_client_factory(app) with client.websocket_connect("/") as websocket: expected_headers = { "accept": "*/*", @@ -83,7 +82,7 @@ async def asgi(receive, send): assert data == {"headers": expected_headers} -def test_websocket_port(): +def test_websocket_port(test_client_factory): def app(scope): async def asgi(receive, send): websocket = WebSocket(scope, receive=receive, send=send) @@ -93,13 +92,13 @@ async def asgi(receive, send): return asgi - client = TestClient(app) + client = test_client_factory(app) with client.websocket_connect("ws://example.com:123/123?a=abc") as websocket: data = websocket.receive_json() assert data == {"port": 123} -def test_websocket_send_and_receive_text(): +def test_websocket_send_and_receive_text(test_client_factory): def app(scope): async def asgi(receive, send): websocket = WebSocket(scope, receive=receive, send=send) @@ -110,14 +109,14 @@ async def asgi(receive, send): return asgi - client = TestClient(app) + client = test_client_factory(app) with client.websocket_connect("/") as websocket: websocket.send_text("Hello, world!") data = websocket.receive_text() assert data == "Message was: Hello, world!" -def test_websocket_send_and_receive_bytes(): +def test_websocket_send_and_receive_bytes(test_client_factory): def app(scope): async def asgi(receive, send): websocket = WebSocket(scope, receive=receive, send=send) @@ -128,14 +127,14 @@ async def asgi(receive, send): return asgi - client = TestClient(app) + client = test_client_factory(app) with client.websocket_connect("/") as websocket: websocket.send_bytes(b"Hello, world!") data = websocket.receive_bytes() assert data == b"Message was: Hello, world!" -def test_websocket_send_and_receive_json(): +def test_websocket_send_and_receive_json(test_client_factory): def app(scope): async def asgi(receive, send): websocket = WebSocket(scope, receive=receive, send=send) @@ -146,14 +145,14 @@ async def asgi(receive, send): return asgi - client = TestClient(app) + client = test_client_factory(app) with client.websocket_connect("/") as websocket: websocket.send_json({"hello": "world"}) data = websocket.receive_json() assert data == {"message": {"hello": "world"}} -def test_websocket_iter_text(): +def test_websocket_iter_text(test_client_factory): def app(scope): async def asgi(receive, send): websocket = WebSocket(scope, receive=receive, send=send) @@ -163,14 +162,14 @@ async def asgi(receive, send): return asgi - client = TestClient(app) + client = test_client_factory(app) with client.websocket_connect("/") as websocket: websocket.send_text("Hello, world!") data = websocket.receive_text() assert data == "Message was: Hello, world!" -def test_websocket_iter_bytes(): +def test_websocket_iter_bytes(test_client_factory): def app(scope): async def asgi(receive, send): websocket = WebSocket(scope, receive=receive, send=send) @@ -180,14 +179,14 @@ async def asgi(receive, send): return asgi - client = TestClient(app) + client = test_client_factory(app) with client.websocket_connect("/") as websocket: websocket.send_bytes(b"Hello, world!") data = websocket.receive_bytes() assert data == b"Message was: Hello, world!" -def test_websocket_iter_json(): +def test_websocket_iter_json(test_client_factory): def app(scope): async def asgi(receive, send): websocket = WebSocket(scope, receive=receive, send=send) @@ -197,14 +196,14 @@ async def asgi(receive, send): return asgi - client = TestClient(app) + client = test_client_factory(app) with client.websocket_connect("/") as websocket: websocket.send_json({"hello": "world"}) data = websocket.receive_json() assert data == {"message": {"hello": "world"}} -def test_websocket_concurrency_pattern(): +def test_websocket_concurrency_pattern(test_client_factory): def app(scope): stream_send, stream_receive = anyio.create_memory_object_stream() @@ -228,14 +227,14 @@ async def asgi(receive, send): return asgi - client = TestClient(app) + client = test_client_factory(app) with client.websocket_connect("/") as websocket: websocket.send_json({"hello": "world"}) data = websocket.receive_json() assert data == {"hello": "world"} -def test_client_close(): +def test_client_close(test_client_factory): close_code = None def app(scope): @@ -250,13 +249,13 @@ async def asgi(receive, send): return asgi - client = TestClient(app) + client = test_client_factory(app) with client.websocket_connect("/") as websocket: websocket.close(code=status.WS_1001_GOING_AWAY) assert close_code == status.WS_1001_GOING_AWAY -def test_application_close(): +def test_application_close(test_client_factory): def app(scope): async def asgi(receive, send): websocket = WebSocket(scope, receive=receive, send=send) @@ -265,14 +264,14 @@ async def asgi(receive, send): return asgi - client = TestClient(app) + client = test_client_factory(app) with client.websocket_connect("/") as websocket: with pytest.raises(WebSocketDisconnect) as exc: websocket.receive_text() assert exc.value.code == status.WS_1001_GOING_AWAY -def test_rejected_connection(): +def test_rejected_connection(test_client_factory): def app(scope): async def asgi(receive, send): websocket = WebSocket(scope, receive=receive, send=send) @@ -280,14 +279,14 @@ async def asgi(receive, send): return asgi - client = TestClient(app) + client = test_client_factory(app) with pytest.raises(WebSocketDisconnect) as exc: with client.websocket_connect("/"): pass # pragma: nocover assert exc.value.code == status.WS_1001_GOING_AWAY -def test_subprotocol(): +def test_subprotocol(test_client_factory): def app(scope): async def asgi(receive, send): websocket = WebSocket(scope, receive=receive, send=send) @@ -297,25 +296,25 @@ async def asgi(receive, send): return asgi - client = TestClient(app) + client = test_client_factory(app) with client.websocket_connect("/", subprotocols=["soap", "wamp"]) as websocket: assert websocket.accepted_subprotocol == "wamp" -def test_websocket_exception(): +def test_websocket_exception(test_client_factory): def app(scope): async def asgi(receive, send): assert False return asgi - client = TestClient(app) + client = test_client_factory(app) with pytest.raises(AssertionError): with client.websocket_connect("/123?a=abc"): pass # pragma: nocover -def test_duplicate_close(): +def test_duplicate_close(test_client_factory): def app(scope): async def asgi(receive, send): websocket = WebSocket(scope, receive=receive, send=send) @@ -325,13 +324,13 @@ async def asgi(receive, send): return asgi - client = TestClient(app) + client = test_client_factory(app) with pytest.raises(RuntimeError): with client.websocket_connect("/"): pass # pragma: nocover -def test_duplicate_disconnect(): +def test_duplicate_disconnect(test_client_factory): def app(scope): async def asgi(receive, send): websocket = WebSocket(scope, receive=receive, send=send) @@ -342,7 +341,7 @@ async def asgi(receive, send): return asgi - client = TestClient(app) + client = test_client_factory(app) with pytest.raises(RuntimeError): with client.websocket_connect("/") as websocket: websocket.close() From 254d0d97e463284fb6a4b41ab8cc481d4bc639b8 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Sat, 3 Jul 2021 17:39:25 +0100 Subject: [PATCH 14/37] ensure TestClient requests run in the same EventLoop as lifespan (#1213) * ensure TestClient requests run in the same EventLoop as lifespan * for lifespan task verification, use native task identity rather than anyio.abc.TaskInfo equality https://github.com/agronholm/anyio/issues/324 * remove redundant pragma: no cover * it's now a loop_id not a threading.ident * replace Protocol with plain Callable TypeAlias * use lifespan_context to actually open a task group trio should complain if used incorrectly here. * assign self.portal once, schedule reset immediately after assignment * inline apps into their tests * make task/loop trackers nonlocals --- starlette/testclient.py | 82 +++++++++++++++++---------- tests/test_testclient.py | 119 ++++++++++++++++++++++++++++++++++----- 2 files changed, 157 insertions(+), 44 deletions(-) diff --git a/starlette/testclient.py b/starlette/testclient.py index 33bb410d02..7aa59fb9e6 100644 --- a/starlette/testclient.py +++ b/starlette/testclient.py @@ -12,7 +12,7 @@ from concurrent.futures import Future from urllib.parse import unquote, urljoin, urlsplit -import anyio +import anyio.abc import requests from anyio.streams.stapled import StapledObjectStream @@ -24,6 +24,12 @@ else: # pragma: no cover from typing_extensions import TypedDict + +_PortalFactoryType = typing.Callable[ + [], typing.ContextManager[anyio.abc.BlockingPortal] +] + + # Annotations for `Session.request()` Cookies = typing.Union[ typing.MutableMapping[str, str], requests.cookies.RequestsCookieJar @@ -106,14 +112,14 @@ class _ASGIAdapter(requests.adapters.HTTPAdapter): def __init__( self, app: ASGI3App, - async_backend: _AsyncBackend, + portal_factory: _PortalFactoryType, raise_server_exceptions: bool = True, root_path: str = "", ) -> None: self.app = app self.raise_server_exceptions = raise_server_exceptions self.root_path = root_path - self.async_backend = async_backend + self.portal_factory = portal_factory def send( self, request: requests.PreparedRequest, *args: typing.Any, **kwargs: typing.Any @@ -162,7 +168,7 @@ def send( "server": [host, port], "subprotocols": subprotocols, } - session = WebSocketTestSession(self.app, scope, self.async_backend) + session = WebSocketTestSession(self.app, scope, self.portal_factory) raise _Upgrade(session) scope = { @@ -252,7 +258,7 @@ async def send(message: Message) -> None: context = message["context"] try: - with anyio.start_blocking_portal(**self.async_backend) as portal: + with self.portal_factory() as portal: response_complete = portal.call(anyio.Event) portal.call(self.app, scope, receive, send) except BaseException as exc: @@ -285,20 +291,18 @@ def __init__( self, app: ASGI3App, scope: Scope, - async_backend: _AsyncBackend, + portal_factory: _PortalFactoryType, ) -> None: self.app = app self.scope = scope self.accepted_subprotocol = None - self.async_backend = async_backend + self.portal_factory = portal_factory self._receive_queue: "queue.Queue[typing.Any]" = queue.Queue() self._send_queue: "queue.Queue[typing.Any]" = queue.Queue() def __enter__(self) -> "WebSocketTestSession": self.exit_stack = contextlib.ExitStack() - self.portal = self.exit_stack.enter_context( - anyio.start_blocking_portal(**self.async_backend) - ) + self.portal = self.exit_stack.enter_context(self.portal_factory()) try: _: "Future[None]" = self.portal.start_task_soon(self._run) @@ -396,6 +400,7 @@ def receive_json(self, mode: str = "text") -> typing.Any: class TestClient(requests.Session): __test__ = False # For pytest to not discover this up. task: "Future[None]" + portal: typing.Optional[anyio.abc.BlockingPortal] = None def __init__( self, @@ -418,7 +423,7 @@ def __init__( asgi_app = _WrapASGI2(app) #  type: ignore adapter = _ASGIAdapter( asgi_app, - self.async_backend, + portal_factory=self._portal_factory, raise_server_exceptions=raise_server_exceptions, root_path=root_path, ) @@ -430,6 +435,16 @@ def __init__( self.app = asgi_app self.base_url = base_url + @contextlib.contextmanager + def _portal_factory( + self, + ) -> typing.Generator[anyio.abc.BlockingPortal, None, None]: + if self.portal is not None: + yield self.portal + else: + with anyio.start_blocking_portal(**self.async_backend) as portal: + yield portal + def request( # type: ignore self, method: str, @@ -490,29 +505,34 @@ def websocket_connect( return session def __enter__(self) -> "TestClient": - self.exit_stack = contextlib.ExitStack() - self.portal = self.exit_stack.enter_context( - anyio.start_blocking_portal(**self.async_backend) - ) - self.stream_send = StapledObjectStream( - *anyio.create_memory_object_stream(math.inf) - ) - self.stream_receive = StapledObjectStream( - *anyio.create_memory_object_stream(math.inf) - ) - try: - self.task = self.portal.start_task_soon(self.lifespan) - self.portal.call(self.wait_startup) - except Exception: - self.exit_stack.close() - raise + with contextlib.ExitStack() as stack: + self.portal = portal = stack.enter_context( + anyio.start_blocking_portal(**self.async_backend) + ) + + @stack.callback + def reset_portal() -> None: + self.portal = None + + self.stream_send = StapledObjectStream( + *anyio.create_memory_object_stream(math.inf) + ) + self.stream_receive = StapledObjectStream( + *anyio.create_memory_object_stream(math.inf) + ) + self.task = portal.start_task_soon(self.lifespan) + portal.call(self.wait_startup) + + @stack.callback + def wait_shutdown() -> None: + portal.call(self.wait_shutdown) + + self.exit_stack = stack.pop_all() + return self def __exit__(self, *args: typing.Any) -> None: - try: - self.portal.call(self.wait_shutdown) - finally: - self.exit_stack.close() + self.exit_stack.close() async def lifespan(self) -> None: scope = {"type": "lifespan"} diff --git a/tests/test_testclient.py b/tests/test_testclient.py index fd96f69a7e..57ea1c3dbf 100644 --- a/tests/test_testclient.py +++ b/tests/test_testclient.py @@ -1,11 +1,22 @@ +import asyncio +import itertools +import sys + import anyio import pytest +import sniffio +import trio.lowlevel from starlette.applications import Starlette from starlette.middleware import Middleware from starlette.responses import JSONResponse from starlette.websockets import WebSocket, WebSocketDisconnect +if sys.version_info >= (3, 7): + from asyncio import current_task as asyncio_current_task # pragma: no cover +else: + asyncio_current_task = asyncio.Task.current_task # pragma: no cover + mock_service = Starlette() @@ -14,16 +25,19 @@ def mock_service_endpoint(request): return JSONResponse({"mock": "example"}) -def create_app(test_client_factory): - app = Starlette() - - @app.route("/") - def homepage(request): - client = test_client_factory(mock_service) - response = client.get("/") - return JSONResponse(response.json()) +def current_task(): + # anyio's TaskInfo comparisons are invalid after their associated native + # task object is GC'd https://github.com/agronholm/anyio/issues/324 + asynclib_name = sniffio.current_async_library() + if asynclib_name == "trio": + return trio.lowlevel.current_task() - return app + if asynclib_name == "asyncio": + task = asyncio_current_task() + if task is None: + raise RuntimeError("must be called from a running task") # pragma: no cover + return task + raise RuntimeError(f"unsupported asynclib={asynclib_name}") # pragma: no cover startup_error_app = Starlette() @@ -41,14 +55,93 @@ def test_use_testclient_in_endpoint(test_client_factory): This is useful if we need to mock out other services, during tests or in development. """ - client = test_client_factory(create_app(test_client_factory)) + + app = Starlette() + + @app.route("/") + def homepage(request): + client = test_client_factory(mock_service) + response = client.get("/") + return JSONResponse(response.json()) + + client = test_client_factory(app) response = client.get("/") assert response.json() == {"mock": "example"} -def test_use_testclient_as_contextmanager(test_client_factory): - with test_client_factory(create_app(test_client_factory)): - pass +def test_use_testclient_as_contextmanager(test_client_factory, anyio_backend_name): + """ + This test asserts a number of properties that are important for an + app level task_group + """ + counter = itertools.count() + identity_runvar = anyio.lowlevel.RunVar[int]("identity_runvar") + + def get_identity(): + try: + return identity_runvar.get() + except LookupError: + token = next(counter) + identity_runvar.set(token) + return token + + startup_task = object() + startup_loop = None + shutdown_task = object() + shutdown_loop = None + + async def lifespan_context(app): + nonlocal startup_task, startup_loop, shutdown_task, shutdown_loop + + startup_task = current_task() + startup_loop = get_identity() + async with anyio.create_task_group() as app.task_group: + yield + shutdown_task = current_task() + shutdown_loop = get_identity() + + app = Starlette(lifespan=lifespan_context) + + @app.route("/loop_id") + async def loop_id(request): + return JSONResponse(get_identity()) + + client = test_client_factory(app) + + with client: + # within a TestClient context every async request runs in the same thread + assert client.get("/loop_id").json() == 0 + assert client.get("/loop_id").json() == 0 + + # that thread is also the same as the lifespan thread + assert startup_loop == 0 + assert shutdown_loop == 0 + + # lifespan events run in the same task, this is important because a task + # group must be entered and exited in the same task. + assert startup_task is shutdown_task + + # outside the TestClient context, new requests continue to spawn in new + # eventloops in new threads + assert client.get("/loop_id").json() == 1 + assert client.get("/loop_id").json() == 2 + + first_task = startup_task + + with client: + # the TestClient context can be re-used, starting a new lifespan task + # in a new thread + assert client.get("/loop_id").json() == 3 + assert client.get("/loop_id").json() == 3 + + assert startup_loop == 3 + assert shutdown_loop == 3 + + # lifespan events still run in the same task, with the context but... + assert startup_task is shutdown_task + + # ... the second TestClient context creates a new lifespan task. + assert first_task is not startup_task def test_error_on_startup(test_client_factory): From 537ab6afd110b79dca95f3c8ecc6980710b1de1c Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Sat, 3 Jul 2021 18:43:24 +0100 Subject: [PATCH 15/37] use an async context manager factory for lifespan (#1227) --- setup.py | 1 + starlette/applications.py | 2 +- starlette/routing.py | 109 ++++++++++++++++++++++++++++++------- starlette/testclient.py | 28 +++++++--- tests/test_applications.py | 43 ++++++++++++++- tests/test_testclient.py | 11 ++-- 6 files changed, 158 insertions(+), 36 deletions(-) diff --git a/setup.py b/setup.py index ac6479746f..31789fe09d 100644 --- a/setup.py +++ b/setup.py @@ -40,6 +40,7 @@ def get_long_description(): install_requires=[ "anyio>=3.0.0,<4", "typing_extensions; python_version < '3.8'", + "contextlib2 >= 21.6.0; python_version < '3.7'", ], extras_require={ "full": [ diff --git a/starlette/applications.py b/starlette/applications.py index 34c3e38bd9..ea52ee70ee 100644 --- a/starlette/applications.py +++ b/starlette/applications.py @@ -46,7 +46,7 @@ def __init__( ] = None, on_startup: typing.Sequence[typing.Callable] = None, on_shutdown: typing.Sequence[typing.Callable] = None, - lifespan: typing.Callable[["Starlette"], typing.AsyncGenerator] = None, + lifespan: typing.Callable[["Starlette"], typing.AsyncContextManager] = None, ) -> None: # The lifespan context function is a newer style that replaces # on_startup / on_shutdown handlers. Use one or the other, not both. diff --git a/starlette/routing.py b/starlette/routing.py index cef1ef4848..9a1a5e12df 100644 --- a/starlette/routing.py +++ b/starlette/routing.py @@ -1,9 +1,13 @@ import asyncio +import contextlib import functools import inspect import re +import sys import traceback +import types import typing +import warnings from enum import Enum from starlette.concurrency import run_in_threadpool @@ -15,6 +19,11 @@ from starlette.types import ASGIApp, Receive, Scope, Send from starlette.websockets import WebSocket, WebSocketClose +if sys.version_info >= (3, 7): + from contextlib import asynccontextmanager # pragma: no cover +else: + from contextlib2 import asynccontextmanager # pragma: no cover + class NoMatchFound(Exception): """ @@ -470,6 +479,51 @@ def __eq__(self, other: typing.Any) -> bool: ) +_T = typing.TypeVar("_T") + + +class _AsyncLiftContextManager(typing.AsyncContextManager[_T]): + def __init__(self, cm: typing.ContextManager[_T]): + self._cm = cm + + async def __aenter__(self) -> _T: + return self._cm.__enter__() + + async def __aexit__( + self, + exc_type: typing.Optional[typing.Type[BaseException]], + exc_value: typing.Optional[BaseException], + traceback: typing.Optional[types.TracebackType], + ) -> typing.Optional[bool]: + return self._cm.__exit__(exc_type, exc_value, traceback) + + +def _wrap_gen_lifespan_context( + lifespan_context: typing.Callable[[typing.Any], typing.Generator] +) -> typing.Callable[[typing.Any], typing.AsyncContextManager]: + cmgr = contextlib.contextmanager(lifespan_context) + + @functools.wraps(cmgr) + def wrapper(app: typing.Any) -> _AsyncLiftContextManager: + return _AsyncLiftContextManager(cmgr(app)) + + return wrapper + + +class _DefaultLifespan: + def __init__(self, router: "Router"): + self._router = router + + async def __aenter__(self) -> None: + await self._router.startup() + + async def __aexit__(self, *exc_info: object) -> None: + await self._router.shutdown() + + def __call__(self: _T, app: object) -> _T: + return self + + class Router: def __init__( self, @@ -478,7 +532,7 @@ def __init__( default: ASGIApp = None, on_startup: typing.Sequence[typing.Callable] = None, on_shutdown: typing.Sequence[typing.Callable] = None, - lifespan: typing.Callable[[typing.Any], typing.AsyncGenerator] = None, + lifespan: typing.Callable[[typing.Any], typing.AsyncContextManager] = None, ) -> None: self.routes = [] if routes is None else list(routes) self.redirect_slashes = redirect_slashes @@ -486,12 +540,31 @@ def __init__( self.on_startup = [] if on_startup is None else list(on_startup) self.on_shutdown = [] if on_shutdown is None else list(on_shutdown) - async def default_lifespan(app: typing.Any) -> typing.AsyncGenerator: - await self.startup() - yield - await self.shutdown() + if lifespan is None: + self.lifespan_context: typing.Callable[ + [typing.Any], typing.AsyncContextManager + ] = _DefaultLifespan(self) - self.lifespan_context = default_lifespan if lifespan is None else lifespan + elif inspect.isasyncgenfunction(lifespan): + warnings.warn( + "async generator function lifespans are deprecated, " + "use an @contextlib.asynccontextmanager function instead", + DeprecationWarning, + ) + self.lifespan_context = asynccontextmanager( + lifespan, # type: ignore[arg-type] + ) + elif inspect.isgeneratorfunction(lifespan): + warnings.warn( + "generator function lifespans are deprecated, " + "use an @contextlib.asynccontextmanager function instead", + DeprecationWarning, + ) + self.lifespan_context = _wrap_gen_lifespan_context( + lifespan, # type: ignore[arg-type] + ) + else: + self.lifespan_context = lifespan async def not_found(self, scope: Scope, receive: Receive, send: Send) -> None: if scope["type"] == "websocket": @@ -541,25 +614,19 @@ async def lifespan(self, scope: Scope, receive: Receive, send: Send) -> None: Handle ASGI lifespan messages, which allows us to manage application startup and shutdown events. """ - first = True + started = False app = scope.get("app") await receive() try: - if inspect.isasyncgenfunction(self.lifespan_context): - async for item in self.lifespan_context(app): - assert first, "Lifespan context yielded multiple times." - first = False - await send({"type": "lifespan.startup.complete"}) - await receive() - else: - for item in self.lifespan_context(app): # type: ignore - assert first, "Lifespan context yielded multiple times." - first = False - await send({"type": "lifespan.startup.complete"}) - await receive() + async with self.lifespan_context(app): + await send({"type": "lifespan.startup.complete"}) + started = True + await receive() except BaseException: - if first: - exc_text = traceback.format_exc() + exc_text = traceback.format_exc() + if started: + await send({"type": "lifespan.shutdown.failed", "message": exc_text}) + else: await send({"type": "lifespan.startup.failed", "message": exc_text}) raise else: diff --git a/starlette/testclient.py b/starlette/testclient.py index 7aa59fb9e6..08d03fa5c4 100644 --- a/starlette/testclient.py +++ b/starlette/testclient.py @@ -543,22 +543,34 @@ async def lifespan(self) -> None: async def wait_startup(self) -> None: await self.stream_receive.send({"type": "lifespan.startup"}) - message = await self.stream_send.receive() - if message is None: - self.task.result() + + async def receive() -> typing.Any: + message = await self.stream_send.receive() + if message is None: + self.task.result() + return message + + message = await receive() assert message["type"] in ( "lifespan.startup.complete", "lifespan.startup.failed", ) if message["type"] == "lifespan.startup.failed": + await receive() + + async def wait_shutdown(self) -> None: + async def receive() -> typing.Any: message = await self.stream_send.receive() if message is None: self.task.result() + return message - async def wait_shutdown(self) -> None: async with self.stream_send: await self.stream_receive.send({"type": "lifespan.shutdown"}) - message = await self.stream_send.receive() - if message is None: - self.task.result() - assert message["type"] == "lifespan.shutdown.complete" + message = await receive() + assert message["type"] in ( + "lifespan.shutdown.complete", + "lifespan.shutdown.failed", + ) + if message["type"] == "lifespan.shutdown.failed": + await receive() diff --git a/tests/test_applications.py b/tests/test_applications.py index 6cb490696d..f5f4c7fbea 100644 --- a/tests/test_applications.py +++ b/tests/test_applications.py @@ -1,4 +1,5 @@ import os +import sys import pytest @@ -10,6 +11,11 @@ from starlette.routing import Host, Mount, Route, Router, WebSocketRoute from starlette.staticfiles import StaticFiles +if sys.version_info >= (3, 7): + from contextlib import asynccontextmanager # pragma: no cover +else: + from contextlib2 import asynccontextmanager # pragma: no cover + app = Starlette() @@ -286,7 +292,39 @@ def run_cleanup(): assert cleanup_complete -def test_app_async_lifespan(test_client_factory): +def test_app_async_cm_lifespan(test_client_factory): + startup_complete = False + cleanup_complete = False + + @asynccontextmanager + async def lifespan(app): + nonlocal startup_complete, cleanup_complete + startup_complete = True + yield + cleanup_complete = True + + app = Starlette(lifespan=lifespan) + + assert not startup_complete + assert not cleanup_complete + with test_client_factory(app): + assert startup_complete + assert not cleanup_complete + assert startup_complete + assert cleanup_complete + + +deprecated_lifespan = pytest.mark.filterwarnings( + r"ignore" + r":(async )?generator function lifespans are deprecated, use an " + r"@contextlib\.asynccontextmanager function instead" + r":DeprecationWarning" + r":starlette.routing" +) + + +@deprecated_lifespan +def test_app_async_gen_lifespan(test_client_factory): startup_complete = False cleanup_complete = False @@ -307,7 +345,8 @@ async def lifespan(app): assert cleanup_complete -def test_app_sync_lifespan(test_client_factory): +@deprecated_lifespan +def test_app_sync_gen_lifespan(test_client_factory): startup_complete = False cleanup_complete = False diff --git a/tests/test_testclient.py b/tests/test_testclient.py index 57ea1c3dbf..8c06667896 100644 --- a/tests/test_testclient.py +++ b/tests/test_testclient.py @@ -12,10 +12,12 @@ from starlette.responses import JSONResponse from starlette.websockets import WebSocket, WebSocketDisconnect -if sys.version_info >= (3, 7): - from asyncio import current_task as asyncio_current_task # pragma: no cover -else: - asyncio_current_task = asyncio.Task.current_task # pragma: no cover +if sys.version_info >= (3, 7): # pragma: no cover + from asyncio import current_task as asyncio_current_task + from contextlib import asynccontextmanager +else: # pragma: no cover + asyncio_current_task = asyncio.Task.current_task + from contextlib2 import asynccontextmanager mock_service = Starlette() @@ -90,6 +92,7 @@ def get_identity(): shutdown_task = object() shutdown_loop = None + @asynccontextmanager async def lifespan_context(app): nonlocal startup_task, startup_loop, shutdown_task, shutdown_loop From 8a3e41a5442239bb2da5f2df6cfaaf4683e49f96 Mon Sep 17 00:00:00 2001 From: Emil Melnikov Date: Thu, 15 Jul 2021 18:13:43 +0200 Subject: [PATCH 16/37] Document the lifespan event handler parameter (#1110) Co-authored-by: Thomas Grainger --- docs/events.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/events.md b/docs/events.md index 4f2bce558b..c7ed49e9dc 100644 --- a/docs/events.md +++ b/docs/events.md @@ -37,6 +37,31 @@ registered startup handlers have completed. The shutdown handlers will run once all connections have been closed, and any in-process background tasks have completed. +A single lifespan asynccontextmanager handler can be used instead of +separate startup and shutdown handlers: + +```python +import contextlib +import anyio +from starlette.applications import Starlette + + +@contextlib.asynccontextmanager +async def lifespan(app): + async with some_async_resource(): + yield + + +routes = [ + ... +] + +app = Starlette(routes=routes, lifespan=lifespan) +``` + +Consider using [`anyio.create_task_group()`](https://anyio.readthedocs.io/en/stable/tasks.html) +for managing asynchronious tasks. + ## Running event handlers in tests You might want to explicitly call into your event handlers in any test setup From b0a6d6ffbdc42cf214ca06d70920e8ddb2c6a53e Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Fri, 16 Jul 2021 11:07:55 +0100 Subject: [PATCH 17/37] ignore charset_normalizer related warning (#1242) --- tests/test_routing.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_routing.py b/tests/test_routing.py index 3c096125fe..9e734b9cc9 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -110,6 +110,12 @@ def client(test_client_factory): yield client +@pytest.mark.filterwarnings( + r"ignore" + r":Trying to detect encoding from a tiny portion of \(5\) byte\(s\)\." + r":UserWarning" + r":charset_normalizer.api" +) def test_router(client): response = client.get("/") assert response.status_code == 200 From e45c5793611bfec606cd9c5d56887ddc67f77c38 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Mon, 19 Jul 2021 08:08:36 +0100 Subject: [PATCH 18/37] prepare release 0.16.0 (#1233) Co-authored-by: Marcelo Trylesinski Co-authored-by: Jamie Hewland --- docs/release-notes.md | 25 +++++++++++++++++++++++++ starlette/__init__.py | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/docs/release-notes.md b/docs/release-notes.md index b6db7b9b60..7305046f9d 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,3 +1,28 @@ +## 0.16.0 + +July 19, 2021 + +### Added + * Added [Encode](https://github.com/sponsors/encode) funding option + [#1219](https://github.com/encode/starlette/pull/1219) + +### Fixed + * `starlette.websockets.WebSocket` instances are now hashable and compare by identity + [#1039](https://github.com/encode/starlette/pull/1039) + * A number of fixes related to running task groups in lifespan + [#1213](https://github.com/encode/starlette/pull/1213), + [#1227](https://github.com/encode/starlette/pull/1227) + +### Deprecated/removed + * The method `starlette.templates.Jinja2Templates.get_env` was removed + [#1218](https://github.com/encode/starlette/pull/1218) + * The ClassVar `starlette.testclient.TestClient.async_backend` was removed, + the backend is now configured using constructor kwargs + [#1211](https://github.com/encode/starlette/pull/1211) + * Passing an Async Generator Function or a Generator Function to `starlette.router.Router(lifespan_context=)` is deprecated. You should wrap your lifespan in `@contextlib.asynccontextmanager`. + [#1227](https://github.com/encode/starlette/pull/1227) + [#1110](https://github.com/encode/starlette/pull/1110) + ## 0.15.0 June 23, 2021 diff --git a/starlette/__init__.py b/starlette/__init__.py index 9da2f8fcca..5a313cc7ef 100644 --- a/starlette/__init__.py +++ b/starlette/__init__.py @@ -1 +1 @@ -__version__ = "0.15.0" +__version__ = "0.16.0" From 7e675a0b86db41e0a99bec2d97bad86633523ec1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=BCttner?= Date: Sat, 14 Aug 2021 16:38:50 +0200 Subject: [PATCH 19/37] Fix BadSignature exception handling in SessionMiddleware (#1264) --- starlette/middleware/sessions.py | 4 ++-- tests/middleware/test_session.py | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/starlette/middleware/sessions.py b/starlette/middleware/sessions.py index a13ec5c0ed..ad7a6ee899 100644 --- a/starlette/middleware/sessions.py +++ b/starlette/middleware/sessions.py @@ -3,7 +3,7 @@ from base64 import b64decode, b64encode import itsdangerous -from itsdangerous.exc import BadTimeSignature, SignatureExpired +from itsdangerous.exc import BadSignature from starlette.datastructures import MutableHeaders, Secret from starlette.requests import HTTPConnection @@ -42,7 +42,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: data = self.signer.unsign(data, max_age=self.max_age) scope["session"] = json.loads(b64decode(data)) initial_session_was_empty = False - except (BadTimeSignature, SignatureExpired): + except BadSignature: scope["session"] = {} else: scope["session"] = {} diff --git a/tests/middleware/test_session.py b/tests/middleware/test_session.py index 314f2be583..42f4447e5c 100644 --- a/tests/middleware/test_session.py +++ b/tests/middleware/test_session.py @@ -112,3 +112,16 @@ def test_session_cookie_subpath(test_client_factory): cookie = response.headers["set-cookie"] cookie_path = re.search(r"; path=(\S+);", cookie).groups()[0] assert cookie_path == "/second_app" + + +def test_invalid_session_cookie(test_client_factory): + app = create_app() + app.add_middleware(SessionMiddleware, secret_key="example") + client = test_client_factory(app) + + response = client.post("/update_session", json={"some": "data"}) + assert response.json() == {"session": {"some": "data"}} + + # we expect it to not raise an exception if we provide a bogus session cookie + response = client.get("/view_session", cookies={"session": "invalid"}) + assert response.json() == {"session": {}} From b65ba8b8cad5d35dbf4734bb1c68f969a651f9ae Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 19 Aug 2021 15:17:41 +0100 Subject: [PATCH 20/37] Add FastAPI sponsorship (#1271) --- docs/overrides/partials/nav.html | 52 +++++++++++++++++++++++++++++++ docs/sponsors/fastapi.png | Bin 0 -> 20640 bytes mkdocs.yml | 1 + 3 files changed, 53 insertions(+) create mode 100644 docs/overrides/partials/nav.html create mode 100644 docs/sponsors/fastapi.png diff --git a/docs/overrides/partials/nav.html b/docs/overrides/partials/nav.html new file mode 100644 index 0000000000..b020b0405a --- /dev/null +++ b/docs/overrides/partials/nav.html @@ -0,0 +1,52 @@ + + {% set class = "md-nav md-nav--primary" %} + {% if "navigation.tabs" in features %} + {% set class = class ~ " md-nav--lifted" %} + {% endif %} + {% if "toc.integrate" in features %} + {% set class = class ~ " md-nav--integrated" %} + {% endif %} + + + diff --git a/docs/sponsors/fastapi.png b/docs/sponsors/fastapi.png new file mode 100644 index 0000000000000000000000000000000000000000..a5b2af17eb7dfebe55636bb4dcd69337ca48c88a GIT binary patch literal 20640 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 literal 0 HcmV?d00001 diff --git a/mkdocs.yml b/mkdocs.yml index b1237aefb4..048cf7fe03 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -4,6 +4,7 @@ site_url: https://www.starlette.io theme: name: 'material' + custom_dir: docs/overrides repo_name: encode/starlette repo_url: https://github.com/encode/starlette From 84e2dc7bfe35aa82a0db958519810309e73de8f5 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 19 Aug 2021 15:40:50 +0100 Subject: [PATCH 21/37] Include FastAPI link --- docs/overrides/partials/nav.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/overrides/partials/nav.html b/docs/overrides/partials/nav.html index b020b0405a..d4684d0a68 100644 --- a/docs/overrides/partials/nav.html +++ b/docs/overrides/partials/nav.html @@ -46,7 +46,7 @@
    - +
From e260283d373058a7ace744aaf8bde28169509008 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Thu, 16 Sep 2021 14:16:25 +0200 Subject: [PATCH 22/37] Ignore loop deprecation warnings originating inside asyncio (#1287) --- setup.cfg | 2 ++ 1 file changed, 2 insertions(+) diff --git a/setup.cfg b/setup.cfg index f59a720292..1266d95c8e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -29,6 +29,8 @@ filterwarnings= ignore: Using or importing the ABCs from 'collections' instead of from 'collections\.abc' is deprecated.*:DeprecationWarning ignore: The 'context' alias has been deprecated. Please use 'context_value' instead\.:DeprecationWarning ignore: The 'variables' alias has been deprecated. Please use 'variable_values' instead\.:DeprecationWarning + # Workaround for Python 3.9.7 (see https://bugs.python.org/issue45097) + ignore:The loop argument is deprecated since Python 3\.8, and scheduled for removal in Python 3\.10\.:DeprecationWarning:asyncio [coverage:run] source_pkgs = starlette, tests From eebb46529b3d0492406abaf73026df7795a7d6ee Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Thu, 16 Sep 2021 14:22:05 +0200 Subject: [PATCH 23/37] Add starlette-cramjam link to the docs (#1283) Co-authored-by: Marcelo Trylesinski --- docs/third-party-packages.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/third-party-packages.md b/docs/third-party-packages.md index 71902ce83f..95abcf4409 100644 --- a/docs/third-party-packages.md +++ b/docs/third-party-packages.md @@ -90,6 +90,12 @@ It relies solely on an auth provider to issue access and/or id tokens to clients Middleware for Starlette that allows you to store and access the context data of a request. Can be used with logging so logs automatically use request headers such as x-request-id or x-correlation-id. +### Starlette Cramjam + +GitHub + +A Starlette middleware that allows **brotli**, **gzip** and **deflate** compression algorithm with a minimal requirements. + ## Frameworks ### Responder From 1ae43b45b508f0f160e7357d17e8214e30e98e05 Mon Sep 17 00:00:00 2001 From: Lukas Kahwe Smith Date: Thu, 16 Sep 2021 14:48:32 +0200 Subject: [PATCH 24/37] Add BaseHTTPMiddleware import on docs (#1285) --- docs/middleware.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/middleware.md b/docs/middleware.md index 7d1233d100..ecef6d6f82 100644 --- a/docs/middleware.md +++ b/docs/middleware.md @@ -183,6 +183,8 @@ To implement a middleware class using `BaseHTTPMiddleware`, you must override th `async def dispatch(request, call_next)` method. ```python +from starlette.middleware.base import BaseHTTPMiddleware + class CustomHeaderMiddleware(BaseHTTPMiddleware): async def dispatch(self, request, call_next): response = await call_next(request) From 85ab09d1b7c0c3307f8cdb96342e6440ca4a2b54 Mon Sep 17 00:00:00 2001 From: Amin Alaee Date: Thu, 16 Sep 2021 22:19:59 +0430 Subject: [PATCH 25/37] Add missing await on database section on docs (#1226) Co-authored-by: Marcelo Trylesinski --- docs/database.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/database.md b/docs/database.md index ca1b85d6a2..aa6cb74edf 100644 --- a/docs/database.md +++ b/docs/database.md @@ -142,10 +142,10 @@ async def populate_note(request): await database.execute(query) raise RuntimeError() except: - transaction.rollback() + await transaction.rollback() raise else: - transaction.commit() + await transaction.commit() ``` ## Test isolation From 874ad467acc7ecea1d759884a83fcd07d5643525 Mon Sep 17 00:00:00 2001 From: Pax <13646646+paxcodes@users.noreply.github.com> Date: Sat, 18 Sep 2021 03:38:52 -0700 Subject: [PATCH 26/37] Update responses.md (#1069) - Fix typo ("Third party middleware" to "...responses") - Fix w3 link (current link leads to 404; I followed links from archive.org to get the "latest" spec) - Format it similarly to "Third party middleware" (use h4, use the class name as header) Co-authored-by: Marcelo Trylesinski --- docs/responses.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/responses.md b/docs/responses.md index c4cd84ed32..5284ac5044 100644 --- a/docs/responses.md +++ b/docs/responses.md @@ -182,9 +182,8 @@ async def app(scope, receive, send): await response(scope, receive, send) ``` -## Third party middleware +## Third party responses -### [SSEResponse(EventSourceResponse)](https://github.com/sysid/sse-starlette) +#### [EventSourceResponse](https://github.com/sysid/sse-starlette) -Server Sent Response implements the ServerSentEvent Protocol: https://www.w3.org/TR/2009/WD-eventsource-20090421. -It enables event streaming from the server to the client without the complexity of websockets. +A response class that implements [Server-Sent Events](https://html.spec.whatwg.org/multipage/server-sent-events.html). It enables event streaming from the server to the client without the complexity of websockets. From 48dea4ddf1ed93a76ddf3bb3107df837993a0b1e Mon Sep 17 00:00:00 2001 From: Steve Dignam Date: Sat, 18 Sep 2021 06:44:11 -0400 Subject: [PATCH 27/37] add typing overloads for Config.__call__ (#1097) * add typing overloads for Config.__call__ This allows for more precise return types instead of the current `Any`. We have 3 overload cases here: 1. handles cases where the user provides an explicit `cast` argument. ``` reveal_type(config("POOL_SIZE", cast=int)) # note: Revealed type is 'int' reveal_type(config("DEBUG", cast=bool, default=None)) # note: Revealed type is 'Union[bool, None]' ``` 2. handles no cast or default being passed ``` reveal_type(config("FOO")) # note: Revealed type is 'str' ``` 3. handles no cast being provided, and the default not being `str` ``` reveal_type(config("DB_NAME", default=None)) # note: Revealed type is 'Union[str, None]' reveal_type(config("FOO", default=False)) # note: Revealed type is 'Union[str, bool]' ``` * ignore overloads from code coverage Co-authored-by: Marcelo Trylesinski --- starlette/config.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/starlette/config.py b/starlette/config.py index e9894e0773..7444ae06d2 100644 --- a/starlette/config.py +++ b/starlette/config.py @@ -46,6 +46,8 @@ def __len__(self) -> int: environ = Environ() +T = typing.TypeVar("T") + class Config: def __init__( @@ -58,6 +60,24 @@ def __init__( if env_file is not None and os.path.isfile(env_file): self.file_values = self._read_file(env_file) + @typing.overload + def __call__( + self, key: str, cast: typing.Type[T], default: T = ... + ) -> T: # pragma: no cover + ... + + @typing.overload + def __call__( + self, key: str, cast: typing.Type[str] = ..., default: str = ... + ) -> str: # pragma: no cover + ... + + @typing.overload + def __call__( + self, key: str, cast: typing.Type[str] = ..., default: T = ... + ) -> typing.Union[T, str]: # pragma: no cover + ... + def __call__( self, key: str, cast: typing.Callable = None, default: typing.Any = undefined ) -> typing.Any: From 7a1108058b6c0a0ab634b4c4376cf878376bf124 Mon Sep 17 00:00:00 2001 From: Piotr Gnus Date: Sat, 18 Sep 2021 13:08:29 +0200 Subject: [PATCH 28/37] Add dark theme for the documentation (#1230) * mkdocs: expanded theme configuration to add alternative dark theme. The default theme will be determined by the users system theme by the `prefers-color-scheme` media query. Co-authored-by: Marcelo Trylesinski --- mkdocs.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/mkdocs.yml b/mkdocs.yml index 048cf7fe03..379f4cbc54 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -5,6 +5,18 @@ site_url: https://www.starlette.io theme: name: 'material' custom_dir: docs/overrides + palette: + - scheme: 'default' + media: '(prefers-color-scheme: light)' + toggle: + icon: 'material/lightbulb' + name: "Switch to dark mode" + - scheme: 'slate' + media: '(prefers-color-scheme: dark)' + primary: 'blue' + toggle: + icon: 'material/lightbulb-outline' + name: 'Switch to light mode' repo_name: encode/starlette repo_url: https://github.com/encode/starlette From 6902361c98a970c017fca856e72987c3b969fbbb Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Sat, 18 Sep 2021 14:10:40 +0200 Subject: [PATCH 29/37] test_database_execute_many: remove unnecessary statement (#778) Co-authored-by: Marcelo Trylesinski --- tests/test_database.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_database.py b/tests/test_database.py index 1230fc8f67..c0a4745d11 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -126,8 +126,6 @@ def test_database(test_client_factory): def test_database_execute_many(test_client_factory): with test_client_factory(app) as client: - response = client.get("/notes") - data = [ {"text": "buy the milk", "completed": True}, {"text": "walk the dog", "completed": False}, From 04242e3a63c6cd640a45191bd517f8e9db5335c1 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 18 Sep 2021 05:39:39 -0700 Subject: [PATCH 30/37] datasette-auth-github is now asgi-auth-github (#967) https://github.com/simonw/datasette-auth-github/issues/63 Co-authored-by: Florimond Manca Co-authored-by: Erik Co-authored-by: Marcelo Trylesinski --- docs/middleware.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/middleware.md b/docs/middleware.md index ecef6d6f82..4c6fb8a9e0 100644 --- a/docs/middleware.md +++ b/docs/middleware.md @@ -258,7 +258,7 @@ when proxy servers are being used, based on the `X-Forwarded-Proto` and `X-Forwa A middleware class to emit timing information (cpu and wall time) for each request which passes through it. Includes examples for how to emit these timings as statsd metrics. -#### [datasette-auth-github](https://github.com/simonw/datasette-auth-github) +#### [asgi-auth-github](https://github.com/simonw/asgi-auth-github) This middleware adds authentication to any ASGI application, requiring users to sign in using their GitHub account (via [OAuth](https://developer.github.com/apps/building-oauth-apps/authorizing-oauth-apps/)). From 8360c0958dbef1deb838328921649e5a6a08eebb Mon Sep 17 00:00:00 2001 From: eprikazc Date: Sat, 18 Sep 2021 16:10:00 +0300 Subject: [PATCH 31/37] Fix typos in config.md code examples (#1065) * Fix typos in config.md code examples * Update docs/config.md * Update docs/config.md Co-authored-by: Marcelo Trylesinski --- docs/config.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/config.md b/docs/config.md index 7a93b22e9e..f7c2c7b7de 100644 --- a/docs/config.md +++ b/docs/config.md @@ -160,7 +160,7 @@ organisations = sqlalchemy.Table( ```python from starlette.applications import Starlette from starlette.middleware import Middleware -from starlette.middleware.session import SessionMiddleware +from starlette.middleware.sessions import SessionMiddleware from starlette.routing import Route from myproject import settings @@ -192,7 +192,7 @@ and drop it once the tests complete. We'd also like to ensure from starlette.config import environ from starlette.testclient import TestClient from sqlalchemy import create_engine -from sqlalchemy_utils import database_exists, create_database +from sqlalchemy_utils import create_database, database_exists, drop_database # This line would raise an error if we use it after 'settings' has been imported. environ['TESTING'] = 'TRUE' From 217019b36303ea16a5869ad076d835e4fdc1cef3 Mon Sep 17 00:00:00 2001 From: Sam Burba Date: Sat, 18 Sep 2021 06:16:05 -0700 Subject: [PATCH 32/37] Align __getitem__ type with scope type (#1118) Scope does not only contain strings, see full spec here: https://asgi.readthedocs.io/en/latest/specs/www.html#websocket-connection-scope Fixes #1117 Co-authored-by: Marcelo Trylesinski --- starlette/requests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/starlette/requests.py b/starlette/requests.py index f88021645c..676f4e9aa3 100644 --- a/starlette/requests.py +++ b/starlette/requests.py @@ -65,7 +65,7 @@ def __init__(self, scope: Scope, receive: Receive = None) -> None: assert scope["type"] in ("http", "websocket") self.scope = scope - def __getitem__(self, key: str) -> str: + def __getitem__(self, key: str) -> typing.Any: return self.scope[key] def __iter__(self) -> typing.Iterator[str]: From ce4687fd071d676e19292c118f321c9b004799fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?jpic=20=E2=88=9E?= Date: Sat, 18 Sep 2021 15:21:04 +0200 Subject: [PATCH 33/37] Propose a roll your own framework solution (#1137) * Propose a roll your own framework solution * Update docs/third-party-packages.md Co-authored-by: Marcelo Trylesinski --- docs/third-party-packages.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/third-party-packages.md b/docs/third-party-packages.md index 95abcf4409..45823e0f3a 100644 --- a/docs/third-party-packages.md +++ b/docs/third-party-packages.md @@ -20,7 +20,7 @@ Simple APISpec integration for Starlette. Document your REST API built with Starlette by declaring OpenAPI (Swagger) schemas in YAML format in your endpoint's docstrings. -### SpecTree +### SpecTree GitHub @@ -43,7 +43,7 @@ Checkout nejma GitHub -Another solution for websocket broadcast. Send messages to channel groups from any part of your code. +Another solution for websocket broadcast. Send messages to channel groups from any part of your code. Checkout channel-box-chat, a simple chat application built using `channel-box` and `starlette`. ### Scout APM @@ -122,3 +122,9 @@ Inspired by **APIStar**'s previous server system with type declarations for rout Formerly Starlette API. Flama aims to bring a layer on top of Starlette to provide an **easy to learn** and **fast to develop** approach for building **highly performant** GraphQL and REST APIs. In the same way of Starlette is, Flama is a perfect option for developing **asynchronous** and **production-ready** services. + +### Starlette-apps + +Roll your own framework with a simple app system, like [Django-GDAPS](https://gdaps.readthedocs.io/en/latest/) or [CakePHP](https://cakephp.org/). + +GitHub From 85316543e7aa69461d3bec536703e5ee6d432a40 Mon Sep 17 00:00:00 2001 From: Ju Date: Sun, 19 Sep 2021 01:35:28 +1200 Subject: [PATCH 34/37] Add `routes=routes` to schemas documentation (#1241) Co-authored-by: Marcelo Trylesinski --- docs/schemas.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/schemas.md b/docs/schemas.md index 2530ba8d1f..275e7b2968 100644 --- a/docs/schemas.md +++ b/docs/schemas.md @@ -51,7 +51,7 @@ routes = [ Route("/schema", endpoint=openapi_schema, include_in_schema=False) ] -app = Starlette() +app = Starlette(routes=routes) ``` We can now access an OpenAPI schema at the "/schema" endpoint. From ddc751a779f09ffb3298aa5b2947a6ed600ec40e Mon Sep 17 00:00:00 2001 From: Alex Oleshkevich Date: Wed, 22 Sep 2021 11:35:17 +0300 Subject: [PATCH 35/37] Add starsessions library to the 3rd-party list. (#1225) * Add starsessions library to the 3rd-party list. * Update docs/third-party-packages.md Co-authored-by: Marcelo Trylesinski Co-authored-by: Marcelo Trylesinski --- docs/third-party-packages.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/third-party-packages.md b/docs/third-party-packages.md index 45823e0f3a..a21f31ba2c 100644 --- a/docs/third-party-packages.md +++ b/docs/third-party-packages.md @@ -90,12 +90,21 @@ It relies solely on an auth provider to issue access and/or id tokens to clients Middleware for Starlette that allows you to store and access the context data of a request. Can be used with logging so logs automatically use request headers such as x-request-id or x-correlation-id. + +### Starsessions + +GitHub + +An alternate session support implementation with customizable storage backends. + + ### Starlette Cramjam GitHub A Starlette middleware that allows **brotli**, **gzip** and **deflate** compression algorithm with a minimal requirements. + ## Frameworks ### Responder From 134eb0b3aed85865ee528ddaea032f67d40f0cb2 Mon Sep 17 00:00:00 2001 From: Amin Alaee Date: Sun, 26 Sep 2021 02:20:03 +0330 Subject: [PATCH 36/37] Fix ImmutableMultiDict getlist return type (#1235) --- starlette/datastructures.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/starlette/datastructures.py b/starlette/datastructures.py index 5149a6e2e5..17dc46eb6a 100644 --- a/starlette/datastructures.py +++ b/starlette/datastructures.py @@ -266,7 +266,7 @@ def __init__( self._dict = {k: v for k, v in _items} self._list = _items - def getlist(self, key: typing.Any) -> typing.List[str]: + def getlist(self, key: typing.Any) -> typing.List[typing.Any]: return [item_value for item_key, item_value in self._list if item_key == key] def keys(self) -> typing.KeysView: From 6c556f6c5e4aa70173a84f6e6854390241231021 Mon Sep 17 00:00:00 2001 From: Anthony Shaw Date: Mon, 27 Sep 2021 07:46:04 +1000 Subject: [PATCH 37/37] Update the Jinja2Templates() constructor to allow PathLike (#1292) * Update the Jinja2Templates() to allow PathLike * Update templating.py * Update starlette/templating.py Co-authored-by: Marcelo Trylesinski * Update starlette/templating.py Co-authored-by: Marcelo Trylesinski * Update starlette/templating.py Co-authored-by: Marcelo Trylesinski * Update starlette/templating.py Co-authored-by: Marcelo Trylesinski --- starlette/templating.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/starlette/templating.py b/starlette/templating.py index 36f613fdfd..18d5eb40c0 100644 --- a/starlette/templating.py +++ b/starlette/templating.py @@ -1,4 +1,5 @@ import typing +from os import PathLike from starlette.background import BackgroundTask from starlette.responses import Response @@ -54,11 +55,13 @@ class Jinja2Templates: return templates.TemplateResponse("index.html", {"request": request}) """ - def __init__(self, directory: str) -> None: + def __init__(self, directory: typing.Union[str, PathLike]) -> None: assert jinja2 is not None, "jinja2 must be installed to use Jinja2Templates" self.env = self._create_env(directory) - def _create_env(self, directory: str) -> "jinja2.Environment": + def _create_env( + self, directory: typing.Union[str, PathLike] + ) -> "jinja2.Environment": @pass_context def url_for(context: dict, name: str, **path_params: typing.Any) -> str: request = context["request"]