Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/796.fixed.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Prevent parametrized ``event_loop_policy`` fixtures from parametrizing synchronous tests.
81 changes: 78 additions & 3 deletions pytest_asyncio/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -723,20 +723,59 @@ def pytest_pycollect_makeitem_convert_async_functions_to_subclass(
hook_result.force_result(updated_node_collection)


@pytest.hookimpl(tryfirst=True)
def pytest_collection_modifyitems(items: list[pytest.Item]) -> None:
for item in items:
if _item_needs_event_loop_policy(item):
_append_fixture_name(item, "event_loop_policy")


def _item_needs_event_loop_policy(item: pytest.Item) -> bool:
if not isinstance(item, Function):
return False
if isinstance(item, PytestAsyncioFunction) and _resolve_asyncio_marker(item):
return True
return _item_uses_asyncio_fixtures(item)


def _item_uses_asyncio_fixtures(item: pytest.Item) -> bool:
fixtureinfo = getattr(item, "_fixtureinfo", None)
if fixtureinfo is None:
return False
return _fixture_defs_use_asyncio(
item.config,
fixtureinfo.name2fixturedefs.values(),
)


def _append_fixture_name(item: pytest.Item, fixture_name: str) -> None:
if fixture_name not in item.fixturenames:
item.fixturenames.append(fixture_name)


@pytest.hookimpl(tryfirst=True)
def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
specialized_item_class = PytestAsyncioFunction.item_subclass_for(
metafunc.definition
)
if specialized_item_class is None:
if _uses_asyncio_fixtures(metafunc):
_add_fixture_to_metafunc(metafunc, "event_loop_policy")
return

asyncio_marker = _resolve_asyncio_marker(metafunc.definition)
if asyncio_marker is None:
if _uses_asyncio_fixtures(metafunc):
_add_fixture_to_metafunc(metafunc, "event_loop_policy")
return
marker_loop_scope, marker_selected_factory_names = _parse_asyncio_marker(
asyncio_marker
)
default_loop_scope = _get_default_test_loop_scope(metafunc.config)
loop_scope = marker_loop_scope or default_loop_scope
runner_fixture_id = f"_{loop_scope}_scoped_runner"
_add_fixture_to_metafunc(metafunc, runner_fixture_id)
_add_fixture_to_metafunc(metafunc, "event_loop_policy")

hook_factories = _collect_hook_loop_factories(metafunc.config, metafunc.definition)
if hook_factories is None:
Expand Down Expand Up @@ -774,8 +813,6 @@ def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
for name in marker_selected_factory_names
]
metafunc.fixturenames.append(_asyncio_loop_factory.__name__)
default_loop_scope = _get_default_test_loop_scope(metafunc.config)
loop_scope = marker_loop_scope or default_loop_scope
# pytest.HIDDEN_PARAM was added in pytest 8.4
hide_id = len(factory_ids) == 1 and hasattr(pytest, "HIDDEN_PARAM")
metafunc.parametrize(
Expand All @@ -787,6 +824,44 @@ def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
)


def _add_fixture_to_metafunc(metafunc: pytest.Metafunc, fixture_name: str) -> None:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this changes pytest's internals and breaks valid usage. For example, on main, this passes.

$ cat >mre.py <<'# EOF'
import asyncio
import pytest


@pytest.fixture(params=[asyncio.DefaultEventLoopPolicy(), asyncio.DefaultEventLoopPolicy()])
def policy(request):
    return request.param


@pytest.fixture
def event_loop_policy(policy):
    return policy


@pytest.mark.asyncio
async def test_async():
    pass


async def test_sync():
    pass
# EOF

$ pytest mre.py
==================================================== test session starts ====================================================
platform darwin -- Python 3.13.0, pytest-9.0.3, pluggy-1.6.0
rootdir: /Users/tjkuson/workspace/pytest-asyncio
configfile: pyproject.toml
plugins: asyncio-1.4.0a3.dev24
asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=function, asyncio_default_test_loop_scope=function
collected 2 items

mre.py EE                                                                                                             [100%]

========================================================== ERRORS ===========================================================
_______________________________________________ ERROR at setup of test_async ________________________________________________
The requested fixture has no parameter defined for test:
    mre.py::test_async

Requested fixture 'policy' defined in:
mre.py:6

Requested here:
.venv/lib/python3.13/site-packages/_pytest/fixtures.py:627
________________________________________________ ERROR at setup of test_sync ________________________________________________
The requested fixture has no parameter defined for test:
    mre.py::test_sync

Requested fixture 'policy' defined in:
mre.py:6

Requested here:
.venv/lib/python3.13/site-packages/_pytest/fixtures.py:627
===================================================== 2 errors in 0.02s =====================================================

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tjkuson Have your concerns been addressed by the latest commit? If not, can you elaborate?

This comment was marked as low quality.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@seifertm The specific issue is addressed, but I still feel a bit concerned about the intervention in pytest's internals and possible breakage for users. For instance, event_loop_policy injected late, meaning users can no longer do this:

def pytest_collection_modifyitems(items):
    for item in items:
        if "event_loop_policy" in item.fixturenames:
            item.add_marker(pytest.mark.uses_loop_policy)

It's admittedly low-level and I doubt many users are doing things like this, but it's still IMO valid pytest usage of a documented and public API. I don't have any ideas at the moment about how to resolve this.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this is a valid compatibility concern. I pushed 2bdec76 to keep the injected event_loop_policy name visible before user pytest_collection_modifyitems hooks run, while still avoiding the old autouse behavior for plain sync tests.

I added test_injected_loop_policy_is_visible_during_collection_modifyitems, which mirrors the hook pattern from your example: marked async items get the marker via item.fixturenames, and the plain sync test does not.

Local verification before pushing:

  • UV_CACHE_DIR=/private/tmp/pytest_asyncio_1454_uv_cache uv run --extra testing python -m pytest tests/markers/test_function_scope.py::test_injected_loop_policy_is_visible_during_collection_modifyitems -q -> 1 passed
  • UV_CACHE_DIR=/private/tmp/pytest_asyncio_1454_uv_cache uv run --extra testing python -m pytest tests/markers/test_function_scope.py -q -> 17 passed
  • UV_CACHE_DIR=/private/tmp/pytest_asyncio_1454_uv_cache uv run --extra testing python -m pytest tests/test_set_event_loop.py -q -> 61 passed
  • UV_CACHE_DIR=/private/tmp/pytest_asyncio_1454_uv_cache uv run --extra testing python -m pytest -q -> 304 passed
  • UV_CACHE_DIR=/private/tmp/pytest_asyncio_1454_uv_cache uvx ruff check pytest_asyncio/plugin.py tests/markers/test_function_scope.py -> All checks passed!
  • git diff --check

fixturemanager = metafunc.definition.session._fixturemanager
fixturenames_closure, arg2fixturedefs = fixturemanager.getfixtureclosure(
metafunc.definition,
(fixture_name,),
ignore_args=set(),
)
metafunc._arg2fixturedefs.update(arg2fixturedefs)
for name in fixturenames_closure:
if name not in metafunc.fixturenames:
metafunc.fixturenames.append(name)


def _uses_asyncio_fixtures(metafunc: pytest.Metafunc) -> bool:
return _fixture_defs_use_asyncio(
metafunc.config,
metafunc._arg2fixturedefs.values(),
)


def _fixture_defs_use_asyncio(
config: Config,
fixturedefs_by_name: Iterable[Sequence[FixtureDef[Any]]],
) -> bool:
asyncio_mode = _get_asyncio_mode(config)
for fixturedefs in fixturedefs_by_name:
if not fixturedefs:
continue
fixturedef = fixturedefs[-1]
fixture_func = fixturedef.func
if _is_asyncio_fixture_function(fixture_func):
return True
if asyncio_mode == Mode.AUTO and _is_coroutine_or_asyncgen(fixture_func):
return True

return False


@contextlib.contextmanager
def _temporary_event_loop(loop: AbstractEventLoop) -> Iterator[None]:
try:
Expand Down Expand Up @@ -1073,7 +1148,7 @@ def _asyncio_loop_factory(request: FixtureRequest) -> LoopFactory | None:
return getattr(request, "param", None)


@pytest.fixture(scope="session", autouse=True)
@pytest.fixture(scope="session")
def event_loop_policy() -> AbstractEventLoopPolicy:
"""Return an instance of the policy used to create asyncio event loops."""
return _get_event_loop_policy()
Expand Down
162 changes: 162 additions & 0 deletions tests/markers/test_function_scope.py
Comment thread
puneetdixit200 marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,168 @@ async def test_parametrized_loop():
result.assert_outcomes(passed=2)


def test_parametrized_loop_policy_does_not_parametrize_sync_tests(
pytester: Pytester,
):
pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function")
pytester.makepyfile(dedent("""\
import asyncio

import pytest

@pytest.fixture(
scope="session",
params=[
asyncio.get_event_loop_policy(),
asyncio.get_event_loop_policy(),
],
)
def event_loop_policy(request):
return request.param

@pytest.mark.asyncio
async def test_async():
pass

def test_sync():
pass
"""))
result = pytester.runpytest("--asyncio-mode=strict")
result.assert_outcomes(passed=3)


def test_parametrized_loop_policy_parametrizes_sync_tests_with_async_fixtures(
pytester: Pytester,
):
pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function")
pytester.makepyfile(dedent("""\
import asyncio

import pytest
import pytest_asyncio

@pytest.fixture(
scope="session",
params=[
asyncio.get_event_loop_policy(),
asyncio.get_event_loop_policy(),
],
)
def event_loop_policy(request):
return request.param

@pytest_asyncio.fixture
async def async_fixture():
pass

def test_sync_with_async_fixture(async_fixture):
pass
"""))
result = pytester.runpytest("--asyncio-mode=strict")
result.assert_outcomes(passed=2)


def test_parametrized_loop_policy_parametrizes_auto_mode_async_fixtures(
pytester: Pytester,
):
pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function")
pytester.makepyfile(dedent("""\
import asyncio

import pytest

@pytest.fixture(
scope="session",
params=[
asyncio.get_event_loop_policy(),
asyncio.get_event_loop_policy(),
],
)
def event_loop_policy(request):
return request.param

@pytest.fixture
async def async_fixture():
pass

def test_sync_with_auto_async_fixture(async_fixture):
pass
"""))
result = pytester.runpytest("--asyncio-mode=auto")
result.assert_outcomes(passed=2)


def test_parametrized_loop_policy_can_depend_on_parametrized_fixture(
pytester: Pytester,
):
pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function")
pytester.makepyfile(dedent("""\
import asyncio

import pytest

@pytest.fixture(params=["policy_a", "policy_b"])
def policy(request):
return request.param

@pytest.fixture
def event_loop_policy(policy):
return asyncio.get_event_loop_policy()

@pytest.mark.asyncio
async def test_marked_async():
pass

async def test_auto_async():
pass
"""))
result = pytester.runpytest("--asyncio-mode=auto")
result.assert_outcomes(passed=4)


def test_injected_loop_policy_is_visible_during_collection_modifyitems(
pytester: Pytester,
):
pytester.makeini(dedent("""\
[pytest]
asyncio_default_fixture_loop_scope = function
markers =
uses_loop_policy: item uses the event_loop_policy fixture
"""))
pytester.makeconftest(dedent("""\
import pytest

def pytest_collection_modifyitems(items):
for item in items:
if "event_loop_policy" in item.fixturenames:
item.add_marker(pytest.mark.uses_loop_policy)
"""))
pytester.makepyfile(dedent("""\
import asyncio

import pytest

@pytest.fixture(
scope="session",
params=[
asyncio.get_event_loop_policy(),
asyncio.get_event_loop_policy(),
],
)
def event_loop_policy(request):
return request.param

@pytest.mark.asyncio
async def test_async(request):
assert request.node.get_closest_marker("uses_loop_policy") is not None

def test_sync(request):
assert request.node.get_closest_marker("uses_loop_policy") is None
"""))
result = pytester.runpytest("--asyncio-mode=strict")
result.assert_outcomes(passed=3)


def test_event_loop_policy_fixture_override_emits_deprecation_warning(
pytester: Pytester,
):
Expand Down
2 changes: 0 additions & 2 deletions tests/test_set_event_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,6 @@ def test_asyncio_run_after_async_fixture_does_not_leak_loop(
import pytest
import pytest_asyncio

pytest_plugins = "pytest_asyncio"

@pytest_asyncio.fixture
async def async_fixture():
yield
Expand Down