Skip to content

Commit 7c04350

Browse files
committed
[feat] Add support for package-scoped loops.
Signed-off-by: Michael Seifert <[email protected]>
1 parent a7b1c39 commit 7c04350

File tree

4 files changed

+261
-8
lines changed

4 files changed

+261
-8
lines changed

docs/source/reference/changelog.rst

+1-2
Original file line numberDiff line numberDiff line change
@@ -7,8 +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. Class-scoped and module-scoped event loops can be requested
11-
via the *scope* keyword argument to the _asyncio_ mark.
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.
1211
- 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>`_
1312
- Removes pytest-trio from the test dependencies `#620 <https://github.com/pytest-dev/pytest-asyncio/pull/620>`_
1413

docs/source/reference/markers/index.rst

+8-4
Original file line numberDiff line numberDiff line change
@@ -19,19 +19,23 @@ The ``pytest.mark.asyncio`` marker can be omitted entirely in *auto* mode, where
1919

2020
By default, each test runs in it's own asyncio event loop.
2121
Multiple tests can share the same event loop by providing a *scope* keyword argument to the *asyncio* mark.
22+
The supported scopes are *class,* and *module,* and *package*.
2223
The following code example provides a shared event loop for all tests in `TestClassScopedLoop`:
2324

2425
.. include:: class_scoped_loop_strict_mode_example.py
2526
:code: python
2627

27-
Requesting class scope for tests that are not part of a class will give a *UsageError.*
28-
Similar to class-scoped event loops, a module-scoped loop is provided when setting the asyncio mark's scope to *module:*
28+
Requesting class scope with the test being part of a class will give a *UsageError*.
29+
Similar to class-scoped event loops, a module-scoped loop is provided when setting mark's scope to *module:*
2930

3031
.. include:: module_scoped_loop_strict_mode_example.py
3132
:code: python
3233

33-
Requesting class scope with the test being part of a class will give a *UsageError*.
34-
The supported scopes are *class*, and *module.*
34+
Package-scoped loops only work with `regular Python packages. <https://docs.python.org/3/glossary.html#term-regular-package>`__
35+
That means they require an *__init__.py* to be present.
36+
Package-scoped loops do not work in `namespace packages. <https://docs.python.org/3/glossary.html#term-namespace-package>`__
37+
Subpackages do not share the loop with their parent package.
38+
3539

3640
.. |pytestmark| replace:: ``pytestmark``
3741
.. _pytestmark: http://doc.pytest.org/en/latest/example/markers.html#marking-whole-classes-or-modules

pytest_asyncio/plugin.py

+27-2
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import functools
66
import inspect
77
import socket
8+
import sys
89
import warnings
910
from asyncio import AbstractEventLoopPolicy
1011
from textwrap import dedent
@@ -35,6 +36,7 @@
3536
Item,
3637
Metafunc,
3738
Module,
39+
Package,
3840
Parser,
3941
PytestCollectionWarning,
4042
PytestDeprecationWarning,
@@ -539,11 +541,16 @@ def pytest_pycollect_makeitem_convert_async_functions_to_subclass(
539541

540542

541543
_event_loop_fixture_id = StashKey[str]
544+
_fixture_scope_by_collector_type = {
545+
Class: "class",
546+
Module: "module",
547+
Package: "package",
548+
}
542549

543550

544551
@pytest.hookimpl
545552
def pytest_collectstart(collector: pytest.Collector):
546-
if not isinstance(collector, (pytest.Class, pytest.Module)):
553+
if not isinstance(collector, (Class, Module, Package)):
547554
return
548555
# There seem to be issues when a fixture is shadowed by another fixture
549556
# and both differ in their params.
@@ -556,7 +563,7 @@ def pytest_collectstart(collector: pytest.Collector):
556563
collector.stash[_event_loop_fixture_id] = event_loop_fixture_id
557564

558565
@pytest.fixture(
559-
scope="class" if isinstance(collector, pytest.Class) else "module",
566+
scope=_fixture_scope_by_collector_type[type(collector)],
560567
name=event_loop_fixture_id,
561568
)
562569
def scoped_event_loop(
@@ -579,6 +586,23 @@ def scoped_event_loop(
579586
# collected Python class, where it will be picked up by pytest.Class.collect()
580587
# or pytest.Module.collect(), respectively
581588
collector.obj.__pytest_asyncio_scoped_event_loop = scoped_event_loop
589+
# When collector is a package, collector.obj is the package's __init__.py.
590+
# pytest doesn't seem to collect fixtures in __init__.py.
591+
# Using parsefactories to collect fixtures in __init__.py their baseid will end
592+
# with "__init__.py", thus limiting the scope of the fixture to the init module.
593+
# Therefore, we tell the pluginmanager explicitly to collect the fixtures
594+
# in the init module, but strip "__init__.py" from the baseid
595+
# Possibly related to https://github.com/pytest-dev/pytest/issues/4085
596+
if isinstance(collector, Package):
597+
fixturemanager = collector.config.pluginmanager.get_plugin("funcmanage")
598+
package_node_id = _removesuffix(collector.nodeid, "__init__.py")
599+
fixturemanager.parsefactories(collector.obj, nodeid=package_node_id)
600+
601+
602+
def _removesuffix(s: str, suffix: str) -> str:
603+
if sys.version_info < (3, 9):
604+
return s[: -len(suffix)]
605+
return s.removesuffix(suffix)
582606

583607

584608
def pytest_collection_modifyitems(
@@ -867,6 +891,7 @@ def _retrieve_scope_root(item: Union[Collector, Item], scope: str) -> Collector:
867891
node_type_by_scope = {
868892
"class": Class,
869893
"module": Module,
894+
"package": Package,
870895
}
871896
scope_root_type = node_type_by_scope[scope]
872897
for node in reversed(item.listchain()):

tests/markers/test_package_scope.py

+225
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
from textwrap import dedent
2+
3+
from pytest import Pytester
4+
5+
6+
def test_asyncio_mark_provides_package_scoped_loop_strict_mode(pytester: Pytester):
7+
package_name = pytester.path.name
8+
subpackage_name = "subpkg"
9+
pytester.makepyfile(
10+
__init__="",
11+
shared_module=dedent(
12+
"""\
13+
import asyncio
14+
15+
loop: asyncio.AbstractEventLoop = None
16+
"""
17+
),
18+
test_module_one=dedent(
19+
f"""\
20+
import asyncio
21+
import pytest
22+
23+
from {package_name} import shared_module
24+
25+
@pytest.mark.asyncio(scope="package")
26+
async def test_remember_loop():
27+
shared_module.loop = asyncio.get_running_loop()
28+
"""
29+
),
30+
test_module_two=dedent(
31+
f"""\
32+
import asyncio
33+
import pytest
34+
35+
from {package_name} import shared_module
36+
37+
pytestmark = pytest.mark.asyncio(scope="package")
38+
39+
async def test_this_runs_in_same_loop():
40+
assert asyncio.get_running_loop() is shared_module.loop
41+
42+
class TestClassA:
43+
async def test_this_runs_in_same_loop(self):
44+
assert asyncio.get_running_loop() is shared_module.loop
45+
"""
46+
),
47+
)
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="package")
58+
59+
async def test_subpackage_runs_in_different_loop():
60+
assert asyncio.get_running_loop() is not 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="package")
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="package")
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="package")
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="package")
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="package")
162+
163+
@pytest.fixture(
164+
scope="package",
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_package_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="package")
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+
test_fixture_runs_in_scoped_loop=dedent(
209+
f"""\
210+
import asyncio
211+
212+
import pytest
213+
import pytest_asyncio
214+
215+
from {package_name} import shared_module
216+
217+
pytestmark = pytest.mark.asyncio(scope="package")
218+
219+
async def test_runs_in_same_loop_as_fixture(my_fixture):
220+
assert asyncio.get_running_loop() is shared_module.loop
221+
"""
222+
),
223+
)
224+
result = pytester.runpytest_subprocess("--asyncio-mode=strict")
225+
result.assert_outcomes(passed=1)

0 commit comments

Comments
 (0)