Skip to content

Commit 683ce86

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

File tree

4 files changed

+226
-8
lines changed

4 files changed

+226
-8
lines changed

docs/source/reference/changelog.rst

Lines changed: 1 addition & 2 deletions
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

1312
0.22.0 (2023-10-31)
1413
===================

docs/source/reference/markers/index.rst

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,17 @@ The following code example provides a shared event loop for all tests in `TestCl
2424
.. include:: class_scoped_loop_strict_mode_example.py
2525
:code: python
2626

27-
28-
Similarly, a module-scoped loop is provided when setting mark's scope to *module:*
27+
Requesting class scope with the test being part of a class will give a *UsageError*.
28+
Similar to class-scoped event loops, a module-scoped loop is provided when setting mark's scope to *module:*
2929

3030
.. include:: module_scoped_loop_strict_mode_example.py
3131
:code: python
3232

33-
Requesting class scope with the test being part of a class will give a *UsageError*.
34-
The supported scopes are *class*, and *module.*
33+
The supported scopes are *class*, and *module,* and *package*.
34+
Package-scope loops only work with tests `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+
3538

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

pytest_asyncio/plugin.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
Item,
3636
Metafunc,
3737
Module,
38+
Package,
3839
Parser,
3940
PytestCollectionWarning,
4041
PytestDeprecationWarning,
@@ -545,11 +546,16 @@ def pytest_pycollect_makeitem_convert_async_functions_to_subclass(
545546

546547

547548
_event_loop_fixture_id = StashKey[str]
549+
_fixture_scope_by_collector_type = {
550+
Class: "class",
551+
Module: "module",
552+
Package: "package",
553+
}
548554

549555

550556
@pytest.hookimpl
551557
def pytest_collectstart(collector: pytest.Collector):
552-
if not isinstance(collector, (pytest.Class, pytest.Module)):
558+
if not isinstance(collector, (Class, Module, Package)):
553559
return
554560
# There seem to be issues when a fixture is shadowed by another fixture
555561
# and both differ in their params.
@@ -560,9 +566,17 @@ def pytest_collectstart(collector: pytest.Collector):
560566
# be injected when setting up the test
561567
event_loop_fixture_id = f"{collector.nodeid}::<event_loop>"
562568
collector.stash[_event_loop_fixture_id] = event_loop_fixture_id
569+
if isinstance(collector, Package):
570+
print(collector)
571+
print(collector.obj)
572+
print(event_loop_fixture_id)
573+
elif isinstance(collector, Module):
574+
print(collector)
575+
print(collector.obj)
576+
print(event_loop_fixture_id)
563577

564578
@pytest.fixture(
565-
scope="class" if isinstance(collector, pytest.Class) else "module",
579+
scope=_fixture_scope_by_collector_type[type(collector)],
566580
name=event_loop_fixture_id,
567581
)
568582
def scoped_event_loop(
@@ -585,6 +599,13 @@ def scoped_event_loop(
585599
# collected Python class, where it will be picked up by pytest.Class.collect()
586600
# or pytest.Module.collect(), respectively
587601
collector.obj.__pytest_asyncio_scoped_event_loop = scoped_event_loop
602+
# When collector is a package, collector.obj is the package's __init__.py.
603+
# pytest doesn't seem to collect fixtures in __init__.py.
604+
# Therefore, we tell the pluginmanager to explicitly collect in __init__.py
605+
# Possibly related to https://github.com/pytest-dev/pytest/issues/4085
606+
if isinstance(collector, Package):
607+
fixturemanager = collector.config.pluginmanager.get_plugin("funcmanage")
608+
fixturemanager.parsefactories(collector)
588609

589610

590611
def pytest_collection_modifyitems(
@@ -648,6 +669,9 @@ def pytest_generate_tests(metafunc: Metafunc) -> None:
648669
# Add the scoped event loop fixture to Metafunc's list of fixture names and
649670
# fixturedefs and leave the actual parametrization to pytest
650671
metafunc.fixturenames.insert(0, event_loop_fixture_id)
672+
print(metafunc.definition)
673+
print(metafunc.fixturenames)
674+
print(fixturemanager._arg2fixturedefs)
651675
metafunc._arg2fixturedefs[
652676
event_loop_fixture_id
653677
] = fixturemanager._arg2fixturedefs[event_loop_fixture_id]
@@ -850,6 +874,7 @@ def _retrieve_scope_root(item: Union[Collector, Item], scope: str) -> Collector:
850874
node_type_by_scope = {
851875
"class": Class,
852876
"module": Module,
877+
"package": Package,
853878
}
854879
scope_root_type = node_type_by_scope[scope]
855880
for node in reversed(item.listchain()):

tests/markers/test_package_scope.py

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
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+
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="package")
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="package")
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+
result = pytester.runpytest("--asyncio-mode=strict")
48+
result.assert_outcomes(passed=3)
49+
50+
51+
def test_raise_when_event_loop_fixture_is_requested_in_addition_to_scoped_loop(
52+
pytester: Pytester,
53+
):
54+
pytester.makepyfile(
55+
__init__="",
56+
test_raises=dedent(
57+
"""\
58+
import asyncio
59+
import pytest
60+
61+
@pytest.mark.asyncio(scope="package")
62+
async def test_remember_loop(event_loop):
63+
pass
64+
"""
65+
),
66+
)
67+
result = pytester.runpytest("--asyncio-mode=strict")
68+
result.assert_outcomes(errors=1)
69+
result.stdout.fnmatch_lines("*MultipleEventLoopsRequestedError: *")
70+
71+
72+
def test_asyncio_mark_respects_the_loop_policy(
73+
pytester: Pytester,
74+
):
75+
pytester.makepyfile(
76+
__init__="",
77+
conftest=dedent(
78+
"""\
79+
import pytest
80+
81+
from .custom_policy import CustomEventLoopPolicy
82+
83+
@pytest.fixture(scope="package")
84+
def event_loop_policy():
85+
return CustomEventLoopPolicy()
86+
"""
87+
),
88+
custom_policy=dedent(
89+
"""\
90+
import asyncio
91+
92+
class CustomEventLoopPolicy(asyncio.DefaultEventLoopPolicy):
93+
pass
94+
"""
95+
),
96+
test_uses_custom_policy=dedent(
97+
"""\
98+
import asyncio
99+
import pytest
100+
101+
from .custom_policy import CustomEventLoopPolicy
102+
103+
pytestmark = pytest.mark.asyncio(scope="package")
104+
105+
async def test_uses_custom_event_loop_policy():
106+
assert isinstance(
107+
asyncio.get_event_loop_policy(),
108+
CustomEventLoopPolicy,
109+
)
110+
"""
111+
),
112+
test_also_uses_custom_policy=dedent(
113+
"""\
114+
import asyncio
115+
import pytest
116+
117+
from .custom_policy import CustomEventLoopPolicy
118+
119+
pytestmark = pytest.mark.asyncio(scope="package")
120+
121+
async def test_also_uses_custom_event_loop_policy():
122+
assert isinstance(
123+
asyncio.get_event_loop_policy(),
124+
CustomEventLoopPolicy,
125+
)
126+
"""
127+
),
128+
)
129+
result = pytester.runpytest("--asyncio-mode=strict")
130+
result.assert_outcomes(passed=2)
131+
132+
133+
def test_asyncio_mark_respects_parametrized_loop_policies(
134+
pytester: Pytester,
135+
):
136+
pytester.makepyfile(
137+
dedent(
138+
"""\
139+
import asyncio
140+
141+
import pytest
142+
143+
pytestmark = pytest.mark.asyncio(scope="module")
144+
145+
@pytest.fixture(
146+
scope="module",
147+
params=[
148+
asyncio.DefaultEventLoopPolicy(),
149+
asyncio.DefaultEventLoopPolicy(),
150+
],
151+
)
152+
def event_loop_policy(request):
153+
return request.param
154+
155+
async def test_parametrized_loop():
156+
pass
157+
"""
158+
)
159+
)
160+
result = pytester.runpytest_subprocess("--asyncio-mode=strict")
161+
result.assert_outcomes(passed=2)
162+
163+
164+
def test_asyncio_mark_provides_module_scoped_loop_to_fixtures(
165+
pytester: Pytester,
166+
):
167+
pytester.makepyfile(
168+
dedent(
169+
"""\
170+
import asyncio
171+
172+
import pytest
173+
import pytest_asyncio
174+
175+
pytestmark = pytest.mark.asyncio(scope="module")
176+
177+
loop: asyncio.AbstractEventLoop
178+
179+
@pytest_asyncio.fixture(scope="module")
180+
async def my_fixture():
181+
global loop
182+
loop = asyncio.get_running_loop()
183+
184+
async def test_runs_is_same_loop_as_fixture(my_fixture):
185+
global loop
186+
assert asyncio.get_running_loop() is loop
187+
"""
188+
)
189+
)
190+
result = pytester.runpytest_subprocess("--asyncio-mode=strict")
191+
result.assert_outcomes(passed=1)

0 commit comments

Comments
 (0)