Skip to content

Commit ecde993

Browse files
Glyphackwebknjaznicoddemusbluetech
authored
fixtures: replace fixture representation with a class (#12473)
During the sprint we discussed fixing the above issue and thought maybe it's a better idea to add a better representation to fixtures. To do this without patching the object more, this PR refactors fixtures to have a class with attributes. The main rational behind that is: - Now we have a type for fixtures makes it easier to check for fixture types and no need to perform cast on objects. Removed monkey patching. - They are no longer functions that carry a class within them. - When you decorate a function with a fixture it's not possible to call it anymore. So it makes more sense for it to not be a function or method. Fixes #11525 --------- Co-authored-by: Sviatoslav Sydorenko (Святослав Сидоренко) <[email protected]> Co-authored-by: Bruno Oliveira <[email protected]> Co-authored-by: Ran Benita <[email protected]>
1 parent 44cb841 commit ecde993

File tree

11 files changed

+203
-133
lines changed

11 files changed

+203
-133
lines changed

AUTHORS

+1
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,7 @@ Serhii Mozghovyi
393393
Seth Junot
394394
Shantanu Jain
395395
Sharad Nair
396+
Shaygan Hooshyari
396397
Shubham Adep
397398
Simon Blanchard
398399
Simon Gomizelj

changelog/11525.improvement.rst

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Fixtures are now clearly represented in the output as a "fixture object", not as a normal function as before, making it easy for beginners to catch mistakes such as referencing a fixture declared in the same module but not requested in the test function.
2+
3+
-- by :user:`the-compiler` and :user:`glyphack`

doc/en/conf.py

+1
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
("py:class", "_pytest._code.code.TerminalRepr"),
7676
("py:class", "TerminalRepr"),
7777
("py:class", "_pytest.fixtures.FixtureFunctionMarker"),
78+
("py:class", "_pytest.fixtures.FixtureFunctionDefinition"),
7879
("py:class", "_pytest.logging.LogCaptureHandler"),
7980
("py:class", "_pytest.mark.structures.ParameterSet"),
8081
# Intentionally undocumented/private

src/_pytest/assertion/rewrite.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from _pytest._version import version
3232
from _pytest.assertion import util
3333
from _pytest.config import Config
34+
from _pytest.fixtures import FixtureFunctionDefinition
3435
from _pytest.main import Session
3536
from _pytest.pathlib import absolutepath
3637
from _pytest.pathlib import fnmatch_ex
@@ -472,7 +473,8 @@ def _format_assertmsg(obj: object) -> str:
472473

473474
def _should_repr_global_name(obj: object) -> bool:
474475
if callable(obj):
475-
return False
476+
# For pytest fixtures the __repr__ method provides more information than the function name.
477+
return isinstance(obj, FixtureFunctionDefinition)
476478

477479
try:
478480
return not hasattr(obj, "__name__")

src/_pytest/compat.py

+2-46
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
from __future__ import annotations
55

66
from collections.abc import Callable
7-
import dataclasses
87
import enum
98
import functools
109
import inspect
@@ -205,59 +204,16 @@ def ascii_escaped(val: bytes | str) -> str:
205204
return ret.translate(_non_printable_ascii_translate_table)
206205

207206

208-
@dataclasses.dataclass
209-
class _PytestWrapper:
210-
"""Dummy wrapper around a function object for internal use only.
211-
212-
Used to correctly unwrap the underlying function object when we are
213-
creating fixtures, because we wrap the function object ourselves with a
214-
decorator to issue warnings when the fixture function is called directly.
215-
"""
216-
217-
obj: Any
218-
219-
220207
def get_real_func(obj):
221208
"""Get the real function object of the (possibly) wrapped object by
222-
functools.wraps or functools.partial."""
223-
start_obj = obj
224-
for i in range(100):
225-
# __pytest_wrapped__ is set by @pytest.fixture when wrapping the fixture function
226-
# to trigger a warning if it gets called directly instead of by pytest: we don't
227-
# want to unwrap further than this otherwise we lose useful wrappings like @mock.patch (#3774)
228-
new_obj = getattr(obj, "__pytest_wrapped__", None)
229-
if isinstance(new_obj, _PytestWrapper):
230-
obj = new_obj.obj
231-
break
232-
new_obj = getattr(obj, "__wrapped__", None)
233-
if new_obj is None:
234-
break
235-
obj = new_obj
236-
else:
237-
from _pytest._io.saferepr import saferepr
209+
:func:`functools.wraps`, or :func:`functools.partial`."""
210+
obj = inspect.unwrap(obj)
238211

239-
raise ValueError(
240-
f"could not find real function of {saferepr(start_obj)}\nstopped at {saferepr(obj)}"
241-
)
242212
if isinstance(obj, functools.partial):
243213
obj = obj.func
244214
return obj
245215

246216

247-
def get_real_method(obj, holder):
248-
"""Attempt to obtain the real function object that might be wrapping
249-
``obj``, while at the same time returning a bound method to ``holder`` if
250-
the original object was a bound method."""
251-
try:
252-
is_method = hasattr(obj, "__func__")
253-
obj = get_real_func(obj)
254-
except Exception: # pragma: no cover
255-
return obj
256-
if is_method and hasattr(obj, "__get__") and callable(obj.__get__):
257-
obj = obj.__get__(holder)
258-
return obj
259-
260-
261217
def getimfunc(func):
262218
try:
263219
return func.__func__

src/_pytest/fixtures.py

+88-69
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,8 @@
4040
from _pytest._code.code import FormattedExcinfo
4141
from _pytest._code.code import TerminalRepr
4242
from _pytest._io import TerminalWriter
43-
from _pytest.compat import _PytestWrapper
4443
from _pytest.compat import assert_never
4544
from _pytest.compat import get_real_func
46-
from _pytest.compat import get_real_method
4745
from _pytest.compat import getfuncargnames
4846
from _pytest.compat import getimfunc
4947
from _pytest.compat import getlocation
@@ -152,13 +150,12 @@ def get_scope_node(node: nodes.Node, scope: Scope) -> nodes.Node | None:
152150
assert_never(scope)
153151

154152

153+
# TODO: Try to use FixtureFunctionDefinition instead of the marker
155154
def getfixturemarker(obj: object) -> FixtureFunctionMarker | None:
156-
"""Return fixturemarker or None if it doesn't exist or raised
157-
exceptions."""
158-
return cast(
159-
Optional[FixtureFunctionMarker],
160-
safe_getattr(obj, "_pytestfixturefunction", None),
161-
)
155+
"""Return fixturemarker or None if it doesn't exist"""
156+
if isinstance(obj, FixtureFunctionDefinition):
157+
return obj._fixture_function_marker
158+
return None
162159

163160

164161
# Algorithm for sorting on a per-parametrized resource setup basis.
@@ -1181,31 +1178,6 @@ def pytest_fixture_setup(
11811178
return result
11821179

11831180

1184-
def wrap_function_to_error_out_if_called_directly(
1185-
function: FixtureFunction,
1186-
fixture_marker: FixtureFunctionMarker,
1187-
) -> FixtureFunction:
1188-
"""Wrap the given fixture function so we can raise an error about it being called directly,
1189-
instead of used as an argument in a test function."""
1190-
name = fixture_marker.name or function.__name__
1191-
message = (
1192-
f'Fixture "{name}" called directly. Fixtures are not meant to be called directly,\n'
1193-
"but are created automatically when test functions request them as parameters.\n"
1194-
"See https://docs.pytest.org/en/stable/explanation/fixtures.html for more information about fixtures, and\n"
1195-
"https://docs.pytest.org/en/stable/deprecations.html#calling-fixtures-directly about how to update your code."
1196-
)
1197-
1198-
@functools.wraps(function)
1199-
def result(*args, **kwargs):
1200-
fail(message, pytrace=False)
1201-
1202-
# Keep reference to the original function in our own custom attribute so we don't unwrap
1203-
# further than this point and lose useful wrappings like @mock.patch (#3774).
1204-
result.__pytest_wrapped__ = _PytestWrapper(function) # type: ignore[attr-defined]
1205-
1206-
return cast(FixtureFunction, result)
1207-
1208-
12091181
@final
12101182
@dataclasses.dataclass(frozen=True)
12111183
class FixtureFunctionMarker:
@@ -1220,19 +1192,21 @@ class FixtureFunctionMarker:
12201192
def __post_init__(self, _ispytest: bool) -> None:
12211193
check_ispytest(_ispytest)
12221194

1223-
def __call__(self, function: FixtureFunction) -> FixtureFunction:
1195+
def __call__(self, function: FixtureFunction) -> FixtureFunctionDefinition:
12241196
if inspect.isclass(function):
12251197
raise ValueError("class fixtures not supported (maybe in the future)")
12261198

1227-
if getattr(function, "_pytestfixturefunction", False):
1199+
if isinstance(function, FixtureFunctionDefinition):
12281200
raise ValueError(
12291201
f"@pytest.fixture is being applied more than once to the same function {function.__name__!r}"
12301202
)
12311203

12321204
if hasattr(function, "pytestmark"):
12331205
warnings.warn(MARKED_FIXTURE, stacklevel=2)
12341206

1235-
function = wrap_function_to_error_out_if_called_directly(function, self)
1207+
fixture_definition = FixtureFunctionDefinition(
1208+
function=function, fixture_function_marker=self, _ispytest=True
1209+
)
12361210

12371211
name = self.name or function.__name__
12381212
if name == "request":
@@ -1242,21 +1216,68 @@ def __call__(self, function: FixtureFunction) -> FixtureFunction:
12421216
pytrace=False,
12431217
)
12441218

1245-
# Type ignored because https://github.com/python/mypy/issues/2087.
1246-
function._pytestfixturefunction = self # type: ignore[attr-defined]
1247-
return function
1219+
return fixture_definition
1220+
1221+
1222+
# TODO: paramspec/return type annotation tracking and storing
1223+
class FixtureFunctionDefinition:
1224+
def __init__(
1225+
self,
1226+
*,
1227+
function: Callable[..., Any],
1228+
fixture_function_marker: FixtureFunctionMarker,
1229+
instance: object | None = None,
1230+
_ispytest: bool = False,
1231+
) -> None:
1232+
check_ispytest(_ispytest)
1233+
self.name = fixture_function_marker.name or function.__name__
1234+
# In order to show the function that this fixture contains in messages.
1235+
# Set the __name__ to be same as the function __name__ or the given fixture name.
1236+
self.__name__ = self.name
1237+
self._fixture_function_marker = fixture_function_marker
1238+
if instance is not None:
1239+
self._fixture_function = cast(
1240+
Callable[..., Any], function.__get__(instance)
1241+
)
1242+
else:
1243+
self._fixture_function = function
1244+
functools.update_wrapper(self, function)
1245+
1246+
def __repr__(self) -> str:
1247+
return f"<pytest_fixture({self._fixture_function})>"
1248+
1249+
def __get__(self, instance, owner=None):
1250+
"""Behave like a method if the function it was applied to was a method."""
1251+
return FixtureFunctionDefinition(
1252+
function=self._fixture_function,
1253+
fixture_function_marker=self._fixture_function_marker,
1254+
instance=instance,
1255+
_ispytest=True,
1256+
)
1257+
1258+
def __call__(self, *args: Any, **kwds: Any) -> Any:
1259+
message = (
1260+
f'Fixture "{self.name}" called directly. Fixtures are not meant to be called directly,\n'
1261+
"but are created automatically when test functions request them as parameters.\n"
1262+
"See https://docs.pytest.org/en/stable/explanation/fixtures.html for more information about fixtures, and\n"
1263+
"https://docs.pytest.org/en/stable/deprecations.html#calling-fixtures-directly"
1264+
)
1265+
fail(message, pytrace=False)
1266+
1267+
def _get_wrapped_function(self) -> Callable[..., Any]:
1268+
return self._fixture_function
12481269

12491270

12501271
@overload
12511272
def fixture(
1252-
fixture_function: FixtureFunction,
1273+
fixture_function: Callable[..., object],
12531274
*,
12541275
scope: _ScopeName | Callable[[str, Config], _ScopeName] = ...,
12551276
params: Iterable[object] | None = ...,
12561277
autouse: bool = ...,
12571278
ids: Sequence[object | None] | Callable[[Any], object | None] | None = ...,
12581279
name: str | None = ...,
1259-
) -> FixtureFunction: ...
1280+
) -> FixtureFunctionDefinition: ...
12601281

12611282

12621283
@overload
@@ -1279,7 +1300,7 @@ def fixture(
12791300
autouse: bool = False,
12801301
ids: Sequence[object | None] | Callable[[Any], object | None] | None = None,
12811302
name: str | None = None,
1282-
) -> FixtureFunctionMarker | FixtureFunction:
1303+
) -> FixtureFunctionMarker | FixtureFunctionDefinition:
12831304
"""Decorator to mark a fixture factory function.
12841305
12851306
This decorator can be used, with or without parameters, to define a
@@ -1774,33 +1795,31 @@ def parsefactories(
17741795
# The attribute can be an arbitrary descriptor, so the attribute
17751796
# access below can raise. safe_getattr() ignores such exceptions.
17761797
obj_ub = safe_getattr(holderobj_tp, name, None)
1777-
marker = getfixturemarker(obj_ub)
1778-
if not isinstance(marker, FixtureFunctionMarker):
1779-
# Magic globals with __getattr__ might have got us a wrong
1780-
# fixture attribute.
1781-
continue
1782-
1783-
# OK we know it is a fixture -- now safe to look up on the _instance_.
1784-
obj = getattr(holderobj, name)
1785-
1786-
if marker.name:
1787-
name = marker.name
1788-
1789-
# During fixture definition we wrap the original fixture function
1790-
# to issue a warning if called directly, so here we unwrap it in
1791-
# order to not emit the warning when pytest itself calls the
1792-
# fixture function.
1793-
func = get_real_method(obj, holderobj)
1794-
1795-
self._register_fixture(
1796-
name=name,
1797-
nodeid=nodeid,
1798-
func=func,
1799-
scope=marker.scope,
1800-
params=marker.params,
1801-
ids=marker.ids,
1802-
autouse=marker.autouse,
1803-
)
1798+
if type(obj_ub) is FixtureFunctionDefinition:
1799+
marker = obj_ub._fixture_function_marker
1800+
if marker.name:
1801+
fixture_name = marker.name
1802+
else:
1803+
fixture_name = name
1804+
1805+
# OK we know it is a fixture -- now safe to look up on the _instance_.
1806+
try:
1807+
obj = getattr(holderobj, name)
1808+
# if the fixture is named in the decorator we cannot find it in the module
1809+
except AttributeError:
1810+
obj = obj_ub
1811+
1812+
func = obj._get_wrapped_function()
1813+
1814+
self._register_fixture(
1815+
name=fixture_name,
1816+
nodeid=nodeid,
1817+
func=func,
1818+
scope=marker.scope,
1819+
params=marker.params,
1820+
ids=marker.ids,
1821+
autouse=marker.autouse,
1822+
)
18041823

18051824
def getfixturedefs(
18061825
self, argname: str, node: nodes.Node

testing/code/test_source.py

+6-6
Original file line numberDiff line numberDiff line change
@@ -478,14 +478,14 @@ def deco_mark():
478478
def deco_fixture():
479479
assert False
480480

481-
src = inspect.getsource(deco_fixture)
481+
src = inspect.getsource(deco_fixture._get_wrapped_function())
482482
assert src == " @pytest.fixture\n def deco_fixture():\n assert False\n"
483-
# currently Source does not unwrap decorators, testing the
484-
# existing behavior here for explicitness, but perhaps we should revisit/change this
485-
# in the future
486-
assert str(Source(deco_fixture)).startswith("@functools.wraps(function)")
483+
# Make sure the decorator is not a wrapped function
484+
assert not str(Source(deco_fixture)).startswith("@functools.wraps(function)")
487485
assert (
488-
textwrap.indent(str(Source(get_real_func(deco_fixture))), " ") + "\n" == src
486+
textwrap.indent(str(Source(deco_fixture._get_wrapped_function())), " ")
487+
+ "\n"
488+
== src
489489
)
490490

491491

0 commit comments

Comments
 (0)