diff --git a/AUTHORS b/AUTHORS index 347efad57..63e5dd60a 100644 --- a/AUTHORS +++ b/AUTHORS @@ -376,6 +376,7 @@ Serhii Mozghovyi Seth Junot Shantanu Jain Sharad Nair +Shaygan Hooshyari Shubham Adep Simon Blanchard Simon Gomizelj diff --git a/changelog/11525.improvement.rst b/changelog/11525.improvement.rst new file mode 100644 index 000000000..0a4c41abb --- /dev/null +++ b/changelog/11525.improvement.rst @@ -0,0 +1,3 @@ +The fixtures are now represented as fixture in test output. + +-- by :user:`the-compiler` and :user:`glyphack`. diff --git a/doc/en/conf.py b/doc/en/conf.py index 3e87003c5..a5a09fa29 100644 --- a/doc/en/conf.py +++ b/doc/en/conf.py @@ -190,6 +190,7 @@ nitpick_ignore = [ ("py:class", "TerminalReporter"), ("py:class", "_pytest._code.code.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 diff --git a/src/_pytest/assertion/rewrite.py b/src/_pytest/assertion/rewrite.py index b29a254f5..550651e6d 100644 --- a/src/_pytest/assertion/rewrite.py +++ b/src/_pytest/assertion/rewrite.py @@ -35,6 +35,7 @@ from _pytest._io.saferepr import saferepr 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 @@ -462,7 +463,7 @@ def _format_assertmsg(obj: object) -> str: def _should_repr_global_name(obj: object) -> bool: if callable(obj): - return False + return isinstance(obj, FixtureFunctionDefinition) try: return not hasattr(obj, "__name__") diff --git a/src/_pytest/compat.py b/src/_pytest/compat.py index 614848e0d..bd458d9aa 100644 --- a/src/_pytest/compat.py +++ b/src/_pytest/compat.py @@ -3,7 +3,6 @@ from __future__ import annotations -import dataclasses import enum import functools import inspect @@ -210,29 +209,15 @@ 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.""" + functools.wraps or functools.partial or pytest.fixture""" + from _pytest.fixtures import FixtureFunctionDefinition + 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 + for _ in range(100): + if isinstance(obj, FixtureFunctionDefinition): + obj = obj._get_wrapped_function() break new_obj = getattr(obj, "__wrapped__", None) if new_obj is None: @@ -249,20 +234,6 @@ def get_real_func(obj): 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__ diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 383084e07..4f099ebf7 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -42,10 +42,8 @@ from _pytest._code import Source 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 @@ -153,12 +151,10 @@ def get_scope_node(node: nodes.Node, scope: Scope) -> Optional[nodes.Node]: def getfixturemarker(obj: object) -> Optional["FixtureFunctionMarker"]: - """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. @@ -1145,31 +1141,6 @@ def pytest_fixture_setup( 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) - 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: @@ -1186,11 +1157,11 @@ class FixtureFunctionMarker: 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}" ) @@ -1198,7 +1169,7 @@ class FixtureFunctionMarker: 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, self) name = self.name or function.__name__ if name == "request": @@ -1208,14 +1179,50 @@ class FixtureFunctionMarker: pytrace=False, ) - # Type ignored because https://github.com/python/mypy/issues/2087. - function._pytestfixturefunction = self # type: ignore[attr-defined] - return function + return fixture_definition + + +class FixtureFunctionDefinition: + def __init__( + self, + function: Callable[..., Any], + fixture_function_marker: FixtureFunctionMarker, + instance: Optional[type] = None, + ): + self.name = fixture_function_marker.name or function.__name__ + self.__name__ = self.name + self._fixture_function_marker = fixture_function_marker + self._fixture_function = function + self._instance = instance + + def __repr__(self) -> str: + return f"pytest_fixture({self._fixture_function})" + + def __get__(self, instance, owner=None): + return FixtureFunctionDefinition( + self._fixture_function, self._fixture_function_marker, instance + ) + + 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]: + if self._instance is not None: + return cast( + Callable[..., Any], self._fixture_function.__get__(self._instance) + ) + return self._fixture_function @overload def fixture( - fixture_function: FixtureFunction, + fixture_function: Callable[..., object], *, scope: "Union[_ScopeName, Callable[[str, Config], _ScopeName]]" = ..., params: Optional[Iterable[object]] = ..., @@ -1224,7 +1231,7 @@ def fixture( Union[Sequence[Optional[object]], Callable[[Any], Optional[object]]] ] = ..., name: Optional[str] = ..., -) -> FixtureFunction: ... +) -> FixtureFunctionDefinition: ... @overload @@ -1238,7 +1245,7 @@ def fixture( Union[Sequence[Optional[object]], Callable[[Any], Optional[object]]] ] = ..., name: Optional[str] = None, -) -> FixtureFunctionMarker: ... +) -> FixtureFunctionDefinition: ... def fixture( @@ -1251,7 +1258,7 @@ def fixture( Union[Sequence[Optional[object]], Callable[[Any], Optional[object]]] ] = None, name: Optional[str] = None, -) -> Union[FixtureFunctionMarker, FixtureFunction]: +) -> Union[FixtureFunctionMarker, FixtureFunctionDefinition]: """Decorator to mark a fixture factory function. This decorator can be used, with or without parameters, to define a @@ -1321,7 +1328,7 @@ def fixture( def yield_fixture( fixture_function=None, *args, - scope="function", + scope: _ScopeName = "function", params=None, autouse=False, ids=None, @@ -1734,33 +1741,21 @@ class FixtureManager: self._holderobjseen.add(holderobj) for name in dir(holderobj): - # The attribute can be an arbitrary descriptor, so the attribute - # access below can raise. safe_getatt() ignores such exceptions. obj = safe_getattr(holderobj, name, None) - marker = getfixturemarker(obj) - if not isinstance(marker, FixtureFunctionMarker): - # Magic globals with __getattr__ might have got us a wrong - # fixture attribute. - continue - - 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) is FixtureFunctionDefinition: + marker = obj._fixture_function_marker + if marker.name: + name = marker.name + func = obj._get_wrapped_function() + self._register_fixture( + name=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 diff --git a/testing/code/test_source.py b/testing/code/test_source.py index a00259976..1bf88fed4 100644 --- a/testing/code/test_source.py +++ b/testing/code/test_source.py @@ -478,12 +478,13 @@ def test_source_with_decorator() -> None: def deco_fixture(): assert False - src = inspect.getsource(deco_fixture) + # Since deco_fixture is now an instance of FixtureFunctionDef the getsource function will not work on it. + with pytest.raises(Exception): + 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 ) diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index d3cff38f9..b07d4cdfa 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -1,4 +1,5 @@ # mypy: allow-untyped-defs +import inspect import os from pathlib import Path import sys @@ -3295,6 +3296,33 @@ class TestFixtureMarker: assert output1 == output2 +class FixtureFunctionDefTestClass: + def __init__(self) -> None: + self.i = 10 + + @pytest.fixture + def fixture_function_def_test_method(self): + return self.i + + +@pytest.fixture +def fixture_function_def_test_func(): + return 9 + + +def test_get_wrapped_func_returns_method(): + obj = FixtureFunctionDefTestClass() + wrapped_function_result = ( + obj.fixture_function_def_test_method._get_wrapped_function() + ) + assert inspect.ismethod(wrapped_function_result) + assert wrapped_function_result() == 10 + + +def test_get_wrapped_func_returns_function(): + assert fixture_function_def_test_func._get_wrapped_function()() == 9 + + class TestRequestScopeAccess: pytestmark = pytest.mark.parametrize( ("scope", "ok", "error"), @@ -4465,6 +4493,21 @@ def test_fixture_double_decorator(pytester: Pytester) -> None: ) +def test_fixture_class(pytester: Pytester) -> None: + """Check if an error is raised when using @pytest.fixture on a class.""" + pytester.makepyfile( + """ + import pytest + + @pytest.fixture + class A: + pass + """ + ) + result = pytester.runpytest() + result.assert_outcomes(errors=1) + + def test_fixture_param_shadowing(pytester: Pytester) -> None: """Parametrized arguments would be shadowed if a fixture with the same name also exists (#5036)""" pytester.makepyfile( diff --git a/testing/test_assertrewrite.py b/testing/test_assertrewrite.py index 8db9dbbe5..b6e4c4112 100644 --- a/testing/test_assertrewrite.py +++ b/testing/test_assertrewrite.py @@ -744,6 +744,23 @@ class TestAssertionRewrite: assert "UnicodeDecodeError" not in msg assert "UnicodeEncodeError" not in msg + def test_assert_fixture(self, pytester: Pytester) -> None: + pytester.makepyfile( + """\ + import pytest + @pytest.fixture + def fixt(): + return 42 + + def test_something(): # missing "fixt" argument + assert fixt == 42 + """ + ) + result = pytester.runpytest() + result.stdout.fnmatch_lines( + ["*assert pytest_fixture() == 42*"] + ) + class TestRewriteOnImport: def test_pycache_is_a_file(self, pytester: Pytester) -> None: diff --git a/testing/test_collection.py b/testing/test_collection.py index 8ff38a334..c1eb236aa 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -1310,7 +1310,7 @@ def test_collect_handles_raising_on_dunder_class(pytester: Pytester) -> None: """ ) result = pytester.runpytest() - result.stdout.fnmatch_lines(["*1 passed in*"]) + result.assert_outcomes(passed=1) assert result.ret == 0 @@ -1374,7 +1374,7 @@ def test_collect_pyargs_with_testpaths( with monkeypatch.context() as mp: mp.chdir(root) result = pytester.runpytest_subprocess() - result.stdout.fnmatch_lines(["*1 passed in*"]) + result.assert_outcomes(passed=1) def test_initial_conftests_with_testpaths(pytester: Pytester) -> None: diff --git a/testing/test_compat.py b/testing/test_compat.py index 73ac1bad8..5af45b4ca 100644 --- a/testing/test_compat.py +++ b/testing/test_compat.py @@ -7,7 +7,6 @@ import sys from typing import TYPE_CHECKING from typing import Union -from _pytest.compat import _PytestWrapper from _pytest.compat import assert_never from _pytest.compat import get_real_func from _pytest.compat import is_generator @@ -52,8 +51,8 @@ def test_real_func_loop_limit() -> None: with pytest.raises( ValueError, match=( - "could not find real function of \n" - "stopped at " + "could not find real function of \n" + "stopped at " ), ): get_real_func(evil) @@ -78,10 +77,13 @@ def test_get_real_func() -> None: wrapped_func2 = decorator(decorator(wrapped_func)) assert get_real_func(wrapped_func2) is func - # special case for __pytest_wrapped__ attribute: used to obtain the function up until the point - # a function was wrapped by pytest itself - wrapped_func2.__pytest_wrapped__ = _PytestWrapper(wrapped_func) - assert get_real_func(wrapped_func2) is wrapped_func + # obtain the function up until the point a function was wrapped by pytest itself + @pytest.fixture + def wrapped_func3(): + pass # pragma: no cover + + wrapped_func4 = decorator(wrapped_func3) + assert get_real_func(wrapped_func4) is wrapped_func3._get_wrapped_function() def test_get_real_func_partial() -> None: