From d3de7f24a376b8d1bb5df93ba4037a99bd5293d5 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Mon, 22 Sep 2025 14:56:09 +0200 Subject: [PATCH 01/13] 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 | 43 +++++++++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 changelog/13564.improvement.rst diff --git a/.gitignore b/.gitignore index d0e8dc54ba1..a1f611604e1 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,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 f4ca2eac455..4252abb60d1 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -1409,7 +1409,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, @@ -2024,6 +2026,35 @@ def _register_fixture( # Global plugin autouse fixtures go under Session. self._node_autousenames.setdefault(self.session, []).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, @@ -2131,6 +2162,9 @@ def parsefactories( node=effective_node, nodeid=effective_nodeid, ) + 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 7000eeccbbd..77601160df2 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -5794,3 +5794,46 @@ def test_foobar(self, fixture_bar): ) result = pytester.runpytest("-v") 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" + ) + + result.stdout.fnmatch_lines( + [ + "*test_custom_decorated_fixture_warning.py:*: " + "PytestWarning: cannot discover my_fixture due to being wrapped in decorators*" + ] + ) + + result.stdout.fnmatch_lines(["*fixture 'my_fixture' not found*"]) + result.assert_outcomes(errors=1) From aa91e7630e9fb021fee44e99a97b870bbe0a1ada Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sat, 18 Oct 2025 12:02:23 +0200 Subject: [PATCH 02/13] 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 4252abb60d1..2b75447d54e 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -2026,24 +2026,63 @@ def _register_fixture( # Global plugin autouse fixtures go under Session. self._node_autousenames.setdefault(self.session, []).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 625af39869ddc8552c1199d43528551ebb64aa9e Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sun, 19 Oct 2025 00:23:48 +0200 Subject: [PATCH 03/13] 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 2b75447d54e..197031a7ff0 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -2041,9 +2041,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: @@ -2053,16 +2063,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 From c9313d9067e3fa030914c20c3ef54be0b913af53 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sun, 14 Jun 2026 10:55:50 +0200 Subject: [PATCH 04/13] Address review comments on wrapped fixture detection - Clarify mock object skip comment to explain why (avoid false positives) - Use unambiguous pytest#214 reference instead of bare "issue #214" - Simplify loop: use safe_getattr directly for __wrapped__ traversal, the None check at loop start already handles termination - Remove narrating comments, add type annotation to seen set - Inline _issue_fixture_wrapped_warning into _check_for_wrapped_fixture and drop unused nodeid parameter from both helpers - Guard warn_explicit_for with isinstance check for FunctionType Co-authored-by: Cursor AI Co-authored-by: Anthropic Claude Opus 4 --- src/_pytest/fixtures.py | 47 ++++++++++++++--------------------------- 1 file changed, 16 insertions(+), 31 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 197031a7ff0..458d69ce64f 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -2036,69 +2036,54 @@ def _find_wrapped_fixture_def( """ from _pytest.compat import safe_getattr - # Skip mock objects (they have _mock_name attribute) + # Skip mock objects to avoid false positives when traversing + # their wrapper chains (they have a _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 + seen: set[int] = set() - for _ in range(max_depth): + for _ in range(100): 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 + # safe_getattr handles "evil objects" that raise on attribute + # access (see pytest#214). + current = safe_getattr(current, "__wrapped__", None) return None def _check_for_wrapped_fixture( - self, holder: object, name: str, obj: object, nodeid: str | None + self, holder: object, name: str, obj: object ) -> 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) + from types import FunctionType - 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 + 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)) + fixture_func = fixture_def._get_wrapped_function() + if isinstance(fixture_func, FunctionType): + msg = f"cannot discover {name} due to being wrapped in decorators" + warn_explicit_for(fixture_func, PytestWarning(msg)) @overload def parsefactories( @@ -2209,7 +2194,7 @@ def parsefactories( ) else: # Check if this might be a wrapped fixture that we can't discover - self._check_for_wrapped_fixture(holderobj, name, obj_ub, nodeid) + self._check_for_wrapped_fixture(holderobj, name, obj_ub) def getfixturedefs( self, argname: str, node: nodes.Node From 26524b22c5e418ad4bfa19ffb582a9ad6f753fc1 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Mon, 20 Jul 2026 13:59:50 +0200 Subject: [PATCH 05/13] chore: drop redundant .claude settings gitignore entry The path is already ignored earlier in .gitignore. Co-authored-by: Cursor AI Co-authored-by: Cursor Grok 4.5 --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index a1f611604e1..d0e8dc54ba1 100644 --- a/.gitignore +++ b/.gitignore @@ -58,4 +58,3 @@ pip-wheel-metadata/ # pytest debug logs generated via --debug pytestdebug.log -.claude/settings.local.json From d29249feb42a14f6e937dac432bd0a8b171cc0b6 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Mon, 20 Jul 2026 14:05:10 +0200 Subject: [PATCH 06/13] Address review feedback on wrapped fixture detection Simplify None/loop handling in the wrapper walk and use module-level imports. Co-authored-by: Cursor AI Co-authored-by: Cursor Grok 4.5 --- src/_pytest/fixtures.py | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 458d69ce64f..3cdcf8cc6d0 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -76,6 +76,7 @@ from _pytest.scope import Scope from _pytest.scope import ScopeName from _pytest.warning_types import PytestWarning +from _pytest.warning_types import warn_explicit_for if sys.version_info < (3, 11): @@ -2034,23 +2035,18 @@ def _find_wrapped_fixture_def( 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 to avoid false positives when traversing # their wrapper chains (they have a _mock_name attribute). if safe_getattr(obj, "_mock_name", None) is not None: return None current = obj - seen: set[int] = set() + seen: set[int] = {id(None)} for _ in range(100): - if current is None: - break - current_id = id(current) if current_id in seen: - return None + break seen.add(current_id) try: @@ -2075,13 +2071,8 @@ def _check_for_wrapped_fixture( fixture_def = self._find_wrapped_fixture_def(obj) if fixture_def is not None and fixture_def is not obj: - from types import FunctionType - - from _pytest.warning_types import PytestWarning - from _pytest.warning_types import warn_explicit_for - fixture_func = fixture_def._get_wrapped_function() - if isinstance(fixture_func, FunctionType): + if isinstance(fixture_func, types.FunctionType): msg = f"cannot discover {name} due to being wrapped in decorators" warn_explicit_for(fixture_func, PytestWarning(msg)) From c7d4331287fc12e9dbd17aa49b3c7d5155438666 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Mon, 20 Jul 2026 14:52:32 +0200 Subject: [PATCH 07/13] Use inspect.unwrap and type __dict__ for wrapped fixture warnings Detect fixtures hidden by decorators via inspect.unwrap(stop=...), looking up raw class MRO __dict__ entries so classmethod/staticmethod wrappers are not hidden by getattr. Warn for @classmethod above @pytest.fixture and for @staticmethod when self/cls would become a fixture dependency. Co-authored-by: Cursor AI Co-authored-by: Cursor Grok 4.5 --- changelog/13564.improvement.rst | 7 +- src/_pytest/fixtures.py | 128 ++++++++++++++++------ testing/python/fixtures.py | 182 ++++++++++++++++++++++++++++++-- 3 files changed, 276 insertions(+), 41 deletions(-) diff --git a/changelog/13564.improvement.rst b/changelog/13564.improvement.rst index c1704e19dd3..56f2b505aac 100644 --- a/changelog/13564.improvement.rst +++ b/changelog/13564.improvement.rst @@ -1,2 +1,5 @@ -Issue a warning when fixtures are wrapped with a decorator, as that excludes -them from being discovered safely by pytest. +Issue a warning when fixtures are wrapped with a decorator that prevents +correct discovery, including ``@classmethod`` above ``@pytest.fixture``, and +``@staticmethod`` above ``@pytest.fixture`` when the fixture still takes +``self``/``cls`` (which then becomes a fixture dependency). Put +``@pytest.fixture`` above those decorators on test classes. diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 3cdcf8cc6d0..dc7e173c3c0 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -2027,54 +2027,111 @@ def _register_fixture( # Global plugin autouse fixtures go under Session. self._node_autousenames.setdefault(self.session, []).append(name) + def _lookup_in_type_dict(self, owner: object, name: str) -> object | None: + """Return ``name`` from ``owner``'s ``__dict__`` without invoking descriptors. + + For classes, walks the MRO so inherited ``staticmethod`` / + ``classmethod`` wrappers are visible. ``getattr`` / descriptor + ``__get__`` would hide those wrappers (bound methods, or the bare + :class:`FixtureFunctionDefinition` under a ``staticmethod``). + """ + if safe_isclass(owner): + cls = cast(type, owner) + try: + mro = cls.__mro__ + except AttributeError: + return None + for base in mro: + try: + return cast(object, base.__dict__[name]) + except KeyError: + continue + return None + if isinstance(owner, types.ModuleType): + return owner.__dict__.get(name) + return None + def _find_wrapped_fixture_def( self, obj: object ) -> FixtureFunctionDefinition | None: - """Walk through wrapper chain to find a FixtureFunctionDefinition. + """Find a FixtureFunctionDefinition in ``obj``'s ``__wrapped__`` chain. - Returns the FixtureFunctionDefinition if found in the wrapper chain, - None otherwise. Handles loops and special objects safely. + Uses :func:`inspect.unwrap` with a ``stop`` callback so unwrapping does + not continue past :class:`FixtureFunctionDefinition` (which itself sets + ``__wrapped__`` via :func:`functools.update_wrapper`). + + Returns the definition if found, else ``None``. Loops, excessive depth, + and objects that raise on attribute access are treated as not found. """ # Skip mock objects to avoid false positives when traversing # their wrapper chains (they have a _mock_name attribute). if safe_getattr(obj, "_mock_name", None) is not None: return None - current = obj - seen: set[int] = {id(None)} - - for _ in range(100): - current_id = id(current) - if current_id in seen: - break - seen.add(current_id) + try: + # unwrap is typed for callables, but we also pass descriptors + # (classmethod/staticmethod) that expose __wrapped__/__func__. + unwrapped = inspect.unwrap( + cast(Callable[..., Any], obj), + stop=lambda o: isinstance(o, FixtureFunctionDefinition), + ) + except Exception: + # ValueError for wrapper loops / depth; also "evil objects" + # that raise on attribute access (see pytest#214). + return None - try: - if isinstance(current, FixtureFunctionDefinition): - return current - except Exception: - return None + if isinstance(unwrapped, FixtureFunctionDefinition): + return unwrapped + return None - # safe_getattr handles "evil objects" that raise on attribute - # access (see pytest#214). - current = safe_getattr(current, "__wrapped__", None) + def _check_for_wrapped_fixture(self, name: str, obj: object) -> None: + """Warn if ``obj`` is a fixture hidden behind wrappers. - return None + ``obj`` must be the raw class/module ``__dict__`` entry (see + :meth:`_lookup_in_type_dict`), not the result of ``getattr``. - def _check_for_wrapped_fixture( - self, holder: object, name: str, obj: object - ) -> None: - """Check if an object might be a fixture wrapped in decorators and warn if so.""" + ``@staticmethod`` above ``@pytest.fixture`` is especially problematic: + discovery may still succeed, but the first parameter can be treated as + a fixture dependency instead of ``self``/``cls``. + """ if obj is None: return fixture_def = self._find_wrapped_fixture_def(obj) + if fixture_def is None or fixture_def is obj: + return + + fixture_func = fixture_def._get_wrapped_function() + if not isinstance(fixture_func, types.FunctionType): + return - if fixture_def is not None and fixture_def is not obj: - fixture_func = fixture_def._get_wrapped_function() - if isinstance(fixture_func, types.FunctionType): - msg = f"cannot discover {name} due to being wrapped in decorators" - warn_explicit_for(fixture_func, PytestWarning(msg)) + if isinstance(obj, classmethod): + msg = ( + f"cannot discover fixture {name!r} because it is wrapped by " + f"@classmethod; place @pytest.fixture above @classmethod" + ) + elif isinstance(obj, staticmethod): + # Plugin classes intentionally use @staticmethod above @fixture so + # the first parameter stays a fixture arg. Only warn when the + # callable looks like a method (self/cls), where staticmethod + # incorrectly keeps that name as a fixture dependency. + try: + first_param = next( + iter(inspect.signature(fixture_func).parameters), None + ) + except (TypeError, ValueError): + return + if first_param not in ("self", "cls"): + return + msg = ( + f"fixture {name!r} is wrapped by @staticmethod above " + f"@pytest.fixture; place @pytest.fixture above @staticmethod " + f"so the first parameter is treated as self/cls, not as a " + f"fixture argument" + ) + else: + msg = f"cannot discover fixture {name!r} due to being wrapped in decorators" + warn_explicit_for(fixture_func, PytestWarning(msg)) @overload def parsefactories( @@ -2154,6 +2211,12 @@ def parsefactories( holderobj_tp = holderobj for name in dir(holderobj): + # Read the raw __dict__ entry first so staticmethod/classmethod + # wrappers are not hidden by descriptor binding. + raw_obj = self._lookup_in_type_dict(holderobj_tp, name) + if raw_obj is not None: + self._check_for_wrapped_fixture(name, raw_obj) + # The attribute can be an arbitrary descriptor, so the attribute # access below can raise. safe_getattr() ignores such exceptions. obj_ub = safe_getattr(holderobj_tp, name, None) @@ -2183,9 +2246,10 @@ def parsefactories( node=effective_node, nodeid=effective_nodeid, ) - else: - # Check if this might be a wrapped fixture that we can't discover - self._check_for_wrapped_fixture(holderobj, name, obj_ub) + elif raw_obj is None and obj_ub is not None: + # No class/module __dict__ entry (e.g. some dynamic attributes); + # still check getattr result for functools.wraps chains. + self._check_for_wrapped_fixture(name, obj_ub) def getfixturedefs( self, argname: str, node: nodes.Node diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index 77601160df2..0c74b1a1e0f 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -5796,13 +5796,9 @@ def test_foobar(self, fixture_bar): result.assert_outcomes(passed=1) -@pytest.mark.filterwarnings( - "default:cannot discover * due to being wrapped in decorators:pytest.PytestWarning" -) +@pytest.mark.filterwarnings("default:cannot discover fixture *: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. - """ + """Fixtures wrapped by custom decorators using functools.wraps warn.""" pytester.makepyfile( """ import pytest @@ -5831,9 +5827,181 @@ def test_fixture_usage(self, my_fixture): result.stdout.fnmatch_lines( [ "*test_custom_decorated_fixture_warning.py:*: " - "PytestWarning: cannot discover my_fixture due to being wrapped in decorators*" + "PytestWarning: cannot discover fixture 'my_fixture' " + "due to being wrapped in decorators*" ] ) result.stdout.fnmatch_lines(["*fixture 'my_fixture' not found*"]) result.assert_outcomes(errors=1) + + +@pytest.mark.filterwarnings("default:cannot discover fixture *:pytest.PytestWarning") +def test_classmethod_above_fixture_warning(pytester: Pytester) -> None: + """@classmethod above @pytest.fixture hides the fixture (#13507).""" + pytester.makepyfile( + """ + import pytest + + class TestFixture: + @classmethod + @pytest.fixture(scope="class") + def fixt(cls): + return 1 + + def test_fixt(self, fixt): + assert fixt == 1 + """ + ) + result = pytester.runpytest_inprocess( + "-v", "-rw", "-W", "default::pytest.PytestWarning" + ) + + result.stdout.fnmatch_lines( + [ + "*test_classmethod_above_fixture_warning.py:*: " + "PytestWarning: cannot discover fixture 'fixt' because it is " + "wrapped by @classmethod; place @pytest.fixture above @classmethod*" + ] + ) + result.stdout.fnmatch_lines(["*fixture 'fixt' not found*"]) + result.assert_outcomes(errors=1) + + +@pytest.mark.filterwarnings( + "default:fixture * is wrapped by @staticmethod*:pytest.PytestWarning" +) +def test_staticmethod_above_fixture_warning(pytester: Pytester) -> None: + """@staticmethod above @pytest.fixture warns: ``self`` becomes a fixture arg. + + Unlike ``classmethod``, discovery still finds the fixture via + ``staticmethod.__get__``, but ``getfuncargnames`` then keeps the first + parameter as a fixture dependency. + """ + pytester.makepyfile( + """ + import pytest + + class TestFixture: + @staticmethod + @pytest.fixture + def fixt(self): + return 1 + + def test_fixt(self, fixt): + assert fixt == 1 + """ + ) + result = pytester.runpytest_inprocess( + "-v", "-rw", "-W", "default::pytest.PytestWarning" + ) + + result.stdout.fnmatch_lines( + [ + "*test_staticmethod_above_fixture_warning.py:*: " + "PytestWarning: fixture 'fixt' is wrapped by @staticmethod above " + "@pytest.fixture; place @pytest.fixture above @staticmethod " + "so the first parameter is treated as self/cls, not as a " + "fixture argument*" + ] + ) + result.stdout.fnmatch_lines(["*fixture 'self' not found*"]) + result.assert_outcomes(errors=1) + + +def test_fixture_above_classmethod_still_works(pytester: Pytester) -> None: + """Documented order @pytest.fixture above @classmethod remains discoverable.""" + pytester.makepyfile( + """ + import pytest + + class TestFixture: + @pytest.fixture(scope="class") + @classmethod + def fixt(cls): + return 1 + + def test_fixt(self, fixt): + assert fixt == 1 + """ + ) + result = pytester.runpytest("-v") + result.assert_outcomes(passed=1) + + +def test_fixture_above_staticmethod_still_works(pytester: Pytester) -> None: + """@pytest.fixture above @staticmethod remains discoverable without warning.""" + pytester.makepyfile( + """ + import pytest + + class TestFixture: + @pytest.fixture + @staticmethod + def fixt(): + return 1 + + def test_fixt(self, fixt): + assert fixt == 1 + """ + ) + result = pytester.runpytest("-W", "error::pytest.PytestWarning", "-v") + result.assert_outcomes(passed=1) + + +def test_staticmethod_fixture_without_self_does_not_warn( + pytester: Pytester, +) -> None: + """@staticmethod above @fixture is OK when there is no self/cls parameter. + + Plugin classes use this pattern so the first argument stays a fixture + dependency instead of being bound as ``self``. + """ + pytester.makepyfile( + """ + import pytest + + class TestFixture: + @staticmethod + @pytest.fixture + def fixt(): + return 1 + + def test_fixt(self, fixt): + assert fixt == 1 + """ + ) + result = pytester.runpytest("-W", "error::pytest.PytestWarning", "-v") + result.assert_outcomes(passed=1) + + +@pytest.mark.filterwarnings("default:cannot discover fixture *:pytest.PytestWarning") +def test_classmethod_above_fixture_warning_inherited(pytester: Pytester) -> None: + """MRO ``__dict__`` lookup finds @classmethod wrappers on a base class.""" + pytester.makepyfile( + """ + import pytest + + class Base: + @classmethod + @pytest.fixture(scope="class") + def fixt(cls): + return 1 + + class TestFixture(Base): + def test_fixt(self, fixt): + assert fixt == 1 + """ + ) + result = pytester.runpytest_inprocess( + "-v", "-rw", "-W", "default::pytest.PytestWarning" + ) + + result.stdout.fnmatch_lines( + [ + "*PytestWarning: cannot discover fixture 'fixt' because it is " + "wrapped by @classmethod; place @pytest.fixture above @classmethod*" + ] + ) + result.stdout.fnmatch_lines(["*fixture 'fixt' not found*"]) + result.assert_outcomes(errors=1) From 8bb1c74d368284fe52ed811b3dd5259f8d6c5f8c Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Mon, 20 Jul 2026 15:09:35 +0200 Subject: [PATCH 08/13] Fix wrapped-fixture detection on older Pythons and evil __class__ Use exact-type checks instead of isinstance for FixtureFunctionDefinition so proxies that raise from __class__ do not break collection. Skip registration when the raw attribute is classmethod(...), since Python 3.9-3.12 descriptor chaining would otherwise still discover the fixture. Co-authored-by: Cursor AI Co-authored-by: Cursor Grok 4.5 --- src/_pytest/fixtures.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index dc7e173c3c0..cc675450d2c 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -2071,16 +2071,18 @@ def _find_wrapped_fixture_def( try: # unwrap is typed for callables, but we also pass descriptors # (classmethod/staticmethod) that expose __wrapped__/__func__. + # Use type(...) is, not isinstance: some proxies raise on + # isinstance via a broken __class__ (see pytest#4266). unwrapped = inspect.unwrap( cast(Callable[..., Any], obj), - stop=lambda o: isinstance(o, FixtureFunctionDefinition), + stop=lambda o: type(o) is FixtureFunctionDefinition, ) except Exception: # ValueError for wrapper loops / depth; also "evil objects" # that raise on attribute access (see pytest#214). return None - if isinstance(unwrapped, FixtureFunctionDefinition): + if type(unwrapped) is FixtureFunctionDefinition: return unwrapped return None @@ -2102,15 +2104,16 @@ def _check_for_wrapped_fixture(self, name: str, obj: object) -> None: return fixture_func = fixture_def._get_wrapped_function() - if not isinstance(fixture_func, types.FunctionType): + if type(fixture_func) is not types.FunctionType: return - if isinstance(obj, classmethod): + # Exact-type checks avoid proxies that raise from isinstance (#4266). + if type(obj) is classmethod: msg = ( f"cannot discover fixture {name!r} because it is wrapped by " f"@classmethod; place @pytest.fixture above @classmethod" ) - elif isinstance(obj, staticmethod): + elif type(obj) is staticmethod: # Plugin classes intentionally use @staticmethod above @fixture so # the first parameter stays a fixture arg. Only warn when the # callable looks like a method (self/cls), where staticmethod @@ -2221,6 +2224,13 @@ def parsefactories( # access below can raise. safe_getattr() ignores such exceptions. obj_ub = safe_getattr(holderobj_tp, name, None) if type(obj_ub) is FixtureFunctionDefinition: + # On Python 3.9-3.12, classmethod chains through descriptors, so + # getattr may return a FixtureFunctionDefinition even when the + # raw __dict__ entry is classmethod(fixture). Do not register + # that case; users must put @pytest.fixture above @classmethod. + if type(raw_obj) is classmethod: + continue + marker = obj_ub._fixture_function_marker if marker.name: fixture_name = marker.name From b1e273d5f0ec49af2f648aa3ec6cdcafc5b3a802 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Mon, 20 Jul 2026 15:27:05 +0200 Subject: [PATCH 09/13] Drop unnecessary defensive checks in wrapped fixture detection After safe_isclass, __mro__ is always present. Callers never pass None into the wrap check, and signature() is safe once we know we have a FunctionType. Co-authored-by: Cursor AI Co-authored-by: Cursor Grok 4.5 --- src/_pytest/fixtures.py | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index cc675450d2c..23ab8500350 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -2037,11 +2037,7 @@ def _lookup_in_type_dict(self, owner: object, name: str) -> object | None: """ if safe_isclass(owner): cls = cast(type, owner) - try: - mro = cls.__mro__ - except AttributeError: - return None - for base in mro: + for base in cls.__mro__: try: return cast(object, base.__dict__[name]) except KeyError: @@ -2096,18 +2092,16 @@ def _check_for_wrapped_fixture(self, name: str, obj: object) -> None: discovery may still succeed, but the first parameter can be treated as a fixture dependency instead of ``self``/``cls``. """ - if obj is None: - return - fixture_def = self._find_wrapped_fixture_def(obj) + # None: not a wrapped fixture. is obj: bare FixtureFunctionDefinition. if fixture_def is None or fixture_def is obj: return fixture_func = fixture_def._get_wrapped_function() + # warn_explicit_for needs a real function (__code__/__globals__). if type(fixture_func) is not types.FunctionType: return - # Exact-type checks avoid proxies that raise from isinstance (#4266). if type(obj) is classmethod: msg = ( f"cannot discover fixture {name!r} because it is wrapped by " @@ -2118,12 +2112,7 @@ def _check_for_wrapped_fixture(self, name: str, obj: object) -> None: # the first parameter stays a fixture arg. Only warn when the # callable looks like a method (self/cls), where staticmethod # incorrectly keeps that name as a fixture dependency. - try: - first_param = next( - iter(inspect.signature(fixture_func).parameters), None - ) - except (TypeError, ValueError): - return + first_param = next(iter(inspect.signature(fixture_func).parameters), None) if first_param not in ("self", "cls"): return msg = ( From 8eec7667f1e3fec2224de0a742cdc7c58d2eee80 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Mon, 20 Jul 2026 15:51:36 +0200 Subject: [PATCH 10/13] Peel classmethod/staticmethod before emitting wrapped-fixture warnings When @pytest.fixture wraps a classmethod/staticmethod, warn_explicit_for needs the underlying function. Cover that path with a wraps+fixture+classmethod test and pragma the remaining non-function guard. Co-authored-by: Cursor AI Co-authored-by: Cursor Grok 4.5 --- src/_pytest/fixtures.py | 8 +++++-- testing/python/fixtures.py | 45 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 23ab8500350..af06db7ecb4 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -2097,9 +2097,13 @@ def _check_for_wrapped_fixture(self, name: str, obj: object) -> None: if fixture_def is None or fixture_def is obj: return - fixture_func = fixture_def._get_wrapped_function() # warn_explicit_for needs a real function (__code__/__globals__). - if type(fixture_func) is not types.FunctionType: + # @pytest.fixture above @classmethod/@staticmethod stores the descriptor + # as _fixture_function; peel once to reach the underlying def. + fixture_func: object = fixture_def._get_wrapped_function() + if type(fixture_func) is classmethod or type(fixture_func) is staticmethod: + fixture_func = fixture_func.__func__ + if not isinstance(fixture_func, types.FunctionType): # pragma: no cover return if type(obj) is classmethod: diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index 0c74b1a1e0f..46f6fcc4210 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -5836,6 +5836,51 @@ def test_fixture_usage(self, my_fixture): result.assert_outcomes(errors=1) +@pytest.mark.filterwarnings("default:cannot discover fixture *:pytest.PytestWarning") +def test_custom_decorated_fixture_above_classmethod_warning( + pytester: Pytester, +) -> None: + """Warn when wraps hides a fixture that itself wraps @classmethod. + + The fixture definition stores the classmethod descriptor; warning emission + peels it to reach the underlying function for warn_explicit_for. + """ + 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(scope="class") + @classmethod + def my_fixture(cls): + 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" + ) + + result.stdout.fnmatch_lines( + [ + "*PytestWarning: cannot discover fixture 'my_fixture' " + "due to being wrapped in decorators*" + ] + ) + result.stdout.fnmatch_lines(["*fixture 'my_fixture' not found*"]) + result.assert_outcomes(errors=1) + + @pytest.mark.filterwarnings("default:cannot discover fixture *:pytest.PytestWarning") def test_classmethod_above_fixture_warning(pytester: Pytester) -> None: """@classmethod above @pytest.fixture hides the fixture (#13507).""" From 35bf773e0ae4dfb4ee5791c2e992e8f28375d4fa Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Mon, 20 Jul 2026 16:09:08 +0200 Subject: [PATCH 11/13] Drop unreachable wrapped-fixture lookup fallbacks Remove the dynamic-getattr wrap check (fixtures live in __dict__) and tighten _lookup_in_type_dict to class/module owners so the dead non-type return is gone. Co-authored-by: Cursor AI Co-authored-by: Cursor Grok 4.5 --- src/_pytest/fixtures.py | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index af06db7ecb4..dad60b34aec 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -2027,7 +2027,9 @@ def _register_fixture( # Global plugin autouse fixtures go under Session. self._node_autousenames.setdefault(self.session, []).append(name) - def _lookup_in_type_dict(self, owner: object, name: str) -> object | None: + def _lookup_in_type_dict( + self, owner: type | types.ModuleType, name: str + ) -> object | None: """Return ``name`` from ``owner``'s ``__dict__`` without invoking descriptors. For classes, walks the MRO so inherited ``staticmethod`` / @@ -2035,16 +2037,13 @@ def _lookup_in_type_dict(self, owner: object, name: str) -> object | None: ``__get__`` would hide those wrappers (bound methods, or the bare :class:`FixtureFunctionDefinition` under a ``staticmethod``). """ - if safe_isclass(owner): - cls = cast(type, owner) - for base in cls.__mro__: - try: - return cast(object, base.__dict__[name]) - except KeyError: - continue - return None if isinstance(owner, types.ModuleType): return owner.__dict__.get(name) + for base in owner.__mro__: + try: + return cast(object, base.__dict__[name]) + except KeyError: + continue return None def _find_wrapped_fixture_def( @@ -2201,10 +2200,11 @@ def parsefactories( effective_node = node_or_obj # Avoid accessing `@property` (and other descriptors) when iterating fixtures. + holderobj_tp: type | types.ModuleType if not safe_isclass(holderobj) and not isinstance(holderobj, types.ModuleType): - holderobj_tp: object = type(holderobj) + holderobj_tp = type(holderobj) else: - holderobj_tp = holderobj + holderobj_tp = cast("type | types.ModuleType", holderobj) for name in dir(holderobj): # Read the raw __dict__ entry first so staticmethod/classmethod @@ -2249,10 +2249,6 @@ def parsefactories( node=effective_node, nodeid=effective_nodeid, ) - elif raw_obj is None and obj_ub is not None: - # No class/module __dict__ entry (e.g. some dynamic attributes); - # still check getattr result for functools.wraps chains. - self._check_for_wrapped_fixture(name, obj_ub) def getfixturedefs( self, argname: str, node: nodes.Node From 8be107603c1939518c07f7d4889764d55497810e Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Wed, 22 Jul 2026 14:11:34 +0200 Subject: [PATCH 12/13] Always warn for @staticmethod above @pytest.fixture Drop the self/cls carve-out; that case already fails as a missing fixture. Reorder legacy plugin fixtures so @fixture stays outermost under -Werror. Co-authored-by: Cursor AI Co-authored-by: Cursor Grok 4.5 --- changelog/13564.improvement.rst | 7 +++--- src/_pytest/fixtures.py | 18 ++++----------- src/_pytest/legacypath.py | 6 ++--- testing/python/fixtures.py | 41 +++++---------------------------- 4 files changed, 17 insertions(+), 55 deletions(-) diff --git a/changelog/13564.improvement.rst b/changelog/13564.improvement.rst index 56f2b505aac..af8346eef17 100644 --- a/changelog/13564.improvement.rst +++ b/changelog/13564.improvement.rst @@ -1,5 +1,4 @@ Issue a warning when fixtures are wrapped with a decorator that prevents -correct discovery, including ``@classmethod`` above ``@pytest.fixture``, and -``@staticmethod`` above ``@pytest.fixture`` when the fixture still takes -``self``/``cls`` (which then becomes a fixture dependency). Put -``@pytest.fixture`` above those decorators on test classes. +correct discovery, including ``@classmethod`` above ``@pytest.fixture`` and +``@staticmethod`` above ``@pytest.fixture``. Put ``@pytest.fixture`` above +those decorators on test classes. diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index dad60b34aec..6e1c63a4b53 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -2087,9 +2087,10 @@ def _check_for_wrapped_fixture(self, name: str, obj: object) -> None: ``obj`` must be the raw class/module ``__dict__`` entry (see :meth:`_lookup_in_type_dict`), not the result of ``getattr``. - ``@staticmethod`` above ``@pytest.fixture`` is especially problematic: - discovery may still succeed, but the first parameter can be treated as - a fixture dependency instead of ``self``/``cls``. + ``@staticmethod`` above ``@pytest.fixture`` is always warned: discovery + may still succeed, but the decorator order is wrong. A leading + ``self``/``cls`` parameter already fails as a missing fixture and needs + no extra special-casing here. """ fixture_def = self._find_wrapped_fixture_def(obj) # None: not a wrapped fixture. is obj: bare FixtureFunctionDefinition. @@ -2111,18 +2112,9 @@ def _check_for_wrapped_fixture(self, name: str, obj: object) -> None: f"@classmethod; place @pytest.fixture above @classmethod" ) elif type(obj) is staticmethod: - # Plugin classes intentionally use @staticmethod above @fixture so - # the first parameter stays a fixture arg. Only warn when the - # callable looks like a method (self/cls), where staticmethod - # incorrectly keeps that name as a fixture dependency. - first_param = next(iter(inspect.signature(fixture_func).parameters), None) - if first_param not in ("self", "cls"): - return msg = ( f"fixture {name!r} is wrapped by @staticmethod above " - f"@pytest.fixture; place @pytest.fixture above @staticmethod " - f"so the first parameter is treated as self/cls, not as a " - f"fixture argument" + f"@pytest.fixture; place @pytest.fixture above @staticmethod" ) else: msg = f"cannot discover fixture {name!r} due to being wrapped in decorators" diff --git a/src/_pytest/legacypath.py b/src/_pytest/legacypath.py index 59e8ef6e742..f7d88db8b5e 100644 --- a/src/_pytest/legacypath.py +++ b/src/_pytest/legacypath.py @@ -251,8 +251,8 @@ def __str__(self) -> str: class LegacyTestdirPlugin: - @staticmethod @fixture + @staticmethod def testdir(pytester: Pytester) -> Testdir: """ Identical to :fixture:`pytester`, and provides an instance whose methods return @@ -294,15 +294,15 @@ def getbasetemp(self) -> LEGACY_PATH: class LegacyTmpdirPlugin: - @staticmethod @fixture(scope="session") + @staticmethod def tmpdir_factory(request: FixtureRequest) -> TempdirFactory: """Return a :class:`pytest.TempdirFactory` instance for the test session.""" # Set dynamically by pytest_configure(). return request.config._tmpdirhandler # type: ignore - @staticmethod @fixture + @staticmethod def tmpdir(tmp_path: Path) -> LEGACY_PATH: """Return a temporary directory (as `legacy_path`_ object) which is unique to each test function invocation. diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index 46f6fcc4210..69044226b6d 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -5917,11 +5917,11 @@ def test_fixt(self, fixt): "default:fixture * is wrapped by @staticmethod*:pytest.PytestWarning" ) def test_staticmethod_above_fixture_warning(pytester: Pytester) -> None: - """@staticmethod above @pytest.fixture warns: ``self`` becomes a fixture arg. + """@staticmethod above @pytest.fixture always warns. Unlike ``classmethod``, discovery still finds the fixture via - ``staticmethod.__get__``, but ``getfuncargnames`` then keeps the first - parameter as a fixture dependency. + ``staticmethod.__get__``, so the test can pass; a leading ``self``/``cls`` + already fails as a missing fixture without special-casing here. """ pytester.makepyfile( """ @@ -5930,7 +5930,7 @@ def test_staticmethod_above_fixture_warning(pytester: Pytester) -> None: class TestFixture: @staticmethod @pytest.fixture - def fixt(self): + def fixt(): return 1 def test_fixt(self, fixt): @@ -5945,13 +5945,10 @@ def test_fixt(self, fixt): [ "*test_staticmethod_above_fixture_warning.py:*: " "PytestWarning: fixture 'fixt' is wrapped by @staticmethod above " - "@pytest.fixture; place @pytest.fixture above @staticmethod " - "so the first parameter is treated as self/cls, not as a " - "fixture argument*" + "@pytest.fixture; place @pytest.fixture above @staticmethod*" ] ) - result.stdout.fnmatch_lines(["*fixture 'self' not found*"]) - result.assert_outcomes(errors=1) + result.assert_outcomes(passed=1) def test_fixture_above_classmethod_still_works(pytester: Pytester) -> None: @@ -5994,32 +5991,6 @@ def test_fixt(self, fixt): result.assert_outcomes(passed=1) -def test_staticmethod_fixture_without_self_does_not_warn( - pytester: Pytester, -) -> None: - """@staticmethod above @fixture is OK when there is no self/cls parameter. - - Plugin classes use this pattern so the first argument stays a fixture - dependency instead of being bound as ``self``. - """ - pytester.makepyfile( - """ - import pytest - - class TestFixture: - @staticmethod - @pytest.fixture - def fixt(): - return 1 - - def test_fixt(self, fixt): - assert fixt == 1 - """ - ) - result = pytester.runpytest("-W", "error::pytest.PytestWarning", "-v") - result.assert_outcomes(passed=1) - - @pytest.mark.filterwarnings("default:cannot discover fixture *:pytest.PytestWarning") def test_classmethod_above_fixture_warning_inherited(pytester: Pytester) -> None: """MRO ``__dict__`` lookup finds @classmethod wrappers on a base class.""" From d987551e7cd2797b5b61d429bec275c867fae1cf Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Wed, 22 Jul 2026 15:06:50 +0200 Subject: [PATCH 13/13] Register legacy path plugins as instances Class registration kept @fixture-wrapped staticmethods as descriptors, so resolve_fixture_function rebound them onto test instances and broke tmpdir with "multiple values for argument". Co-authored-by: Cursor AI Co-authored-by: Cursor Grok 4.5 --- src/_pytest/legacypath.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/_pytest/legacypath.py b/src/_pytest/legacypath.py index f7d88db8b5e..e1fc38d19ed 100644 --- a/src/_pytest/legacypath.py +++ b/src/_pytest/legacypath.py @@ -456,7 +456,10 @@ def pytest_configure(config: Config) -> None: _tmpdirhandler = TempdirFactory(tmp_path_factory, _ispytest=True) mp.setattr(config, "_tmpdirhandler", _tmpdirhandler, raising=False) - config.pluginmanager.register(LegacyTmpdirPlugin, "legacypath-tmpdir") + # Register an instance so @fixture above @staticmethod unwraps to a bare + # function. Class registration would keep the staticmethod descriptor, + # which resolve_fixture_function then binds onto test instances (#13564). + config.pluginmanager.register(LegacyTmpdirPlugin(), "legacypath-tmpdir") @hookimpl @@ -464,5 +467,5 @@ def pytest_plugin_registered(plugin: object, manager: PytestPluginManager) -> No # pytester is not loaded by default and is commonly loaded from a conftest, # so checking for it in `pytest_configure` is not enough. is_pytester = plugin is manager.get_plugin("pytester") - if is_pytester and not manager.is_registered(LegacyTestdirPlugin): - manager.register(LegacyTestdirPlugin, "legacypath-pytester") + if is_pytester and not manager.has_plugin("legacypath-pytester"): + manager.register(LegacyTestdirPlugin(), "legacypath-pytester")