From 59a31cac21ac65885745d9b8612c286192f6dd40 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Mon, 22 Sep 2025 14:56:09 +0200 Subject: [PATCH 1/3] fix #13564 - warn when fixtures get wrapped with a decorator --- .gitignore | 1 + changelog/13564.improvement.rst | 2 ++ src/_pytest/fixtures.py | 36 +++++++++++++++++++++++++- testing/python/fixtures.py | 46 +++++++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 changelog/13564.improvement.rst 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..5b77f6ad995 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,35 @@ def _register_fixture( if autouse: self._nodeid_autousenames.setdefault(nodeid or "", []).append(name) + 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 and not already FixtureFunctionDefinition + if obj is None: + return + try: + maybe_def = get_real_func(obj) + except Exception: + warnings.warn( + f"could not get real function for fixture {name} on {holder}", + stacklevel=2, + ) + else: + if isinstance(maybe_def, FixtureFunctionDefinition): + fixture_func = maybe_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 +1876,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) From e9b4baa24ac1ca7c10eb09c07bfa4737bc0a8fb4 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sat, 18 Oct 2025 12:02:23 +0200 Subject: [PATCH 2/3] Improve wrapped fixture detection to handle edge cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor the wrapped fixture detection logic to safely handle: - Mock objects (which have _mock_name attribute) - Proxy objects with problematic __class__ properties - Wrapper loops (like in mock.call) - Objects that raise exceptions during isinstance checks The new _find_wrapped_fixture_def() method walks the wrapper chain more safely and avoids infinite loops and errors from special objects. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/_pytest/fixtures.py | 63 +++++++++++++++++++++++++++++++++-------- 1 file changed, 51 insertions(+), 12 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 5b77f6ad995..b8aa99832ce 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -1767,24 +1767,63 @@ 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 = {id(current)} # Track objects to detect loops + + while current is not None: + # 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 + wrapped = safe_getattr(current, "__wrapped__", None) + if wrapped is None: + break + + # Check for wrapper loops (like in mock.call) + if id(wrapped) in seen: + return None + + seen.add(id(wrapped)) + 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 and not already FixtureFunctionDefinition + # Only check objects that are not None if obj is None: return - try: - maybe_def = get_real_func(obj) - except Exception: - warnings.warn( - f"could not get real function for fixture {name} on {holder}", - stacklevel=2, - ) - else: - if isinstance(maybe_def, FixtureFunctionDefinition): - fixture_func = maybe_def._get_wrapped_function() - self._issue_fixture_wrapped_warning(name, nodeid, fixture_func) + + # 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 From f6e841406bd4e82c7316fcff3c514ec5c88bdc21 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sun, 19 Oct 2025 00:23:48 +0200 Subject: [PATCH 3/3] Fix infinite loop in wrapped fixture detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevent OOM crash when encountering objects with self-referential __wrapped__ attributes. The loop detection logic now checks object IDs at the start of each iteration and includes a maximum depth limit as an additional safeguard. The original code had a timing issue where it checked id(wrapped) against seen after fetching __wrapped__ but before updating current, which failed to detect self-referential objects properly. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/_pytest/fixtures.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index b8aa99832ce..412661f17df 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -1782,9 +1782,19 @@ def _find_wrapped_fixture_def( return None current = obj - seen = {id(current)} # Track objects to detect loops + 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) - while current is not None: # Check if current is a FixtureFunctionDefinition # Use try/except to handle objects with problematic __class__ properties try: @@ -1794,16 +1804,12 @@ def _find_wrapped_fixture_def( # Can't check isinstance - probably a proxy object return None - # Try to get the next wrapped object + # 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 - # Check for wrapper loops (like in mock.call) - if id(wrapped) in seen: - return None - - seen.add(id(wrapped)) current = wrapped return None