Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rebind instance method fixtures to the same instance as the test #807

Merged
merged 7 commits into from
Oct 12, 2024
Merged
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
3 changes: 3 additions & 0 deletions docs/versionhistory.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ This library adheres to `Semantic Versioning 2.0 <http://semver.org/>`_.

- Fixed acquring a lock twice in the same task on asyncio hanging instead of raising a
``RuntimeError`` (`#798 <https://github.com/agronholm/anyio/issues/798>`_)
- Fixed an async fixture's ``self`` being different than the test's ``self`` in
class-based tests (`#633 <https://github.com/agronholm/anyio/issues/633>`_)
(PR by @agronholm and @graingert)

**4.6.0**

Expand Down
47 changes: 38 additions & 9 deletions src/anyio/pytest_plugin.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
from __future__ import annotations

import sys
from collections.abc import Iterator
from collections.abc import Generator, Iterator
from contextlib import ExitStack, contextmanager
from inspect import isasyncgenfunction, iscoroutinefunction
from inspect import isasyncgenfunction, iscoroutinefunction, ismethod
from typing import Any, cast

import pytest
import sniffio
from _pytest.fixtures import SubRequest
from _pytest.outcomes import Exit

from ._core._eventloop import get_all_backends, get_async_backend
Expand Down Expand Up @@ -70,28 +71,56 @@ def pytest_configure(config: Any) -> None:
)


def pytest_fixture_setup(fixturedef: Any, request: Any) -> None:
def wrapper(*args, anyio_backend, **kwargs): # type: ignore[no-untyped-def]
@pytest.hookimpl(hookwrapper=True)
def pytest_fixture_setup(fixturedef: Any, request: Any) -> Generator[Any]:
def wrapper(
*args: Any, anyio_backend: Any, request: SubRequest, **kwargs: Any
) -> Any:
# Rebind any fixture methods to the request instance
if (
request.instance
and ismethod(func)
and type(func.__self__) is type(request.instance)
):
local_func = func.__func__.__get__(request.instance)
else:
local_func = func

backend_name, backend_options = extract_backend_and_options(anyio_backend)
if has_backend_arg:
kwargs["anyio_backend"] = anyio_backend

if has_request_arg:
kwargs["request"] = anyio_backend
Comment on lines +93 to +94
Copy link

@layday layday Oct 13, 2024

Choose a reason for hiding this comment

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

This overwrites the request param of all async fixtures so that you are no longer able to parameterise async fixtures at all. The following example will throw an AttributeError: 'str' object has no attribute 'param':

@pytest.fixture(params=['foo', 'bar'])
async def dummy_fixture(
    request: pytest.FixtureRequest,
):
    if request.param == 'foo':
        do_this()
    else:
        do_that()

Copy link
Owner Author

Choose a reason for hiding this comment

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

Seems like this was missed in the review. I'll make a fix promptly.

Copy link
Owner Author

Choose a reason for hiding this comment

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

I've released a fix now.

Copy link

Choose a reason for hiding this comment

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

Thank you!


with get_runner(backend_name, backend_options) as runner:
if isasyncgenfunction(func):
yield from runner.run_asyncgen_fixture(func, kwargs)
if isasyncgenfunction(local_func):
yield from runner.run_asyncgen_fixture(local_func, kwargs)
else:
yield runner.run_fixture(func, kwargs)
yield runner.run_fixture(local_func, kwargs)

# Only apply this to coroutine functions and async generator functions in requests
# that involve the anyio_backend fixture
func = fixturedef.func
if isasyncgenfunction(func) or iscoroutinefunction(func):
if "anyio_backend" in request.fixturenames:
has_backend_arg = "anyio_backend" in fixturedef.argnames
fixturedef.func = wrapper
if not has_backend_arg:
original_argname = fixturedef.argnames

if not (has_backend_arg := "anyio_backend" in fixturedef.argnames):
fixturedef.argnames += ("anyio_backend",)

if not (has_request_arg := "request" in fixturedef.argnames):
fixturedef.argnames += ("request",)

try:
return (yield)
finally:
fixturedef.func = func
fixturedef.argnames = original_argname

return (yield)


@pytest.hookimpl(tryfirst=True)
def pytest_pycollect_makeitem(collector: Any, name: Any, obj: Any) -> None:
Expand Down
73 changes: 73 additions & 0 deletions tests/test_pytest_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -468,3 +468,76 @@ async def test_anyio_mark_first():
)

testdir.runpytest_subprocess(*pytest_args, timeout=3)


def test_async_fixture_in_test_class(testdir: Pytester) -> None:
# Regression test for #633
testdir.makepyfile(
"""
import pytest


class TestAsyncFixtureMethod:
is_same_instance = False

@pytest.fixture(autouse=True)
async def async_fixture_method(self):
self.is_same_instance = True

@pytest.mark.anyio
async def test_async_fixture_method(self):
assert self.is_same_instance
"""
)

result = testdir.runpytest_subprocess(*pytest_args)
result.assert_outcomes(passed=len(get_all_backends()))


def test_asyncgen_fixture_in_test_class(testdir: Pytester) -> None:
# Regression test for #633
testdir.makepyfile(
"""
import pytest


class TestAsyncFixtureMethod:
is_same_instance = False

@pytest.fixture(autouse=True)
async def async_fixture_method(self):
self.is_same_instance = True
yield

@pytest.mark.anyio
async def test_async_fixture_method(self):
assert self.is_same_instance
"""
)

result = testdir.runpytest_subprocess(*pytest_args)
result.assert_outcomes(passed=len(get_all_backends()))


def test_anyio_fixture_adoption_does_not_persist(testdir: Pytester) -> None:
testdir.makepyfile(
"""
import inspect
import pytest

@pytest.fixture
async def fixt():
return 1

@pytest.mark.anyio
async def test_fixt(fixt):
assert fixt == 1

def test_no_mark(fixt):
assert inspect.iscoroutine(fixt)
fixt.close()
"""
)

result = testdir.runpytest(*pytest_args)
result.assert_outcomes(passed=len(get_all_backends()) + 1)