From 720c4585b5e4e0ab9eb9b60288995a534314bb7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20=27Khorne=27=20Lowas-Rzechonek?= Date: Mon, 20 Jun 2022 12:41:41 +0200 Subject: [PATCH] Add pytest.fixture(indirect=...) argument closes #10101 --- AUTHORS | 1 + changelog/10101.feature.rst | 2 + doc/en/example/parametrize.rst | 18 ++++++ src/_pytest/fixtures.py | 17 +++++- src/_pytest/python.py | 31 +++++++--- testing/python/fixtures.py | 102 +++++++++++++++++++++++++++++++++ testing/python/metafunc.py | 3 +- 7 files changed, 163 insertions(+), 11 deletions(-) create mode 100644 changelog/10101.feature.rst diff --git a/AUTHORS b/AUTHORS index 8530e2e01..680abd021 100644 --- a/AUTHORS +++ b/AUTHORS @@ -238,6 +238,7 @@ Michael Goerz Michael Krebs Michael Seifert Michal Wajszczuk +Michał Lowas-Rzechonek Michał Zięba Mihai Capotă Mike Hoyle (hoylemd) diff --git a/changelog/10101.feature.rst b/changelog/10101.feature.rst new file mode 100644 index 000000000..34004b751 --- /dev/null +++ b/changelog/10101.feature.rst @@ -0,0 +1,2 @@ +Add an optional ``indirect`` argument for ``pytest.fixture`` to make all +parametrizations of that fixture indirect by default. diff --git a/doc/en/example/parametrize.rst b/doc/en/example/parametrize.rst index f81476839..d9b70f877 100644 --- a/doc/en/example/parametrize.rst +++ b/doc/en/example/parametrize.rst @@ -372,6 +372,24 @@ test: This can be used, for example, to do more expensive setup at test run time in the fixture, rather than having to run those setup steps at collection time. +It's also possible to configure this on the fixture level, making all tests +using that fixture indirectly parametrized by default: + +.. code-block:: python + + import pytest + + + @pytest.fixture(indirect=True) + def fixt(request): + return request.param * 3 + + + @pytest.mark.parametrize("fixt", ["a", "b"]) + def test_indirect(fixt): + assert len(fixt) == 3 + + .. regendoc:wipe Apply indirect on particular arguments diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 32c3ec4b0..948202d26 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -212,6 +212,7 @@ def add_funcarg_pseudo_fixture_def( func=get_direct_param_fixture_func, scope=arg2scope[argname], params=valuelist, + indirect=False, unittest=False, ids=None, ) @@ -943,6 +944,7 @@ class FixtureDef(Generic[FixtureValue]): func: "_FixtureFunc[FixtureValue]", scope: Union[Scope, "_ScopeName", Callable[[str, Config], "_ScopeName"], None], params: Optional[Sequence[object]], + indirect: bool = False, unittest: bool = False, ids: Optional[ Union[Tuple[Optional[object], ...], Callable[[Any], Optional[object]]] @@ -987,6 +989,8 @@ class FixtureDef(Generic[FixtureValue]): # assign to the parameter values, or a callable to generate an ID given # a parameter value. self.ids = ids + # Whether the fixture should be always indirectly parametrized + self.indirect = indirect # The names requested by the fixtures. self.argnames = getfuncargnames(func, name=argname, is_method=unittest) # Whether the fixture was collected from a unittest TestCase class. @@ -1174,6 +1178,7 @@ class FixtureFunctionMarker: converter=_ensure_immutable_ids, ) name: Optional[str] = None + indirect: bool = False def __call__(self, function: FixtureFunction) -> FixtureFunction: if inspect.isclass(function): @@ -1241,6 +1246,7 @@ def fixture( Union[Sequence[Optional[object]], Callable[[Any], Optional[object]]] ] = None, name: Optional[str] = None, + indirect: bool = False, ) -> Union[FixtureFunctionMarker, FixtureFunction]: """Decorator to mark a fixture factory function. @@ -1298,6 +1304,7 @@ def fixture( autouse=autouse, ids=ids, name=name, + indirect=indirect, ) # Direct decoration. @@ -1417,7 +1424,11 @@ class FixtureManager: p_argnames, _ = ParameterSet._parse_parametrize_args( *marker.args, **marker.kwargs ) - parametrize_argnames.extend(p_argnames) + for argname in p_argnames: + fixturedefs = self.getfixturedefs(argname, node.nodeid) or [] + if any(f.indirect for f in fixturedefs): + continue + parametrize_argnames.append(argname) return parametrize_argnames @@ -1614,10 +1625,14 @@ class FixtureManager: func=obj, scope=marker.scope, params=marker.params, + indirect=marker.indirect, unittest=unittest, ids=marker.ids, ) + if marker.indirect: + fixture_def.params = fixture_def.params or [] + faclist = self._arg2fixturedefs.setdefault(name, []) if fixture_def.has_location: faclist.append(fixture_def) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 91054f370..c47d02293 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -1194,8 +1194,8 @@ class Metafunc: #: Underlying Python test function. self.function = definition.obj - #: Set of fixture names required by the test function. - self.fixturenames = fixtureinfo.names_closure + #: Set of fixtures required by the test function + self.fixtureinfo = fixtureinfo #: Class object where the test function is defined in or ``None``. self.cls = cls @@ -1205,6 +1205,10 @@ class Metafunc: # Result of parametrize(). self._calls: List[CallSpec2] = [] + @property + def fixturenames(self): + return self.fixtureinfo.names_closure + def parametrize( self, argnames: Union[str, List[str], Tuple[str, ...]], @@ -1300,8 +1304,23 @@ class Metafunc: else: scope_ = _find_parametrized_scope(argnames, self._arg2fixturedefs, indirect) + if not isinstance(indirect, (bool, Sequence)): + fail( + "In {func}: expected Sequence or boolean for indirect, got {type}".format( + type=type(indirect).__name__, func=self.function.__name__ + ), + pytrace=False, + ) + self._validate_if_using_arg_names(argnames, indirect) + if indirect is not True: # False, or a list + indirect = list(indirect or []) + for argname in argnames: + fixturedefs = self.fixtureinfo.name2fixturedefs.get(argname, []) + if any(f.indirect for f in fixturedefs): + indirect.append(argname) + arg_values_types = self._resolve_arg_value_types(argnames, indirect) # Use any already (possibly) generated ids with parametrize Marks. @@ -1435,13 +1454,7 @@ class Metafunc: pytrace=False, ) valtypes[arg] = "params" - else: - fail( - "In {func}: expected Sequence or boolean for indirect, got {type}".format( - type=type(indirect).__name__, func=self.function.__name__ - ), - pytrace=False, - ) + return valtypes def _validate_if_using_arg_names( diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index 3ce5cb34d..e36e66439 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -4468,3 +4468,105 @@ def test_yield_fixture_with_no_value(pytester: Pytester) -> None: result.assert_outcomes(errors=1) result.stdout.fnmatch_lines([expected]) assert result.ret == ExitCode.TESTS_FAILED + + +def test_fixture_indirect(pytester: Pytester) -> None: + pytester.makeconftest( + """ + import pytest + @pytest.fixture(indirect=True) + def indirect_sum(request): + return sum(request.param) + + @pytest.fixture + def indirect_reversed(request): + return list(reversed(request.param)) + """ + ) + + pytester.makepyfile( + """ + import pytest + @pytest.mark.parametrize("indirect_sum, indirect_reversed, direct_list", + [ + ([1,2,3], [1,2,3], [1,2,3]) + ], + indirect=["indirect_reversed"] + ) + def test_indirect_sum(indirect_sum, indirect_reversed, direct_list): + assert indirect_sum == 6 + assert indirect_reversed == [3, 2, 1] + assert direct_list == [1, 2, 3] + """ + ) + result = pytester.runpytest() + result.assert_outcomes(passed=1) + + +def test_fixture_indirect_always(pytester: Pytester) -> None: + pytester.makeconftest( + """ + import pytest + @pytest.fixture(indirect=True) + def indirect_sum(request): + return sum(request.param) + """ + ) + + pytester.makepyfile( + """ + import pytest + @pytest.mark.parametrize("indirect_sum", + [ + ([1,2,3]) + ], + indirect=[] + ) + def test_indirect_sum(indirect_sum): + assert indirect_sum == 6 + """ + ) + result = pytester.runpytest() + result.assert_outcomes(passed=1) + + +def test_fixture_indirect_no_params(pytester: Pytester) -> None: + pytester.makeconftest( + """ + import pytest + @pytest.fixture(indirect=True) + def indirect_skip(request): + pass + """ + ) + + pytester.makepyfile( + """ + import pytest + def test_indirect_skip(indirect_skip): + pass + """ + ) + result = pytester.runpytest() + result.assert_outcomes(skipped=1) + + +def test_fixture_indirect_default_params(pytester: Pytester) -> None: + pytester.makeconftest( + """ + import pytest + @pytest.fixture(indirect=True, params=[1]) + def indirect_default(request): + return request.param + """ + ) + + pytester.makepyfile( + """ + import pytest + def test_indirect_sum(indirect_default): + assert indirect_default == 1 + """ + ) + result = pytester.runpytest() + result.assert_outcomes(passed=1) diff --git a/testing/python/metafunc.py b/testing/python/metafunc.py index 2fed22718..1332730e2 100644 --- a/testing/python/metafunc.py +++ b/testing/python/metafunc.py @@ -22,6 +22,7 @@ from _pytest import python from _pytest.compat import _format_args from _pytest.compat import getfuncargnames from _pytest.compat import NOTSET +from _pytest.fixtures import FixtureDef from _pytest.outcomes import fail from _pytest.pytester import Pytester from _pytest.python import IdMaker @@ -34,7 +35,7 @@ class TestMetafunc: # on the funcarg level, so we don't need a full blown # initialization. class FuncFixtureInfoMock: - name2fixturedefs = None + name2fixturedefs: Dict[str, Sequence["FixtureDef[Any]"]] = {} def __init__(self, names): self.names_closure = names