Skip to content
Draft
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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,4 @@ pip-wheel-metadata/

# pytest debug logs generated via --debug
pytestdebug.log
.claude/settings.local.json
2 changes: 2 additions & 0 deletions changelog/13564.improvement.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Issue a warning when fixtures are wrapped with a decorator, as that excludes
them from being discovered safely by pytest.
81 changes: 80 additions & 1 deletion src/_pytest/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -1271,7 +1271,9 @@ def __init__(
def __repr__(self) -> str:
return f"<pytest_fixture({self._fixture_function})>"

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,
Expand Down Expand Up @@ -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)
Copy link

Copilot AI Oct 18, 2025

Choose a reason for hiding this comment

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

[nitpick] The comment should clarify why mock objects need to be skipped. Consider: 'Skip mock objects (they have _mock_name attribute) to avoid false positives when traversing their wrapper chains.'

Suggested change
# Skip mock objects (they have _mock_name attribute)
# Skip mock objects (they have _mock_name attribute) to avoid false positives when traversing their wrapper chains.

Copilot uses AI. Check for mistakes.
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)
Copy link

Copilot AI Oct 18, 2025

Choose a reason for hiding this comment

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

[nitpick] The reference to 'issue #214' is unclear without context. Consider specifying the full issue reference (e.g., 'pytest issue #214') or linking to the relevant documentation.

Suggested change
# "evil objects" that raise on attribute access (see issue #214)
# "evil objects" that raise on attribute access (see pytest issue #214: https://github.com/pytest-dev/pytest/issues/214)

Copilot uses AI. Check for mistakes.
wrapped = safe_getattr(current, "__wrapped__", None)
if wrapped is None:
break

current = wrapped
Comment on lines +1809 to +1813
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
wrapped = safe_getattr(current, "__wrapped__", None)
if wrapped is None:
break
current = wrapped
current = safe_getattr(current, "__wrapped__", None)

redundant with start-of-loop logic


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,
Expand Down Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions testing/python/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)