Skip to content

Commit

Permalink
[feat] Add support for package-scoped loops.
Browse files Browse the repository at this point in the history
Signed-off-by: Michael Seifert <m.seifert@digitalernachschub.de>
  • Loading branch information
seifertm committed Nov 5, 2023
1 parent a257c55 commit 683ce86
Show file tree
Hide file tree
Showing 4 changed files with 226 additions and 8 deletions.
3 changes: 1 addition & 2 deletions docs/source/reference/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ Changelog
This release is backwards-compatible with v0.21.
Changes are non-breaking, unless you upgrade from v0.22.

BREAKING: The *asyncio_event_loop* mark has been removed. Class-scoped and module-scoped event loops can be requested
via the *scope* keyword argument to the _asyncio_ mark.
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.

0.22.0 (2023-10-31)
===================
Expand Down
11 changes: 7 additions & 4 deletions docs/source/reference/markers/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,17 @@ The following code example provides a shared event loop for all tests in `TestCl
.. include:: class_scoped_loop_strict_mode_example.py
:code: python


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

.. include:: module_scoped_loop_strict_mode_example.py
:code: python

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


.. |pytestmark| replace:: ``pytestmark``
.. _pytestmark: http://doc.pytest.org/en/latest/example/markers.html#marking-whole-classes-or-modules
29 changes: 27 additions & 2 deletions pytest_asyncio/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
Item,
Metafunc,
Module,
Package,
Parser,
PytestCollectionWarning,
PytestDeprecationWarning,
Expand Down Expand Up @@ -545,11 +546,16 @@ def pytest_pycollect_makeitem_convert_async_functions_to_subclass(


_event_loop_fixture_id = StashKey[str]
_fixture_scope_by_collector_type = {
Class: "class",
Module: "module",
Package: "package",
}


@pytest.hookimpl
def pytest_collectstart(collector: pytest.Collector):
if not isinstance(collector, (pytest.Class, pytest.Module)):
if not isinstance(collector, (Class, Module, Package)):
return
# There seem to be issues when a fixture is shadowed by another fixture
# and both differ in their params.
Expand All @@ -560,9 +566,17 @@ def pytest_collectstart(collector: pytest.Collector):
# be injected when setting up the test
event_loop_fixture_id = f"{collector.nodeid}::<event_loop>"
collector.stash[_event_loop_fixture_id] = event_loop_fixture_id
if isinstance(collector, Package):
print(collector)
print(collector.obj)
print(event_loop_fixture_id)
elif isinstance(collector, Module):
print(collector)
print(collector.obj)
print(event_loop_fixture_id)

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


def pytest_collection_modifyitems(
Expand Down Expand Up @@ -648,6 +669,9 @@ def pytest_generate_tests(metafunc: Metafunc) -> None:
# Add the scoped event loop fixture to Metafunc's list of fixture names and
# fixturedefs and leave the actual parametrization to pytest
metafunc.fixturenames.insert(0, event_loop_fixture_id)
print(metafunc.definition)
print(metafunc.fixturenames)
print(fixturemanager._arg2fixturedefs)
metafunc._arg2fixturedefs[
event_loop_fixture_id
] = fixturemanager._arg2fixturedefs[event_loop_fixture_id]
Expand Down Expand Up @@ -850,6 +874,7 @@ def _retrieve_scope_root(item: Union[Collector, Item], scope: str) -> Collector:
node_type_by_scope = {
"class": Class,
"module": Module,
"package": Package,
}
scope_root_type = node_type_by_scope[scope]
for node in reversed(item.listchain()):
Expand Down
191 changes: 191 additions & 0 deletions tests/markers/test_package_scope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
from textwrap import dedent

from pytest import Pytester


def test_asyncio_mark_provides_package_scoped_loop_strict_mode(pytester: Pytester):
package_name = pytester.path.name
pytester.makepyfile(
__init__="",
shared_module=dedent(
"""\
import asyncio
loop: asyncio.AbstractEventLoop = None
"""
),
test_module_one=dedent(
f"""\
import asyncio
import pytest
from {package_name} import shared_module
@pytest.mark.asyncio(scope="package")
async def test_remember_loop():
shared_module.loop = asyncio.get_running_loop()
"""
),
test_module_two=dedent(
f"""\
import asyncio
import pytest
from {package_name} import shared_module
pytestmark = pytest.mark.asyncio(scope="package")
async def test_this_runs_in_same_loop():
assert asyncio.get_running_loop() is shared_module.loop
class TestClassA:
async def test_this_runs_in_same_loop(self):
assert asyncio.get_running_loop() is shared_module.loop
"""
),
)
result = pytester.runpytest("--asyncio-mode=strict")
result.assert_outcomes(passed=3)


def test_raise_when_event_loop_fixture_is_requested_in_addition_to_scoped_loop(
pytester: Pytester,
):
pytester.makepyfile(
__init__="",
test_raises=dedent(
"""\
import asyncio
import pytest
@pytest.mark.asyncio(scope="package")
async def test_remember_loop(event_loop):
pass
"""
),
)
result = pytester.runpytest("--asyncio-mode=strict")
result.assert_outcomes(errors=1)
result.stdout.fnmatch_lines("*MultipleEventLoopsRequestedError: *")


def test_asyncio_mark_respects_the_loop_policy(
pytester: Pytester,
):
pytester.makepyfile(
__init__="",
conftest=dedent(
"""\
import pytest
from .custom_policy import CustomEventLoopPolicy
@pytest.fixture(scope="package")
def event_loop_policy():
return CustomEventLoopPolicy()
"""
),
custom_policy=dedent(
"""\
import asyncio
class CustomEventLoopPolicy(asyncio.DefaultEventLoopPolicy):
pass
"""
),
test_uses_custom_policy=dedent(
"""\
import asyncio
import pytest
from .custom_policy import CustomEventLoopPolicy
pytestmark = pytest.mark.asyncio(scope="package")
async def test_uses_custom_event_loop_policy():
assert isinstance(
asyncio.get_event_loop_policy(),
CustomEventLoopPolicy,
)
"""
),
test_also_uses_custom_policy=dedent(
"""\
import asyncio
import pytest
from .custom_policy import CustomEventLoopPolicy
pytestmark = pytest.mark.asyncio(scope="package")
async def test_also_uses_custom_event_loop_policy():
assert isinstance(
asyncio.get_event_loop_policy(),
CustomEventLoopPolicy,
)
"""
),
)
result = pytester.runpytest("--asyncio-mode=strict")
result.assert_outcomes(passed=2)


def test_asyncio_mark_respects_parametrized_loop_policies(
pytester: Pytester,
):
pytester.makepyfile(
dedent(
"""\
import asyncio
import pytest
pytestmark = pytest.mark.asyncio(scope="module")
@pytest.fixture(
scope="module",
params=[
asyncio.DefaultEventLoopPolicy(),
asyncio.DefaultEventLoopPolicy(),
],
)
def event_loop_policy(request):
return request.param
async def test_parametrized_loop():
pass
"""
)
)
result = pytester.runpytest_subprocess("--asyncio-mode=strict")
result.assert_outcomes(passed=2)


def test_asyncio_mark_provides_module_scoped_loop_to_fixtures(
pytester: Pytester,
):
pytester.makepyfile(
dedent(
"""\
import asyncio
import pytest
import pytest_asyncio
pytestmark = pytest.mark.asyncio(scope="module")
loop: asyncio.AbstractEventLoop
@pytest_asyncio.fixture(scope="module")
async def my_fixture():
global loop
loop = asyncio.get_running_loop()
async def test_runs_is_same_loop_as_fixture(my_fixture):
global loop
assert asyncio.get_running_loop() is loop
"""
)
)
result = pytester.runpytest_subprocess("--asyncio-mode=strict")
result.assert_outcomes(passed=1)

0 comments on commit 683ce86

Please sign in to comment.