Skip to content

(fixtures): Replace fixture represenation with a class #12473

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 53 commits into from
Dec 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
41c7c2c
feat: use repr for pytest fixtures
Glyphack Jun 18, 2024
cee9044
(fixture) create a new class to represent fixtures
Glyphack Jun 19, 2024
004f29f
fix: update code to use new fixture definitions
Glyphack Jun 20, 2024
224595a
Update AUTHORS
Glyphack Jun 20, 2024
8666dfb
chore: add changelog entry
Glyphack Jun 20, 2024
b724030
add test case for asserting missing fixture
Glyphack Jun 20, 2024
4552e06
refactor: removed PytestWrapper class
Glyphack Jun 20, 2024
1efc230
chore: update changelog
Glyphack Jun 20, 2024
b6f87a3
refactor: remove _pytestfixturefunction attribute
Glyphack Jun 20, 2024
bc69c19
refactor: remove unused code
Glyphack Jun 20, 2024
da5e8e9
ci: set no cover for test function
Glyphack Jun 20, 2024
ec7f7b1
docs: fix docstring
Glyphack Jun 20, 2024
f61c23c
refactor: replace attribute check with type check
Glyphack Jun 20, 2024
144befb
chore: update changelog
Glyphack Jun 20, 2024
e556218
test: add test for _get_wrapped_function
Glyphack Jun 20, 2024
a48b2a0
refactor: remove unused code
Glyphack Jun 20, 2024
c7d8339
refactor: replace type with isinstance
Glyphack Jun 20, 2024
6ba2370
docs: update docstring
Glyphack Jun 20, 2024
3bc5ad1
refactor: use instance check to print repr
Glyphack Jun 20, 2024
04a0cde
docs: ignore FixtureFunctionDefinition from docs
Glyphack Jun 20, 2024
9822c94
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 4, 2024
786aec5
Update changelog/11525.improvement.rst
Glyphack Jul 4, 2024
afe72f6
Update src/_pytest/fixtures.py
Glyphack Jul 4, 2024
3b66f72
Update src/_pytest/fixtures.py
Glyphack Jul 4, 2024
149cc30
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 4, 2024
eb3943c
Update src/_pytest/compat.py
Glyphack Jul 4, 2024
2891be5
fix: update fixture repr in text
Glyphack Jul 30, 2024
1734722
fix: check TypeError raised
Glyphack Jul 30, 2024
d749473
test: remove redundant test
Glyphack Oct 23, 2024
fa2f2b5
Fix tests
Glyphack Oct 23, 2024
1b5b4c7
Handle scenarios where fixture is renamed in decorator
Glyphack Oct 23, 2024
6ee850a
Add follow ups as todo comments
Glyphack Oct 27, 2024
131e306
Remove get_real_func loop
Glyphack Oct 27, 2024
f062ff6
Use _get_wrapped_function to unwrap fixtures
Glyphack Nov 23, 2024
7f1b8d3
Merge branch 'main' into reprs-assert-callable
Glyphack Nov 25, 2024
fb4ce59
Update changelog/11525.improvement.rst
Glyphack Dec 5, 2024
3f22fe5
Update src/_pytest/fixtures.py
Glyphack Dec 5, 2024
98ea2f5
Update src/_pytest/fixtures.py
Glyphack Dec 5, 2024
72269d8
Update src/_pytest/fixtures.py
Glyphack Dec 5, 2024
d625874
Pass args by name to FixtureFunctionDefinition
Glyphack Dec 5, 2024
2f1a17e
Add comments for __name__ and __get__
Glyphack Dec 5, 2024
d909768
Check non-internal use of FixtureFunctionDefinition
Glyphack Dec 5, 2024
56899f6
Use pytester
Glyphack Dec 5, 2024
ad3f0fa
Update test description
Glyphack Dec 5, 2024
845d50e
Merge branch 'main' of github.com:pytest-dev/pytest into reprs-assert…
Glyphack Dec 5, 2024
a6d0998
Revert changing fixture annotation
Glyphack Dec 6, 2024
0bb4f14
Revert yield_fixture annotation
Glyphack Dec 6, 2024
68a66b5
use functool.wraps when applying fixture
Glyphack Dec 6, 2024
a160a6a
Remove __wrapped__ attribute from FixtureFunctionDefinition
Glyphack Dec 6, 2024
b1de16a
Bind fixture_function when there is an instance
Glyphack Dec 6, 2024
3ba7108
Remove unused attribute
Glyphack Dec 6, 2024
475deb1
get_real_func will unwrap the underlying fixture function
Glyphack Dec 7, 2024
723a8f3
Merge branch 'main' into reprs-assert-callable
Glyphack Dec 7, 2024
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 AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,7 @@ Serhii Mozghovyi
Seth Junot
Shantanu Jain
Sharad Nair
Shaygan Hooshyari
Shubham Adep
Simon Blanchard
Simon Gomizelj
Expand Down
3 changes: 3 additions & 0 deletions changelog/11525.improvement.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
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.

-- by :user:`the-compiler` and :user:`glyphack`
1 change: 1 addition & 0 deletions doc/en/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
("py:class", "_pytest._code.code.TerminalRepr"),
("py:class", "TerminalRepr"),
("py:class", "_pytest.fixtures.FixtureFunctionMarker"),
("py:class", "_pytest.fixtures.FixtureFunctionDefinition"),
("py:class", "_pytest.logging.LogCaptureHandler"),
("py:class", "_pytest.mark.structures.ParameterSet"),
# Intentionally undocumented/private
Expand Down
4 changes: 3 additions & 1 deletion src/_pytest/assertion/rewrite.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from _pytest._version import version
from _pytest.assertion import util
from _pytest.config import Config
from _pytest.fixtures import FixtureFunctionDefinition
from _pytest.main import Session
from _pytest.pathlib import absolutepath
from _pytest.pathlib import fnmatch_ex
Expand Down Expand Up @@ -472,7 +473,8 @@ def _format_assertmsg(obj: object) -> str:

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

try:
return not hasattr(obj, "__name__")
Expand Down
48 changes: 2 additions & 46 deletions src/_pytest/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from __future__ import annotations

from collections.abc import Callable
import dataclasses
import enum
import functools
import inspect
Expand Down Expand Up @@ -205,59 +204,16 @@ def ascii_escaped(val: bytes | str) -> str:
return ret.translate(_non_printable_ascii_translate_table)


@dataclasses.dataclass
class _PytestWrapper:
"""Dummy wrapper around a function object for internal use only.
Used to correctly unwrap the underlying function object when we are
creating fixtures, because we wrap the function object ourselves with a
decorator to issue warnings when the fixture function is called directly.
"""

obj: Any


def get_real_func(obj):
"""Get the real function object of the (possibly) wrapped object by
functools.wraps or functools.partial."""
start_obj = obj
for i in range(100):
# __pytest_wrapped__ is set by @pytest.fixture when wrapping the fixture function
# to trigger a warning if it gets called directly instead of by pytest: we don't
# want to unwrap further than this otherwise we lose useful wrappings like @mock.patch (#3774)
new_obj = getattr(obj, "__pytest_wrapped__", None)
if isinstance(new_obj, _PytestWrapper):
obj = new_obj.obj
break
new_obj = getattr(obj, "__wrapped__", None)
if new_obj is None:
break
obj = new_obj
else:
from _pytest._io.saferepr import saferepr
:func:`functools.wraps`, or :func:`functools.partial`."""
obj = inspect.unwrap(obj)

raise ValueError(
f"could not find real function of {saferepr(start_obj)}\nstopped at {saferepr(obj)}"
)
if isinstance(obj, functools.partial):
obj = obj.func
return obj


def get_real_method(obj, holder):
"""Attempt to obtain the real function object that might be wrapping
``obj``, while at the same time returning a bound method to ``holder`` if
the original object was a bound method."""
try:
is_method = hasattr(obj, "__func__")
obj = get_real_func(obj)
except Exception: # pragma: no cover
return obj
if is_method and hasattr(obj, "__get__") and callable(obj.__get__):
obj = obj.__get__(holder)
return obj


def getimfunc(func):
try:
return func.__func__
Expand Down
157 changes: 88 additions & 69 deletions src/_pytest/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,8 @@
from _pytest._code.code import FormattedExcinfo
from _pytest._code.code import TerminalRepr
from _pytest._io import TerminalWriter
from _pytest.compat import _PytestWrapper
from _pytest.compat import assert_never
from _pytest.compat import get_real_func
from _pytest.compat import get_real_method
from _pytest.compat import getfuncargnames
from _pytest.compat import getimfunc
from _pytest.compat import getlocation
Expand Down Expand Up @@ -152,13 +150,12 @@
assert_never(scope)


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


# Algorithm for sorting on a per-parametrized resource setup basis.
Expand Down Expand Up @@ -1181,31 +1178,6 @@
return result


def wrap_function_to_error_out_if_called_directly(
function: FixtureFunction,
fixture_marker: FixtureFunctionMarker,
) -> FixtureFunction:
"""Wrap the given fixture function so we can raise an error about it being called directly,
instead of used as an argument in a test function."""
name = fixture_marker.name or function.__name__
message = (
f'Fixture "{name}" called directly. Fixtures are not meant to be called directly,\n'
"but are created automatically when test functions request them as parameters.\n"
"See https://docs.pytest.org/en/stable/explanation/fixtures.html for more information about fixtures, and\n"
"https://docs.pytest.org/en/stable/deprecations.html#calling-fixtures-directly about how to update your code."
)

@functools.wraps(function)
Copy link
Member

Choose a reason for hiding this comment

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

The previous code used functools.wraps, while the new code does the wrapping "manually". But functools.wraps/functools.update_wrapper does some stuff like copy __doc__ and such. Are we losing that?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes unfortunately the new result is this object instead of a wrapped function. I was not aware of this thanks for noticing it. Since the methods that are copied are not a lot shall I just assign them just like we do with __name__?

Copy link
Member

Choose a reason for hiding this comment

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

I think the best thing would be to use functools.update_wrapper in FixtureFunctionDefinition.__init__, unless there is something preventing it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I did this the only problem was that when using
functools.update_wrapper(self, function)
The FixtureFunctionDefinition will have a __wrapped__ attribute which will be followed by inspect.unwrap. I removed this attribute so we don't accidentally unwrap the fixture without using _get_wrapped_function method.
If we don't remove this then we need to be careful to provide stop for inspect.unwrap when it's used.

Copy link
Member

Choose a reason for hiding this comment

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

I was actually thinking that __wrapper__ would be good to have, since FixtureFunctionDefinition is indeed a wrapper. What are the places where it causes an issue? (I can look it up myself a bit later, but in case you tried it already).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Please see this commit that fixes the issue: 475deb1

I agree with doing this. Initially I was lazily binding methods, which led me to use _get_wrapped_function. Now with binding in the init we can rely on inspect.unwrap to get the underlying fixture. Less code for us. Thank you for the suggestion.

def result(*args, **kwargs):
fail(message, pytrace=False)

# Keep reference to the original function in our own custom attribute so we don't unwrap
# further than this point and lose useful wrappings like @mock.patch (#3774).
result.__pytest_wrapped__ = _PytestWrapper(function) # type: ignore[attr-defined]

return cast(FixtureFunction, result)


@final
@dataclasses.dataclass(frozen=True)
class FixtureFunctionMarker:
Expand All @@ -1220,19 +1192,21 @@
def __post_init__(self, _ispytest: bool) -> None:
check_ispytest(_ispytest)

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

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

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

function = wrap_function_to_error_out_if_called_directly(function, self)
fixture_definition = FixtureFunctionDefinition(
function=function, fixture_function_marker=self, _ispytest=True
)

name = self.name or function.__name__
if name == "request":
Expand All @@ -1242,21 +1216,68 @@
pytrace=False,
)

# Type ignored because https://github.com/python/mypy/issues/2087.
function._pytestfixturefunction = self # type: ignore[attr-defined]
return function
return fixture_definition


# TODO: paramspec/return type annotation tracking and storing
class FixtureFunctionDefinition:
def __init__(
self,
*,
function: Callable[..., Any],
fixture_function_marker: FixtureFunctionMarker,
instance: object | None = None,
_ispytest: bool = False,
) -> None:
check_ispytest(_ispytest)
self.name = fixture_function_marker.name or function.__name__
# In order to show the function that this fixture contains in messages.
# Set the __name__ to be same as the function __name__ or the given fixture name.
self.__name__ = self.name
self._fixture_function_marker = fixture_function_marker
if instance is not None:
self._fixture_function = cast(
Callable[..., Any], function.__get__(instance)
)
else:
self._fixture_function = function
functools.update_wrapper(self, function)

def __repr__(self) -> str:
return f"<pytest_fixture({self._fixture_function})>"

def __get__(self, instance, owner=None):
"""Behave like a method if the function it was applied to was a method."""
return FixtureFunctionDefinition(
function=self._fixture_function,
fixture_function_marker=self._fixture_function_marker,
instance=instance,
_ispytest=True,
)

def __call__(self, *args: Any, **kwds: Any) -> Any:
message = (
f'Fixture "{self.name}" called directly. Fixtures are not meant to be called directly,\n'
"but are created automatically when test functions request them as parameters.\n"
"See https://docs.pytest.org/en/stable/explanation/fixtures.html for more information about fixtures, and\n"
"https://docs.pytest.org/en/stable/deprecations.html#calling-fixtures-directly"
)
fail(message, pytrace=False)

def _get_wrapped_function(self) -> Callable[..., Any]:
return self._fixture_function


@overload
def fixture(
fixture_function: FixtureFunction,
fixture_function: Callable[..., object],
*,
scope: _ScopeName | Callable[[str, Config], _ScopeName] = ...,
params: Iterable[object] | None = ...,
autouse: bool = ...,
ids: Sequence[object | None] | Callable[[Any], object | None] | None = ...,
name: str | None = ...,
) -> FixtureFunction: ...
) -> FixtureFunctionDefinition: ...


@overload
Expand All @@ -1279,7 +1300,7 @@
autouse: bool = False,
ids: Sequence[object | None] | Callable[[Any], object | None] | None = None,
name: str | None = None,
) -> FixtureFunctionMarker | FixtureFunction:
) -> FixtureFunctionMarker | FixtureFunctionDefinition:
"""Decorator to mark a fixture factory function.

This decorator can be used, with or without parameters, to define a
Expand Down Expand Up @@ -1774,33 +1795,31 @@
# 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)
marker = getfixturemarker(obj_ub)
if not isinstance(marker, FixtureFunctionMarker):
# Magic globals with __getattr__ might have got us a wrong
# fixture attribute.
continue

# OK we know it is a fixture -- now safe to look up on the _instance_.
obj = getattr(holderobj, name)

if marker.name:
name = marker.name

# During fixture definition we wrap the original fixture function
# to issue a warning if called directly, so here we unwrap it in
# order to not emit the warning when pytest itself calls the
# fixture function.
func = get_real_method(obj, holderobj)

self._register_fixture(
name=name,
nodeid=nodeid,
func=func,
scope=marker.scope,
params=marker.params,
ids=marker.ids,
autouse=marker.autouse,
)
if type(obj_ub) is FixtureFunctionDefinition:
marker = obj_ub._fixture_function_marker
if marker.name:
fixture_name = marker.name
else:
fixture_name = name

# OK we know it is a fixture -- now safe to look up on the _instance_.
try:
obj = getattr(holderobj, name)
# if the fixture is named in the decorator we cannot find it in the module
except AttributeError:
obj = obj_ub

Check warning on line 1810 in src/_pytest/fixtures.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/fixtures.py#L1809-L1810

Added lines #L1809 - L1810 were not covered by tests

func = obj._get_wrapped_function()

self._register_fixture(
name=fixture_name,
nodeid=nodeid,
func=func,
scope=marker.scope,
params=marker.params,
ids=marker.ids,
autouse=marker.autouse,
)

def getfixturedefs(
self, argname: str, node: nodes.Node
Expand Down
12 changes: 6 additions & 6 deletions testing/code/test_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -478,14 +478,14 @@ def deco_mark():
def deco_fixture():
assert False

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


Expand Down
Loading
Loading