diff --git a/changelog/11284.deprecation.rst b/changelog/11284.deprecation.rst new file mode 100644 index 000000000..12bcbdc56 --- /dev/null +++ b/changelog/11284.deprecation.rst @@ -0,0 +1,3 @@ +Accessing ``item.funcargs`` with fixture names other than the direct ones, i.e. the direct args, the ones with ``autouse`` and the ones with ``usefixtures`` issues a warning. + +This will become an error in pytest 9. diff --git a/doc/en/deprecations.rst b/doc/en/deprecations.rst index b9a59d791..0d76c2d58 100644 --- a/doc/en/deprecations.rst +++ b/doc/en/deprecations.rst @@ -274,6 +274,17 @@ The accompanying ``py.path.local`` based paths have been deprecated: plugins whi resolved in future versions as we slowly get rid of the :pypi:`py` dependency (see :issue:`9283` for a longer discussion). +.. _item-funcargs-deprecation: + +Accessing ``item.funcargs`` with non-directly requested fixture names +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. versionremoved:: 8.1 + +Accessing ``item.funcargs`` with non-directly requested fixture names issues a warning and will be erroneous starting from pytest 9. +Directly requested fixtures are the direct arguments to the test, ``usefixtures`` fixtures and ``autouse`` fixtures. + +To request a fixture other than the directly requested ones, use :func:`request.getfixturevalue ` instead. .. _nose-deprecation: diff --git a/doc/en/example/simple.rst b/doc/en/example/simple.rst index 7064f61f0..178cecebd 100644 --- a/doc/en/example/simple.rst +++ b/doc/en/example/simple.rst @@ -843,7 +843,7 @@ case we just write some information out to a ``failures`` file: mode = "a" if os.path.exists("failures") else "w" with open("failures", mode, encoding="utf-8") as f: # let's also access a fixture for the fun of it - if "tmp_path" in item.fixturenames: + if "tmp_path" in item.funcargs: extra = " ({})".format(item.funcargs["tmp_path"]) else: extra = "" diff --git a/src/_pytest/assertion/rewrite.py b/src/_pytest/assertion/rewrite.py index 9f3b65e8f..b5bbc878a 100644 --- a/src/_pytest/assertion/rewrite.py +++ b/src/_pytest/assertion/rewrite.py @@ -18,6 +18,7 @@ import sys import tokenize import types from typing import Callable +from typing import DefaultDict from typing import Dict from typing import IO from typing import Iterable @@ -671,9 +672,9 @@ class AssertionRewriter(ast.NodeVisitor): else: self.enable_assertion_pass_hook = False self.source = source - self.scope: tuple[ast.AST, ...] = () - self.variables_overwrite: defaultdict[ - tuple[ast.AST, ...], Dict[str, str] + self.scope: Tuple[ast.AST, ...] = () + self.variables_overwrite: DefaultDict[ + Tuple[ast.AST, ...], Dict[str, str] ] = defaultdict(dict) def run(self, mod: ast.Module) -> None: diff --git a/src/_pytest/deprecated.py b/src/_pytest/deprecated.py index 56271c957..e75ea83a9 100644 --- a/src/_pytest/deprecated.py +++ b/src/_pytest/deprecated.py @@ -50,6 +50,13 @@ MARKED_FIXTURE = PytestRemovedIn9Warning( "See docs: https://docs.pytest.org/en/stable/deprecations.html#applying-a-mark-to-a-fixture-function" ) +ITEM_FUNCARGS_MEMBERS = PytestRemovedIn9Warning( + "Accessing `item.funcargs` with a fixture name not directly requested" + " by the item, through a direct argument, `usefixtures` marker or" + " an `autouse` fixture, is deprecated and will raise KeyError starting" + " from pytest 9. Please use request.getfixturevalue instead." +) + # You want to make some `__init__` or function "private". # # def my_private_function(some, args): diff --git a/src/_pytest/doctest.py b/src/_pytest/doctest.py index fdf84d300..b71ca60c7 100644 --- a/src/_pytest/doctest.py +++ b/src/_pytest/doctest.py @@ -41,6 +41,7 @@ from _pytest.outcomes import OutcomeException from _pytest.outcomes import skip from _pytest.pathlib import fnmatch_ex from _pytest.pathlib import import_path +from _pytest.python import DeprecatingFuncArgs from _pytest.python import Module from _pytest.python_api import approx from _pytest.warning_types import PytestWarning @@ -286,7 +287,9 @@ class DoctestItem(Item): return super().from_parent(name=name, parent=parent, runner=runner, dtest=dtest) def _initrequest(self) -> None: - self.funcargs: Dict[str, object] = {} + self.funcargs: Dict[str, object] = DeprecatingFuncArgs( + self._fixtureinfo.initialnames + ) self._request = TopRequest(self, _ispytest=True) # type: ignore[arg-type] def setup(self) -> None: diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index b0bbc0e04..24a8a703a 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -679,7 +679,8 @@ class TopRequest(FixtureRequest): def _fillfixtures(self) -> None: item = self._pyfuncitem - for argname in item.fixturenames: + fixturenames = getattr(item, "fixturenames", self.fixturenames) + for argname in fixturenames: if argname not in item.funcargs: item.funcargs[argname] = self.getfixturevalue(argname) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index a5ae48134..65700a2f9 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -15,6 +15,7 @@ import types from typing import Any from typing import Callable from typing import Dict +from typing import Final from typing import final from typing import Generator from typing import Iterable @@ -56,6 +57,7 @@ from _pytest.config import ExitCode from _pytest.config import hookimpl from _pytest.config.argparsing import Parser from _pytest.deprecated import check_ispytest +from _pytest.deprecated import ITEM_FUNCARGS_MEMBERS from _pytest.fixtures import FixtureDef from _pytest.fixtures import FixtureRequest from _pytest.fixtures import FuncFixtureInfo @@ -1650,6 +1652,19 @@ def write_docstring(tw: TerminalWriter, doc: str, indent: str = " ") -> None: tw.line(indent + line) +class DeprecatingFuncArgs(Dict[str, object]): + def __init__(self, initialnames: Sequence[str]) -> None: + super().__init__() + self.warned: bool = False + self.initialnames: Final = initialnames + + def __getitem__(self, key: str) -> object: + if not self.warned and key not in self.initialnames: + self.warned = True + warnings.warn(ITEM_FUNCARGS_MEMBERS, stacklevel=2) + return super().__getitem__(key) + + class Function(PyobjMixin, nodes.Item): """Item responsible for setting up and executing a Python test function. @@ -1738,7 +1753,9 @@ class Function(PyobjMixin, nodes.Item): return super().from_parent(parent=parent, **kw) def _initrequest(self) -> None: - self.funcargs: Dict[str, object] = {} + self.funcargs: Dict[str, object] = DeprecatingFuncArgs( + self._fixtureinfo.initialnames + ) self._request = fixtures.TopRequest(self, _ispytest=True) @property diff --git a/testing/deprecated_test.py b/testing/deprecated_test.py index 52752d4e8..00c70794a 100644 --- a/testing/deprecated_test.py +++ b/testing/deprecated_test.py @@ -134,3 +134,32 @@ def test_fixture_disallowed_between_marks(): raise NotImplementedError() assert len(record) == 2 # one for each mark decorator + + +def test_deprecated_access_to_item_funcargs(pytester: Pytester) -> None: + pytester.makepyfile( + """ + import pytest + + @pytest.fixture + def fixture1(): + return None + + @pytest.fixture + def fixture2(fixture1): + return None + + def test(request, fixture2): + with pytest.warns( + pytest.PytestRemovedIn9Warning, + match=r"Accessing `item.funcargs` with a fixture", + ) as record: + request.node.funcargs["fixture1"] + assert request.node.funcargs.warned + request.node.funcargs.warned = False + request.node.funcargs["fixture2"] + assert len(record) == 1 + """ + ) + output = pytester.runpytest() + output.assert_outcomes(passed=1) diff --git a/testing/test_doctest.py b/testing/test_doctest.py index 372528490..68fe805e9 100644 --- a/testing/test_doctest.py +++ b/testing/test_doctest.py @@ -878,6 +878,41 @@ class TestDoctests: result = pytester.runpytest(p, "--doctest-modules") result.stdout.fnmatch_lines(["*collected 1 item*"]) + def test_deprecated_access_to_item_funcargs(self, pytester: Pytester): + pytester.makeconftest( + """ + import pytest + + @pytest.fixture + def fixture1(): + return None + + @pytest.fixture(autouse=True) + def fixture2(fixture1): + return None + """ + ) + pytester.makepyfile( + """ + ''' + >>> import pytest + >>> request = getfixture('request') + >>> with pytest.warns( + ... pytest.PytestRemovedIn9Warning, + ... match=r"Accessing `item.funcargs` with a fixture", + ... ) as record: + ... request.node.funcargs["fixture1"] + ... assert request.node.funcargs.warned + ... request.node.funcargs.warned = False + ... request.node.funcargs["fixture2"] + >>> len(record) + 1 + ''' + """ + ) + result = pytester.runpytest("--doctest-modules") + result.assert_outcomes(passed=1) + class TestLiterals: @pytest.mark.parametrize("config_mode", ["ini", "comment"])