Skip to content

Commit ee1589f

Browse files
committed
[feat] Add support for session-scoped event loops.
Signed-off-by: Michael Seifert <m.seifert@digitalernachschub.de>
1 parent 3815046 commit ee1589f

File tree

4 files changed

+258
-1
lines changed

4 files changed

+258
-1
lines changed

docs/source/reference/changelog.rst

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Changelog
77
This release is backwards-compatible with v0.21.
88
Changes are non-breaking, unless you upgrade from v0.22.
99

10-
- BREAKING: The *asyncio_event_loop* mark has been removed. Event loops with class, module and package scope can be requested via the *scope* keyword argument to the _asyncio_ mark.
10+
- BREAKING: The *asyncio_event_loop* mark has been removed. Event loops with class, module, package, and session scopes can be requested via the *scope* keyword argument to the _asyncio_ mark.
1111
- Introduces the *event_loop_policy* fixture which allows testing with non-default or multiple event loops `#662 <https://github.com/pytest-dev/pytest-asyncio/pull/662>`_
1212
- Removes pytest-trio from the test dependencies `#620 <https://github.com/pytest-dev/pytest-asyncio/pull/620>`_
1313

docs/source/reference/markers/index.rst

+1
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ That means they require an *__init__.py* to be present.
3636
Package-scoped loops do not work in `namespace packages. <https://docs.python.org/3/glossary.html#term-namespace-package>`__
3737
Subpackages do not share the loop with their parent package.
3838

39+
Tests marked with *session* scope share the same event loop, even if the tests exist in different packages.
3940

4041
.. |auto mode| replace:: *auto mode*
4142
.. _auto mode: ../../concepts.html#auto-mode

pytest_asyncio/plugin.py

+27
Original file line numberDiff line numberDiff line change
@@ -545,11 +545,21 @@ def pytest_pycollect_makeitem_convert_async_functions_to_subclass(
545545
Class: "class",
546546
Module: "module",
547547
Package: "package",
548+
Session: "session",
548549
}
549550

550551

551552
@pytest.hookimpl
552553
def pytest_collectstart(collector: pytest.Collector):
554+
# Session is not a PyCollector type, so it doesn't have a corresponding
555+
# "obj" attribute to attach a dynamic fixture function to.
556+
# However, there's only one session per pytest run, so there's no need to
557+
# create the fixture dynamically. We can simply define a session-scoped
558+
# event loop fixture once in the plugin code.
559+
if isinstance(collector, Session):
560+
event_loop_fixture_id = _session_event_loop.__name__
561+
collector.stash[_event_loop_fixture_id] = event_loop_fixture_id
562+
return
553563
if not isinstance(collector, (Class, Module, Package)):
554564
return
555565
# There seem to be issues when a fixture is shadowed by another fixture
@@ -892,6 +902,7 @@ def _retrieve_scope_root(item: Union[Collector, Item], scope: str) -> Collector:
892902
"class": Class,
893903
"module": Module,
894904
"package": Package,
905+
"session": Session,
895906
}
896907
scope_root_type = node_type_by_scope[scope]
897908
for node in reversed(item.listchain()):
@@ -920,6 +931,22 @@ def event_loop(request: FixtureRequest) -> Iterator[asyncio.AbstractEventLoop]:
920931
loop.close()
921932

922933

934+
@pytest.fixture(scope="session")
935+
def _session_event_loop(
936+
request: FixtureRequest, event_loop_policy: AbstractEventLoopPolicy
937+
) -> Iterator[asyncio.AbstractEventLoop]:
938+
new_loop_policy = event_loop_policy
939+
old_loop_policy = asyncio.get_event_loop_policy()
940+
old_loop = asyncio.get_event_loop()
941+
asyncio.set_event_loop_policy(new_loop_policy)
942+
loop = asyncio.new_event_loop()
943+
asyncio.set_event_loop(loop)
944+
yield loop
945+
loop.close()
946+
asyncio.set_event_loop_policy(old_loop_policy)
947+
asyncio.set_event_loop(old_loop)
948+
949+
923950
@pytest.fixture(scope="session", autouse=True)
924951
def event_loop_policy() -> AbstractEventLoopPolicy:
925952
"""Return an instance of the policy used to create asyncio event loops."""

tests/markers/test_session_scope.py

+229
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
from textwrap import dedent
2+
3+
from pytest import Pytester
4+
5+
6+
def test_asyncio_mark_provides_session_scoped_loop_strict_mode(pytester: Pytester):
7+
package_name = pytester.path.name
8+
pytester.makepyfile(
9+
__init__="",
10+
shared_module=dedent(
11+
"""\
12+
import asyncio
13+
14+
loop: asyncio.AbstractEventLoop = None
15+
"""
16+
),
17+
test_module_one=dedent(
18+
f"""\
19+
import asyncio
20+
import pytest
21+
22+
from {package_name} import shared_module
23+
24+
@pytest.mark.asyncio(scope="session")
25+
async def test_remember_loop():
26+
shared_module.loop = asyncio.get_running_loop()
27+
"""
28+
),
29+
test_module_two=dedent(
30+
f"""\
31+
import asyncio
32+
import pytest
33+
34+
from {package_name} import shared_module
35+
36+
pytestmark = pytest.mark.asyncio(scope="session")
37+
38+
async def test_this_runs_in_same_loop():
39+
assert asyncio.get_running_loop() is shared_module.loop
40+
41+
class TestClassA:
42+
async def test_this_runs_in_same_loop(self):
43+
assert asyncio.get_running_loop() is shared_module.loop
44+
"""
45+
),
46+
)
47+
subpackage_name = "subpkg"
48+
subpkg = pytester.mkpydir(subpackage_name)
49+
subpkg.joinpath("test_subpkg.py").write_text(
50+
dedent(
51+
f"""\
52+
import asyncio
53+
import pytest
54+
55+
from {package_name} import shared_module
56+
57+
pytestmark = pytest.mark.asyncio(scope="session")
58+
59+
async def test_subpackage_runs_in_same_loop():
60+
assert asyncio.get_running_loop() is shared_module.loop
61+
"""
62+
)
63+
)
64+
result = pytester.runpytest("--asyncio-mode=strict")
65+
result.assert_outcomes(passed=4)
66+
67+
68+
def test_raise_when_event_loop_fixture_is_requested_in_addition_to_scoped_loop(
69+
pytester: Pytester,
70+
):
71+
pytester.makepyfile(
72+
__init__="",
73+
test_raises=dedent(
74+
"""\
75+
import asyncio
76+
import pytest
77+
78+
@pytest.mark.asyncio(scope="session")
79+
async def test_remember_loop(event_loop):
80+
pass
81+
"""
82+
),
83+
)
84+
result = pytester.runpytest("--asyncio-mode=strict")
85+
result.assert_outcomes(errors=1)
86+
result.stdout.fnmatch_lines("*MultipleEventLoopsRequestedError: *")
87+
88+
89+
def test_asyncio_mark_respects_the_loop_policy(
90+
pytester: Pytester,
91+
):
92+
pytester.makepyfile(
93+
__init__="",
94+
conftest=dedent(
95+
"""\
96+
import pytest
97+
98+
from .custom_policy import CustomEventLoopPolicy
99+
100+
@pytest.fixture(scope="session")
101+
def event_loop_policy():
102+
return CustomEventLoopPolicy()
103+
"""
104+
),
105+
custom_policy=dedent(
106+
"""\
107+
import asyncio
108+
109+
class CustomEventLoopPolicy(asyncio.DefaultEventLoopPolicy):
110+
pass
111+
"""
112+
),
113+
test_uses_custom_policy=dedent(
114+
"""\
115+
import asyncio
116+
import pytest
117+
118+
from .custom_policy import CustomEventLoopPolicy
119+
120+
pytestmark = pytest.mark.asyncio(scope="session")
121+
122+
async def test_uses_custom_event_loop_policy():
123+
assert isinstance(
124+
asyncio.get_event_loop_policy(),
125+
CustomEventLoopPolicy,
126+
)
127+
"""
128+
),
129+
test_also_uses_custom_policy=dedent(
130+
"""\
131+
import asyncio
132+
import pytest
133+
134+
from .custom_policy import CustomEventLoopPolicy
135+
136+
pytestmark = pytest.mark.asyncio(scope="session")
137+
138+
async def test_also_uses_custom_event_loop_policy():
139+
assert isinstance(
140+
asyncio.get_event_loop_policy(),
141+
CustomEventLoopPolicy,
142+
)
143+
"""
144+
),
145+
)
146+
result = pytester.runpytest("--asyncio-mode=strict")
147+
result.assert_outcomes(passed=2)
148+
149+
150+
def test_asyncio_mark_respects_parametrized_loop_policies(
151+
pytester: Pytester,
152+
):
153+
pytester.makepyfile(
154+
__init__="",
155+
test_parametrization=dedent(
156+
"""\
157+
import asyncio
158+
159+
import pytest
160+
161+
pytestmark = pytest.mark.asyncio(scope="session")
162+
163+
@pytest.fixture(
164+
scope="session",
165+
params=[
166+
asyncio.DefaultEventLoopPolicy(),
167+
asyncio.DefaultEventLoopPolicy(),
168+
],
169+
)
170+
def event_loop_policy(request):
171+
return request.param
172+
173+
async def test_parametrized_loop():
174+
pass
175+
"""
176+
),
177+
)
178+
result = pytester.runpytest_subprocess("--asyncio-mode=strict")
179+
result.assert_outcomes(passed=2)
180+
181+
182+
def test_asyncio_mark_provides_session_scoped_loop_to_fixtures(
183+
pytester: Pytester,
184+
):
185+
package_name = pytester.path.name
186+
pytester.makepyfile(
187+
__init__="",
188+
conftest=dedent(
189+
f"""\
190+
import asyncio
191+
192+
import pytest_asyncio
193+
194+
from {package_name} import shared_module
195+
196+
@pytest_asyncio.fixture(scope="session")
197+
async def my_fixture():
198+
shared_module.loop = asyncio.get_running_loop()
199+
"""
200+
),
201+
shared_module=dedent(
202+
"""\
203+
import asyncio
204+
205+
loop: asyncio.AbstractEventLoop = None
206+
"""
207+
),
208+
)
209+
subpackage_name = "subpkg"
210+
subpkg = pytester.mkpydir(subpackage_name)
211+
subpkg.joinpath("test_subpkg.py").write_text(
212+
dedent(
213+
f"""\
214+
import asyncio
215+
216+
import pytest
217+
import pytest_asyncio
218+
219+
from {package_name} import shared_module
220+
221+
pytestmark = pytest.mark.asyncio(scope="session")
222+
223+
async def test_runs_in_same_loop_as_fixture(my_fixture):
224+
assert asyncio.get_running_loop() is shared_module.loop
225+
"""
226+
)
227+
)
228+
result = pytester.runpytest_subprocess("--asyncio-mode=strict")
229+
result.assert_outcomes(passed=1)

0 commit comments

Comments
 (0)