Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ Add support for Trio via AnyIO #3372

Merged
merged 24 commits into from
Oct 6, 2021
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
6cc34f1
configure strict pytest options
graingert Feb 10, 2021
9f50ab2
open/close files with with
graingert Feb 10, 2021
388b5c9
support trio via anyio with starlette 0.15.0
graingert Jun 14, 2021
33ffaa7
WebSocketDisconnect only happens in websocket_connect context manager
graingert Jun 23, 2021
15b9f76
drop pytest-asyncio dep
graingert Jun 23, 2021
0349712
Merge branch 'configure-strict-pytest' of github.com:graingert/fastap…
graingert Jun 23, 2021
06f543d
Merge branch 'master' into anyio
graingert Jun 29, 2021
3b689b7
Update pyproject.toml
graingert Jul 19, 2021
0622860
bump anyio[trio]
graingert Jul 19, 2021
327cfd0
Merge branch 'master' into anyio
graingert Jul 19, 2021
17ed7ea
increase coverage with pytest.fail() # pragma: no cover
graingert Jul 19, 2021
9b14e4a
drop async_generator and async_exit_stack deps
graingert Jul 20, 2021
7af1e84
drop aiofiles
graingert Jul 20, 2021
0c7195f
Merge branch 'master' into anyio
graingert Jul 24, 2021
ada7c74
Merge branch 'master' of git://github.com/tiangolo/fastapi into anyio
graingert Aug 3, 2021
a539aa6
Merge branch 'master' of git://github.com/tiangolo/fastapi into anyio
graingert Sep 24, 2021
bcfddbe
✏️ Fix typo
tiangolo Oct 6, 2021
702aca3
⏪ Revert import/reexport style in concurrency to simplify maintainabi…
tiangolo Oct 6, 2021
580c65b
♻️ Simplify and inline re-implementation of asyncio.gather()
tiangolo Oct 6, 2021
861f71d
📌 Pin Starlette to the gradual next version, minimum that supports AnyIO
tiangolo Oct 6, 2021
51c5edd
🔀 Merge master
tiangolo Oct 6, 2021
9b52693
🔇 Add pytest filterwarning needed by AnyIO in Python 3.9
tiangolo Oct 6, 2021
96cb5b2
🔀 Merge master
tiangolo Oct 6, 2021
7c19efb
➕ Add contextlib2 as a direct dependency during the migration
tiangolo Oct 6, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
20 changes: 4 additions & 16 deletions docs/en/docs/advanced/async-tests.md
Expand Up @@ -6,21 +6,9 @@ Being able to use asynchronous functions in your tests could be useful, for exam

Let's look at how we can make that work.

## pytest-asyncio
## pytest.mark.anyio

If we want to call asynchronous functions in our tests, our test functions have to be asynchronous. Pytest provides a neat library for this, called `pytest-asyncio`, that allows us to specify that some test functions are to be called asynchronously.

You can install it via:

<div class="termy">

```console
$ pip install pytest-asyncio

---> 100%
```

</div>
If we want to call asynchronous functions in our tests, our test functions have to be asynchronous. Anyio provides a neat plugin for this, that allows us to specify that some test functions are to be called asynchronously.

## HTTPX

Expand Down Expand Up @@ -66,7 +54,7 @@ $ pytest

## In Detail

The marker `@pytest.mark.asyncio` tells pytest that this test function should be called asynchronously:
The marker `@pytest.mark.anyio` tells pytest that this test function should be called asynchronously:

```Python hl_lines="7"
{!../../../docs_src/async_tests/test_main.py!}
Expand Down Expand Up @@ -97,4 +85,4 @@ that we used to make our requests with the `TestClient`.
As the testing function is now asynchronous, you can now also call (and `await`) other `async` functions apart from sending requests to your FastAPI application in your tests, exactly as you would call them anywhere else in your code.

!!! tip
If you encounter a `RuntimeError: Task attached to a different loop` when integrating asynchronous function calls in your tests (e.g. when using <a href="https://stackoverflow.com/questions/41584243/runtimeerror-task-attached-to-a-different-loop" class="external-link" target="_blank">MongoDB's MotorClient</a>) check out <a href="https://github.com/pytest-dev/pytest-asyncio/issues/38#issuecomment-264418154" class="external-link" target="_blank">this issue</a> in the pytest-asyncio repository.
If you encounter a `RuntimeError: Task attached to a different loop` when integrating asynchronous function calls in your tests (e.g. when using <a href="https://stackoverflow.com/questions/41584243/runtimeerror-task-attached-to-a-different-loop" class="external-link" target="_blank">MongoDB's MotorClient</a>) Remember to instantiate objects that need an event loop only within async functions, eg an `'@app.on_event("startup")` callback.
2 changes: 1 addition & 1 deletion docs_src/async_tests/test_main.py
Expand Up @@ -4,7 +4,7 @@
from .main import app


@pytest.mark.asyncio
@pytest.mark.anyio
async def test_root():
async with AsyncClient(app=app, base_url="http://test") as ac:
response = await ac.get("/")
Expand Down
7 changes: 5 additions & 2 deletions docs_src/custom_response/tutorial008.py
Expand Up @@ -7,5 +7,8 @@

@app.get("/")
def main():
file_like = open(some_file_path, mode="rb")
return StreamingResponse(file_like, media_type="video/mp4")
def iterfile():
with open(some_file_path, mode="rb") as file_like:
yield from file_like

return StreamingResponse(iterfile(), media_type="video/mp4")
26 changes: 23 additions & 3 deletions fastapi/dependencies/utils.py
@@ -1,21 +1,23 @@
import asyncio
import inspect
from contextlib import contextmanager
from copy import deepcopy
from typing import (
Any,
Callable,
Coroutine,
Dict,
List,
Mapping,
Optional,
Sequence,
Tuple,
Type,
TypeVar,
Union,
cast,
)

import anyio
from fastapi import params
from fastapi.concurrency import (
AsyncExitStack,
Expand Down Expand Up @@ -85,6 +87,25 @@
)


_T = TypeVar("_T")


async def _run_and_assign(
results: List[Optional[_T]], i: int, async_fn: Callable[[], Coroutine[Any, Any, _T]]
) -> None:
results[i] = await async_fn()


async def _wait_all(*async_fns: Callable[[], Coroutine[Any, Any, _T]]) -> List[_T]:
results: List[Optional[_T]] = [None] * len(async_fns)

async with anyio.create_task_group() as tg:
for i, async_fn in enumerate(async_fns):
tg.start_soon(_run_and_assign, results, i, async_fn)

return results # type: ignore[return-value]


def check_file_field(field: ModelField) -> None:
field_info = field.field_info
if isinstance(field_info, params.Form):
Expand Down Expand Up @@ -695,8 +716,7 @@ async def request_body_to_args(
and lenient_issubclass(field.type_, bytes)
and isinstance(value, sequence_types)
):
awaitables = [sub_value.read() for sub_value in value]
contents = await asyncio.gather(*awaitables)
contents = await _wait_all(*(sub_value.read for sub_value in value))
value = sequence_shape_to_type[field.shape](contents)

v_, errors_ = field.validate(value, values, loc=loc)
Expand Down
25 changes: 20 additions & 5 deletions pyproject.toml
Expand Up @@ -32,7 +32,7 @@ classifiers = [
"Topic :: Internet :: WWW/HTTP",
]
requires = [
"starlette ==0.14.2",
"starlette ==0.15.0",
graingert marked this conversation as resolved.
Show resolved Hide resolved
"pydantic >=1.6.2,!=1.7,!=1.7.1,!=1.7.2,!=1.7.3,!=1.8,!=1.8.1,<2.0.0"
]
description-file = "README.md"
Expand All @@ -43,9 +43,8 @@ Documentation = "https://fastapi.tiangolo.com/"

[tool.flit.metadata.requires-extra]
test = [
"pytest ==5.4.3",
"pytest-cov ==2.10.0",
"pytest-asyncio >=0.14.0,<0.15.0",
"pytest ==6.2.4",
"pytest-cov ==2.12.1",
"mypy ==0.812",
"flake8 >=3.8.3,<4.0.0",
"black ==20.8b1",
Expand All @@ -62,7 +61,8 @@ test = [
"async_generator >=1.10,<2.0.0",
"python-multipart >=0.0.5,<0.0.6",
"aiofiles >=0.5.0,<0.6.0",
"flask >=1.1.2,<2.0.0"
"flask >=1.1.2,<2.0.0",
"anyio[trio] >=3.1.0,<4",
graingert marked this conversation as resolved.
Show resolved Hide resolved
]
doc = [
"mkdocs >=1.1.2,<2.0.0",
Expand Down Expand Up @@ -99,3 +99,18 @@ all = [
[tool.isort]
profile = "black"
known_third_party = ["fastapi", "pydantic", "starlette"]

[tool.pytest.ini_options]
addopts = [
"--strict-config",
"--strict-markers",
]
xfail_strict = true
junit_family = "xunit2"
filterwarnings = [
"error",
'ignore:"@coroutine" decorator is deprecated since Python 3\.8, use "async def" instead:DeprecationWarning',
'ignore:The explicit passing of coroutine objects to asyncio\.wait\(\) is deprecated since Python 3\.8:DeprecationWarning',
'ignore:int_from_bytes is deprecated, use int\.from_bytes instead:cryptography.utils.CryptographyDeprecationWarning',
'ignore:Exception ignored in. <socket\.socket fd=-1:pytest.PytestUnraisableExceptionWarning',
]
2 changes: 1 addition & 1 deletion tests/test_tutorial/test_async_tests/test_main.py
Expand Up @@ -3,6 +3,6 @@
from docs_src.async_tests.test_main import test_root


@pytest.mark.asyncio
@pytest.mark.anyio
async def test_async_testing():
await test_root()
32 changes: 15 additions & 17 deletions tests/test_tutorial/test_request_files/test_tutorial001.py
@@ -1,5 +1,3 @@
import os

from fastapi.testclient import TestClient

from docs_src.request_files.tutorial001 import app
Expand Down Expand Up @@ -152,35 +150,35 @@ def test_post_body_json():
assert response.json() == file_required


def test_post_file(tmpdir):
path = os.path.join(tmpdir, "test.txt")
with open(path, "wb") as file:
file.write(b"<file content>")
def test_post_file(tmp_path):
path = tmp_path / "test.txt"
path.write_bytes(b"<file content>")

client = TestClient(app)
response = client.post("/files/", files={"file": open(path, "rb")})
with path.open("rb") as file:
response = client.post("/files/", files={"file": file})
assert response.status_code == 200, response.text
assert response.json() == {"file_size": 14}


def test_post_large_file(tmpdir):
def test_post_large_file(tmp_path):
default_pydantic_max_size = 2 ** 16
path = os.path.join(tmpdir, "test.txt")
with open(path, "wb") as file:
file.write(b"x" * (default_pydantic_max_size + 1))
path = tmp_path / "test.txt"
path.write_bytes(b"x" * (default_pydantic_max_size + 1))

client = TestClient(app)
response = client.post("/files/", files={"file": open(path, "rb")})
with path.open("rb") as file:
response = client.post("/files/", files={"file": file})
assert response.status_code == 200, response.text
assert response.json() == {"file_size": default_pydantic_max_size + 1}


def test_post_upload_file(tmpdir):
path = os.path.join(tmpdir, "test.txt")
with open(path, "wb") as file:
file.write(b"<file content>")
def test_post_upload_file(tmp_path):
path = tmp_path / "test.txt"
path.write_bytes(b"<file content>")

client = TestClient(app)
response = client.post("/uploadfile/", files={"file": open(path, "rb")})
with path.open("rb") as file:
response = client.post("/uploadfile/", files={"file": file})
assert response.status_code == 200, response.text
assert response.json() == {"filename": "test.txt"}
56 changes: 26 additions & 30 deletions tests/test_tutorial/test_request_files/test_tutorial002.py
@@ -1,5 +1,3 @@
import os

from fastapi.testclient import TestClient

from docs_src.request_files.tutorial002 import app
Expand Down Expand Up @@ -172,42 +170,40 @@ def test_post_body_json():
assert response.json() == file_required


def test_post_files(tmpdir):
path = os.path.join(tmpdir, "test.txt")
with open(path, "wb") as file:
file.write(b"<file content>")
path2 = os.path.join(tmpdir, "test2.txt")
with open(path2, "wb") as file:
file.write(b"<file content2>")
def test_post_files(tmp_path):
path = tmp_path / "test.txt"
path.write_bytes(b"<file content>")
path2 = tmp_path / "test2.txt"
path2.write_bytes(b"<file content2>")

client = TestClient(app)
response = client.post(
"/files/",
files=(
("files", ("test.txt", open(path, "rb"))),
("files", ("test2.txt", open(path2, "rb"))),
),
)
with path.open("rb") as file, path2.open("rb") as file2:
response = client.post(
"/files/",
files=(
("files", ("test.txt", file)),
("files", ("test2.txt", file2)),
),
)
assert response.status_code == 200, response.text
assert response.json() == {"file_sizes": [14, 15]}


def test_post_upload_file(tmpdir):
path = os.path.join(tmpdir, "test.txt")
with open(path, "wb") as file:
file.write(b"<file content>")
path2 = os.path.join(tmpdir, "test2.txt")
with open(path2, "wb") as file:
file.write(b"<file content2>")
def test_post_upload_file(tmp_path):
path = tmp_path / "test.txt"
path.write_bytes(b"<file content>")
path2 = tmp_path / "test2.txt"
path2.write_bytes(b"<file content2>")

client = TestClient(app)
response = client.post(
"/uploadfiles/",
files=(
("files", ("test.txt", open(path, "rb"))),
("files", ("test2.txt", open(path2, "rb"))),
),
)
with path.open("rb") as file, path2.open("rb") as file2:
response = client.post(
"/uploadfiles/",
files=(
("files", ("test.txt", file)),
("files", ("test2.txt", file2)),
),
)
assert response.status_code == 200, response.text
assert response.json() == {"filenames": ["test.txt", "test2.txt"]}

Expand Down
@@ -1,6 +1,3 @@
import os
from pathlib import Path

from fastapi.testclient import TestClient

from docs_src.request_forms_and_files.tutorial001 import app
Expand Down Expand Up @@ -163,32 +160,30 @@ def test_post_body_json():
assert response.json() == file_and_token_required


def test_post_file_no_token(tmpdir):
path = os.path.join(tmpdir, "test.txt")
with open(path, "wb") as file:
file.write(b"<file content>")
def test_post_file_no_token(tmp_path):
path = tmp_path / "test.txt"
path.write_bytes(b"<file content>")

client = TestClient(app)
response = client.post("/files/", files={"file": open(path, "rb")})
with path.open("rb") as file:
response = client.post("/files/", files={"file": file})
assert response.status_code == 422, response.text
assert response.json() == token_required


def test_post_files_and_token(tmpdir):
patha = Path(tmpdir) / "test.txt"
pathb = Path(tmpdir) / "testb.txt"
def test_post_files_and_token(tmp_path):
patha = tmp_path / "test.txt"
pathb = tmp_path / "testb.txt"
patha.write_text("<file content>")
pathb.write_text("<file b content>")

client = TestClient(app)
response = client.post(
"/files/",
data={"token": "foo"},
files={
"file": patha.open("rb"),
"fileb": ("testb.txt", pathb.open("rb"), "text/plain"),
},
)
with patha.open("rb") as filea, pathb.open("rb") as fileb:
response = client.post(
"/files/",
data={"token": "foo"},
files={"file": filea, "fileb": ("testb.txt", fileb, "text/plain")},
)
assert response.status_code == 200, response.text
assert response.json() == {
"file_size": 14,
Expand Down
6 changes: 4 additions & 2 deletions tests/test_tutorial/test_websockets/test_tutorial002.py
Expand Up @@ -72,9 +72,11 @@ def test_websocket_with_header_and_query():

def test_websocket_no_credentials():
with pytest.raises(WebSocketDisconnect):
client.websocket_connect("/items/foo/ws")
with client.websocket_connect("/items/foo/ws"):
pass


def test_websocket_invalid_data():
with pytest.raises(WebSocketDisconnect):
client.websocket_connect("/items/foo/ws?q=bar&token=some-token")
with client.websocket_connect("/items/foo/ws?q=bar&token=some-token"):
pass