Skip to content

Commit 42592d6

Browse files
uSpikeKludexagronholmgraingert
authored
anyio integration (#1157)
* First whack at anyio integration * Fix formatting * Remove debug messages * mypy fixes * Update README.md Co-authored-by: Marcelo Trylesinski <[email protected]> * 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 <[email protected]> * 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 #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 <[email protected]> Co-authored-by: Alex Grönholm <[email protected]> Co-authored-by: Thomas Grainger <[email protected]>
1 parent 15761fb commit 42592d6

28 files changed

+335
-258
lines changed

README.md

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
# Starlette
2323

2424
Starlette is a lightweight [ASGI](https://asgi.readthedocs.io/en/latest/) framework/toolkit,
25-
which is ideal for building high performance asyncio services.
25+
which is ideal for building high performance async services.
2626

2727
It is production-ready, and gives you the following:
2828

@@ -36,7 +36,8 @@ It is production-ready, and gives you the following:
3636
* Session and Cookie support.
3737
* 100% test coverage.
3838
* 100% type annotated codebase.
39-
* Zero hard dependencies.
39+
* Few hard dependencies.
40+
* Compatible with `asyncio` and `trio` backends.
4041

4142
## Requirements
4243

@@ -84,10 +85,9 @@ For a more complete example, see [encode/starlette-example](https://github.com/e
8485

8586
## Dependencies
8687

87-
Starlette does not have any hard dependencies, but the following are optional:
88+
Starlette only requires `anyio`, and the following are optional:
8889

8990
* [`requests`][requests] - Required if you want to use the `TestClient`.
90-
* [`aiofiles`][aiofiles] - Required if you want to use `FileResponse` or `StaticFiles`.
9191
* [`jinja2`][jinja2] - Required if you want to use `Jinja2Templates`.
9292
* [`python-multipart`][python-multipart] - Required if you want to support form parsing, with `request.form()`.
9393
* [`itsdangerous`][itsdangerous] - Required for `SessionMiddleware` support.
@@ -167,7 +167,6 @@ gunicorn -k uvicorn.workers.UvicornH11Worker ...
167167
<p align="center"><i>Starlette is <a href="https://github.com/encode/starlette/blob/master/LICENSE.md">BSD licensed</a> code. Designed & built in Brighton, England.</i></p>
168168

169169
[requests]: http://docs.python-requests.org/en/master/
170-
[aiofiles]: https://github.com/Tinche/aiofiles
171170
[jinja2]: http://jinja.pocoo.org/
172171
[python-multipart]: https://andrew-d.github.io/python-multipart/
173172
[graphene]: https://graphene-python.org/

docs/index.md

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ It is production-ready, and gives you the following:
3232
* Session and Cookie support.
3333
* 100% test coverage.
3434
* 100% type annotated codebase.
35-
* Zero hard dependencies.
35+
* Few hard dependencies.
3636

3737
## Requirements
3838

@@ -79,10 +79,9 @@ For a more complete example, [see here](https://github.com/encode/starlette-exam
7979

8080
## Dependencies
8181

82-
Starlette does not have any hard dependencies, but the following are optional:
82+
Starlette only requires `anyio`, and the following dependencies are optional:
8383

8484
* [`requests`][requests] - Required if you want to use the `TestClient`.
85-
* [`aiofiles`][aiofiles] - Required if you want to use `FileResponse` or `StaticFiles`.
8685
* [`jinja2`][jinja2] - Required if you want to use `Jinja2Templates`.
8786
* [`python-multipart`][python-multipart] - Required if you want to support form parsing, with `request.form()`.
8887
* [`itsdangerous`][itsdangerous] - Required for `SessionMiddleware` support.
@@ -161,7 +160,6 @@ gunicorn -k uvicorn.workers.UvicornH11Worker ...
161160
<p align="center"><i>Starlette is <a href="https://github.com/encode/starlette/blob/master/LICENSE.md">BSD licensed</a> code. Designed & built in Brighton, England.</i></p>
162161

163162
[requests]: http://docs.python-requests.org/en/master/
164-
[aiofiles]: https://github.com/Tinche/aiofiles
165163
[jinja2]: http://jinja.pocoo.org/
166164
[python-multipart]: https://andrew-d.github.io/python-multipart/
167165
[graphene]: https://graphene-python.org/

docs/testclient.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,22 @@ application. Occasionally you might want to test the content of 500 error
3131
responses, rather than allowing client to raise the server exception. In this
3232
case you should use `client = TestClient(app, raise_server_exceptions=False)`.
3333

34+
### Selecting the Async backend
35+
36+
`TestClient.async_backend` is a dictionary which allows you to set the options
37+
for the backend used to run tests. These options are passed to
38+
`anyio.start_blocking_portal()`. See the [anyio documentation](https://anyio.readthedocs.io/en/stable/basics.html#backend-options)
39+
for more information about backend options. By default, `asyncio` is used.
40+
41+
To run `Trio`, set `async_backend["backend"] = "trio"`, for example:
42+
43+
```python
44+
def test_app()
45+
client = TestClient(app)
46+
client.async_backend["backend"] = "trio"
47+
...
48+
```
49+
3450
### Testing WebSocket sessions
3551

3652
You can also test websocket sessions with the test client.
@@ -72,6 +88,8 @@ always raised by the test client.
7288

7389
May raise `starlette.websockets.WebSocketDisconnect` if the application does not accept the websocket connection.
7490

91+
`websocket_connect()` must be used as a context manager (in a `with` block).
92+
7593
#### Sending data
7694

7795
* `.send_text(data)` - Send the given text to the application.

requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,10 @@ types-requests
1818
types-contextvars
1919
types-aiofiles
2020
types-PyYAML
21+
types-dataclasses
2122
pytest
2223
pytest-cov
23-
pytest-asyncio
24+
trio
2425

2526
# Documentation
2627
mkdocs

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,9 @@ def get_long_description():
3737
packages=find_packages(exclude=["tests*"]),
3838
package_data={"starlette": ["py.typed"]},
3939
include_package_data=True,
40+
install_requires=["anyio>=3.0.0,<4"],
4041
extras_require={
4142
"full": [
42-
"aiofiles",
4343
"graphene",
4444
"itsdangerous",
4545
"jinja2",

starlette/concurrency.py

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,42 @@
1-
import asyncio
21
import functools
3-
import sys
42
import typing
53
from typing import Any, AsyncGenerator, Iterator
64

5+
import anyio
6+
77
try:
88
import contextvars # Python 3.7+ only or via contextvars backport.
99
except ImportError: # pragma: no cover
1010
contextvars = None # type: ignore
1111

12-
if sys.version_info >= (3, 7): # pragma: no cover
13-
from asyncio import create_task
14-
else: # pragma: no cover
15-
from asyncio import ensure_future as create_task
1612

1713
T = typing.TypeVar("T")
1814

1915

2016
async def run_until_first_complete(*args: typing.Tuple[typing.Callable, dict]) -> None:
21-
tasks = [create_task(handler(**kwargs)) for handler, kwargs in args]
22-
(done, pending) = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
23-
[task.cancel() for task in pending]
24-
[task.result() for task in done]
17+
async with anyio.create_task_group() as task_group:
18+
19+
async def run(func: typing.Callable[[], typing.Coroutine]) -> None:
20+
await func()
21+
task_group.cancel_scope.cancel()
22+
23+
for func, kwargs in args:
24+
task_group.start_soon(run, functools.partial(func, **kwargs))
2525

2626

2727
async def run_in_threadpool(
2828
func: typing.Callable[..., T], *args: typing.Any, **kwargs: typing.Any
2929
) -> T:
30-
loop = asyncio.get_event_loop()
3130
if contextvars is not None: # pragma: no cover
3231
# Ensure we run in the same context
3332
child = functools.partial(func, *args, **kwargs)
3433
context = contextvars.copy_context()
3534
func = context.run
3635
args = (child,)
3736
elif kwargs: # pragma: no cover
38-
# loop.run_in_executor doesn't accept 'kwargs', so bind them in here
37+
# run_sync doesn't accept 'kwargs', so bind them in here
3938
func = functools.partial(func, **kwargs)
40-
return await loop.run_in_executor(None, func, *args)
39+
return await anyio.to_thread.run_sync(func, *args)
4140

4241

4342
class _StopIteration(Exception):
@@ -57,6 +56,6 @@ def _next(iterator: Iterator) -> Any:
5756
async def iterate_in_threadpool(iterator: Iterator) -> AsyncGenerator:
5857
while True:
5958
try:
60-
yield await run_in_threadpool(_next, iterator)
59+
yield await anyio.to_thread.run_sync(_next, iterator)
6160
except _StopIteration:
6261
break

starlette/graphql.py

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -31,29 +31,18 @@ class GraphQLApp:
3131
def __init__(
3232
self,
3333
schema: "graphene.Schema",
34-
executor: typing.Any = None,
3534
executor_class: type = None,
3635
graphiql: bool = True,
3736
) -> None:
3837
self.schema = schema
3938
self.graphiql = graphiql
40-
if executor is None:
41-
# New style in 0.10.0. Use 'executor_class'.
42-
# See issue https://github.com/encode/starlette/issues/242
43-
self.executor = executor
44-
self.executor_class = executor_class
45-
self.is_async = executor_class is not None and issubclass(
46-
executor_class, AsyncioExecutor
47-
)
48-
else:
49-
# Old style. Use 'executor'.
50-
# We should remove this in the next median/major version bump.
51-
self.executor = executor
52-
self.executor_class = None
53-
self.is_async = isinstance(executor, AsyncioExecutor)
39+
self.executor_class = executor_class
40+
self.is_async = executor_class is not None and issubclass(
41+
executor_class, AsyncioExecutor
42+
)
5443

5544
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
56-
if self.executor is None and self.executor_class is not None:
45+
if self.executor_class is not None:
5746
self.executor = self.executor_class()
5847

5948
request = Request(scope, receive=receive)

starlette/middleware/base.py

Lines changed: 32 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import asyncio
21
import typing
32

3+
import anyio
4+
45
from starlette.requests import Request
56
from starlette.responses import Response, StreamingResponse
6-
from starlette.types import ASGIApp, Message, Receive, Scope, Send
7+
from starlette.types import ASGIApp, Receive, Scope, Send
78

89
RequestResponseEndpoint = typing.Callable[[Request], typing.Awaitable[Response]]
910
DispatchFunction = typing.Callable[
@@ -21,45 +22,39 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
2122
await self.app(scope, receive, send)
2223
return
2324

24-
request = Request(scope, receive=receive)
25-
response = await self.dispatch_func(request, self.call_next)
26-
await response(scope, receive, send)
25+
async def call_next(request: Request) -> Response:
26+
send_stream, recv_stream = anyio.create_memory_object_stream()
2727

28-
async def call_next(self, request: Request) -> Response:
29-
loop = asyncio.get_event_loop()
30-
queue: "asyncio.Queue[typing.Optional[Message]]" = asyncio.Queue()
28+
async def coro() -> None:
29+
async with send_stream:
30+
await self.app(scope, request.receive, send_stream.send)
3131

32-
scope = request.scope
33-
receive = request.receive
34-
send = queue.put
32+
task_group.start_soon(coro)
3533

36-
async def coro() -> None:
3734
try:
38-
await self.app(scope, receive, send)
39-
finally:
40-
await queue.put(None)
41-
42-
task = loop.create_task(coro())
43-
message = await queue.get()
44-
if message is None:
45-
task.result()
46-
raise RuntimeError("No response returned.")
47-
assert message["type"] == "http.response.start"
48-
49-
async def body_stream() -> typing.AsyncGenerator[bytes, None]:
50-
while True:
51-
message = await queue.get()
52-
if message is None:
53-
break
54-
assert message["type"] == "http.response.body"
55-
yield message.get("body", b"")
56-
task.result()
57-
58-
response = StreamingResponse(
59-
status_code=message["status"], content=body_stream()
60-
)
61-
response.raw_headers = message["headers"]
62-
return response
35+
message = await recv_stream.receive()
36+
except anyio.EndOfStream:
37+
raise RuntimeError("No response returned.")
38+
39+
assert message["type"] == "http.response.start"
40+
41+
async def body_stream() -> typing.AsyncGenerator[bytes, None]:
42+
async with recv_stream:
43+
async for message in recv_stream:
44+
assert message["type"] == "http.response.body"
45+
yield message.get("body", b"")
46+
47+
response = StreamingResponse(
48+
status_code=message["status"], content=body_stream()
49+
)
50+
response.raw_headers = message["headers"]
51+
return response
52+
53+
async with anyio.create_task_group() as task_group:
54+
request = Request(scope, receive=receive)
55+
response = await self.dispatch_func(request, call_next)
56+
await response(scope, receive, send)
57+
task_group.cancel_scope.cancel()
6358

6459
async def dispatch(
6560
self, request: Request, call_next: RequestResponseEndpoint

0 commit comments

Comments
 (0)