Skip to content

Commit

Permalink
[feat] Add support for session-scoped event loops.
Browse files Browse the repository at this point in the history
Signed-off-by: Michael Seifert <[email protected]>
  • Loading branch information
seifertm committed Nov 12, 2023
1 parent d8650de commit dead6b7
Show file tree
Hide file tree
Showing 4 changed files with 258 additions and 1 deletion.
2 changes: 1 addition & 1 deletion docs/source/reference/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +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. Event loops with class, module and package scope 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, package, and session scopes can be requested via the *scope* keyword argument to the _asyncio_ mark.
- 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>`_
- Removes pytest-trio from the test dependencies `#620 <https://github.com/pytest-dev/pytest-asyncio/pull/620>`_

Expand Down
1 change: 1 addition & 0 deletions docs/source/reference/markers/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ 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>`__
Subpackages do not share the loop with their parent package.

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

.. |auto mode| replace:: *auto mode*
.. _auto mode: ../../concepts.html#auto-mode
Expand Down
27 changes: 27 additions & 0 deletions pytest_asyncio/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -545,11 +545,21 @@ def pytest_pycollect_makeitem_convert_async_functions_to_subclass(
Class: "class",
Module: "module",
Package: "package",
Session: "session",
}


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


@pytest.fixture(scope="session")
def _session_event_loop(
request: FixtureRequest, event_loop_policy: AbstractEventLoopPolicy
) -> Iterator[asyncio.AbstractEventLoop]:
new_loop_policy = event_loop_policy
old_loop_policy = asyncio.get_event_loop_policy()
old_loop = asyncio.get_event_loop()
asyncio.set_event_loop_policy(new_loop_policy)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
yield loop
loop.close()
asyncio.set_event_loop_policy(old_loop_policy)
asyncio.set_event_loop(old_loop)


@pytest.fixture(scope="session", autouse=True)
def event_loop_policy() -> AbstractEventLoopPolicy:
"""Return an instance of the policy used to create asyncio event loops."""
Expand Down
229 changes: 229 additions & 0 deletions tests/markers/test_session_scope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
from textwrap import dedent

from pytest import Pytester


def test_asyncio_mark_provides_session_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="session")
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="session")
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
"""
),
)
subpackage_name = "subpkg"
subpkg = pytester.mkpydir(subpackage_name)
subpkg.joinpath("test_subpkg.py").write_text(
dedent(
f"""\
import asyncio
import pytest
from {package_name} import shared_module
pytestmark = pytest.mark.asyncio(scope="session")
async def test_subpackage_runs_in_same_loop():
assert asyncio.get_running_loop() is shared_module.loop
"""
)
)
result = pytester.runpytest("--asyncio-mode=strict")
result.assert_outcomes(passed=4)


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="session")
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="session")
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="session")
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="session")
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(
__init__="",
test_parametrization=dedent(
"""\
import asyncio
import pytest
pytestmark = pytest.mark.asyncio(scope="session")
@pytest.fixture(
scope="session",
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_session_scoped_loop_to_fixtures(
pytester: Pytester,
):
package_name = pytester.path.name
pytester.makepyfile(
__init__="",
conftest=dedent(
f"""\
import asyncio
import pytest_asyncio
from {package_name} import shared_module
@pytest_asyncio.fixture(scope="session")
async def my_fixture():
shared_module.loop = asyncio.get_running_loop()
"""
),
shared_module=dedent(
"""\
import asyncio
loop: asyncio.AbstractEventLoop = None
"""
),
)
subpackage_name = "subpkg"
subpkg = pytester.mkpydir(subpackage_name)
subpkg.joinpath("test_subpkg.py").write_text(
dedent(
f"""\
import asyncio
import pytest
import pytest_asyncio
from {package_name} import shared_module
pytestmark = pytest.mark.asyncio(scope="session")
async def test_runs_in_same_loop_as_fixture(my_fixture):
assert asyncio.get_running_loop() is shared_module.loop
"""
)
)
result = pytester.runpytest_subprocess("--asyncio-mode=strict")
result.assert_outcomes(passed=1)

0 comments on commit dead6b7

Please sign in to comment.