diff --git a/.gitignore b/.gitignore index c4557b33a1c..59cdcc6d968 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,4 @@ pip-wheel-metadata/ # pytest debug logs generated via --debug pytestdebug.log +.claude/settings.local.json diff --git a/changelog/13564.improvement.rst b/changelog/13564.improvement.rst new file mode 100644 index 00000000000..c1704e19dd3 --- /dev/null +++ b/changelog/13564.improvement.rst @@ -0,0 +1,2 @@ +Issue a warning when fixtures are wrapped with a decorator, as that excludes +them from being discovered safely by pytest. diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 91f1b3a67f6..412661f17df 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -1271,7 +1271,9 @@ def __init__( def __repr__(self) -> str: return f"" - def __get__(self, instance, owner=None): + def __get__( + self, instance: object, owner: type | None = None + ) -> FixtureFunctionDefinition: """Behave like a method if the function it was applied to was a method.""" return FixtureFunctionDefinition( function=self._fixture_function, @@ -1765,6 +1767,80 @@ def _register_fixture( if autouse: self._nodeid_autousenames.setdefault(nodeid or "", []).append(name) + def _find_wrapped_fixture_def( + self, obj: object + ) -> FixtureFunctionDefinition | None: + """Walk through wrapper chain to find a FixtureFunctionDefinition. + + Returns the FixtureFunctionDefinition if found in the wrapper chain, + None otherwise. Handles loops and special objects safely. + """ + from _pytest.compat import safe_getattr + + # Skip mock objects (they have _mock_name attribute) + if safe_getattr(obj, "_mock_name", None) is not None: + return None + + current = obj + seen = set() # Track object IDs to detect loops + max_depth = 100 # Prevent infinite loops even if ID tracking fails + + for _ in range(max_depth): + if current is None: + break + + # Check for wrapper loops by object identity + current_id = id(current) + if current_id in seen: + return None + seen.add(current_id) + + # Check if current is a FixtureFunctionDefinition + # Use try/except to handle objects with problematic __class__ properties + try: + if isinstance(current, FixtureFunctionDefinition): + return current + except Exception: + # Can't check isinstance - probably a proxy object + return None + + # Try to get the next wrapped object using safe_getattr to handle + # "evil objects" that raise on attribute access (see issue #214) + wrapped = safe_getattr(current, "__wrapped__", None) + if wrapped is None: + break + + current = wrapped + + return None + + def _check_for_wrapped_fixture( + self, holder: object, name: str, obj: object, nodeid: str | None + ) -> None: + """Check if an object might be a fixture wrapped in decorators and warn if so.""" + # Only check objects that are not None + if obj is None: + return + + # Try to find a FixtureFunctionDefinition in the wrapper chain + fixture_def = self._find_wrapped_fixture_def(obj) + + # If we found a fixture definition and it's not the top-level object, + # it means the fixture is wrapped in decorators + if fixture_def is not None and fixture_def is not obj: + fixture_func = fixture_def._get_wrapped_function() + self._issue_fixture_wrapped_warning(name, nodeid, fixture_func) + + def _issue_fixture_wrapped_warning( + self, fixture_name: str, nodeid: str | None, fixture_func: Any + ) -> None: + """Issue a warning about a fixture that cannot be discovered due to decorators.""" + from _pytest.warning_types import PytestWarning + from _pytest.warning_types import warn_explicit_for + + msg = f"cannot discover {fixture_name} due to being wrapped in decorators" + warn_explicit_for(fixture_func, PytestWarning(msg)) + @overload def parsefactories( self, @@ -1845,6 +1921,9 @@ def parsefactories( ids=marker.ids, autouse=marker.autouse, ) + else: + # Check if this might be a wrapped fixture that we can't discover + self._check_for_wrapped_fixture(holderobj, name, obj_ub, nodeid) def getfixturedefs( self, argname: str, node: nodes.Node diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index 8b97d35c21e..164e8364a47 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -5069,3 +5069,49 @@ def test_method(self, /, fix): ) result = pytester.runpytest() result.assert_outcomes(passed=1) + + +@pytest.mark.filterwarnings( + "default:cannot discover * due to being wrapped in decorators:pytest.PytestWarning" +) +def test_custom_decorated_fixture_warning(pytester: Pytester) -> None: + """ + Test that fixtures decorated with custom decorators using functools.wraps + generate a warning about not being discoverable. + """ + pytester.makepyfile( + """ + import pytest + import functools + + def custom_deco(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + return wrapper + + class TestClass: + @custom_deco + @pytest.fixture + def my_fixture(self): + return "fixture_value" + + def test_fixture_usage(self, my_fixture): + assert my_fixture == "fixture_value" + """ + ) + result = pytester.runpytest_inprocess( + "-v", "-rw", "-W", "default::pytest.PytestWarning" + ) + + # Should get a warning about the decorated fixture during collection with correct location + result.stdout.fnmatch_lines( + [ + "*test_custom_decorated_fixture_warning.py:*: " + "PytestWarning: cannot discover my_fixture due to being wrapped in decorators*" + ] + ) + + # The test should fail because fixture is not found + result.stdout.fnmatch_lines(["*fixture 'my_fixture' not found*"]) + result.assert_outcomes(errors=1)